diff --git a/order-import.php b/order-import.php index ef34cbc..cb9a2a9 100644 --- a/order-import.php +++ b/order-import.php @@ -430,6 +430,456 @@ function upsert_addresses(PDO $pdo, int $partyId, array $data): void ]); } +function normalize_title_key(string $value): string +{ + $value = trim(strtolower($value)); + $value = preg_replace('/\s+/', ' ', $value) ?? $value; + return $value; +} + +function find_existing_order_id(PDO $pdo, string $externalRef): ?int +{ + $stmt = $pdo->prepare('SELECT id FROM public.sales_order WHERE external_ref = :external_ref LIMIT 1'); + $stmt->execute([':external_ref' => $externalRef]); + $id = $stmt->fetchColumn(); + return $id === false ? null : (int) $id; +} + +function get_default_location_ids(PDO $pdo): array +{ + $storageId = $pdo->query("SELECT id FROM public.location WHERE type = 'storage' ORDER BY id LIMIT 1")->fetchColumn(); + $dispatchId = $pdo->query("SELECT id FROM public.location WHERE type = 'dispatch' ORDER BY id LIMIT 1")->fetchColumn(); + + if ($storageId === false || $dispatchId === false) { + $warehouseId = $pdo->query('SELECT id FROM public.warehouse ORDER BY id LIMIT 1')->fetchColumn(); + if ($warehouseId === false) { + $createWarehouse = $pdo->prepare( + "INSERT INTO public.warehouse (code, name, created_at, updated_at) + VALUES ('MAIN', 'Main Warehouse', NOW(), NOW()) + RETURNING id" + ); + $createWarehouse->execute(); + $warehouseId = $createWarehouse->fetchColumn(); + } + $warehouseId = (int) $warehouseId; + + if ($storageId === false) { + $existingStorage = $pdo->prepare( + "SELECT id FROM public.location + WHERE warehouse_id = :warehouse_id AND type = 'storage' + ORDER BY id LIMIT 1" + ); + $existingStorage->execute([':warehouse_id' => $warehouseId]); + $storageId = $existingStorage->fetchColumn(); + if ($storageId === false) { + $insertStorage = $pdo->prepare( + "INSERT INTO public.location (warehouse_id, code, name, type, created_at, updated_at) + VALUES (:warehouse_id, 'STORAGE', 'Storage', 'storage', NOW(), NOW()) + RETURNING id" + ); + $insertStorage->execute([':warehouse_id' => $warehouseId]); + $storageId = $insertStorage->fetchColumn(); + } + } + + if ($dispatchId === false) { + $existingDispatch = $pdo->prepare( + "SELECT id FROM public.location + WHERE warehouse_id = :warehouse_id AND type = 'dispatch' + ORDER BY id LIMIT 1" + ); + $existingDispatch->execute([':warehouse_id' => $warehouseId]); + $dispatchId = $existingDispatch->fetchColumn(); + if ($dispatchId === false) { + $insertDispatch = $pdo->prepare( + "INSERT INTO public.location (warehouse_id, code, name, type, created_at, updated_at) + VALUES (:warehouse_id, 'DISPATCH', 'Dispatch', 'dispatch', NOW(), NOW()) + RETURNING id" + ); + $insertDispatch->execute([':warehouse_id' => $warehouseId]); + $dispatchId = $insertDispatch->fetchColumn(); + } + } + } + + if ($storageId === false || $dispatchId === false) { + throw new RuntimeException('Missing required locations after auto-bootstrap'); + } + + return [ + 'storage' => (int) $storageId, + 'dispatch' => (int) $dispatchId, + ]; +} + +function resolve_sellable_item_id(PDO $pdo, string $articleNumber, string $title): ?int +{ + $articleNumber = trim($articleNumber); + $title = trim($title); + $titleNorm = normalize_title_key($title); + + $stmt = $pdo->prepare( + "SELECT sellable_item_id + FROM public.external_item_alias + WHERE source_system = 'wix' + AND is_active = TRUE + AND ( + (:article_number <> '' AND external_article_number = :article_number) + OR (:title_norm <> '' AND title_normalized = :title_norm) + OR (:title <> '' AND lower(external_title) = lower(:title)) + ) + ORDER BY + CASE + WHEN :article_number <> '' AND external_article_number = :article_number THEN 0 + WHEN :title_norm <> '' AND title_normalized = :title_norm THEN 1 + ELSE 2 + END, + id + LIMIT 1" + ); + $stmt->execute([ + ':article_number' => $articleNumber, + ':title_norm' => $titleNorm, + ':title' => $title, + ]); + $id = $stmt->fetchColumn(); + + return $id === false ? null : (int) $id; +} + +function get_item_components(PDO $pdo, int $sellableItemId): array +{ + $stmt = $pdo->prepare( + 'SELECT product_id, qty_per_item + FROM public.sellable_item_component + WHERE sellable_item_id = :sellable_item_id + ORDER BY id' + ); + $stmt->execute([':sellable_item_id' => $sellableItemId]); + return $stmt->fetchAll(); +} + +function resolve_product_id_fallback(PDO $pdo, string $articleNumber, string $title): ?int +{ + $articleNumber = trim($articleNumber); + $title = trim($title); + + if ($articleNumber !== '') { + $stmt = $pdo->prepare('SELECT id FROM public.product WHERE sku = :sku ORDER BY id LIMIT 1'); + $stmt->execute([':sku' => $articleNumber]); + $id = $stmt->fetchColumn(); + if ($id !== false) { + return (int) $id; + } + } + + if ($title !== '') { + $stmt = $pdo->prepare('SELECT id FROM public.product WHERE lower(name) = lower(:name) ORDER BY id LIMIT 1'); + $stmt->execute([':name' => $title]); + $id = $stmt->fetchColumn(); + if ($id !== false) { + return (int) $id; + } + } + + return null; +} + +function get_current_lot_balance_for_update(PDO $pdo, int $productId): array +{ + $lotStmt = $pdo->prepare( + "SELECT id + FROM public.stock_lot + WHERE product_id = :product_id + AND status = 'current' + ORDER BY id + LIMIT 1 + FOR UPDATE" + ); + $lotStmt->execute([':product_id' => $productId]); + $lotId = $lotStmt->fetchColumn(); + if ($lotId === false) { + throw new RuntimeException("No current lot found for product {$productId}"); + } + + $balStmt = $pdo->prepare('SELECT qty_net FROM public.v_stock_lot_balance WHERE stock_lot_id = :lot_id'); + $balStmt->execute([':lot_id' => (int) $lotId]); + $qtyNet = $balStmt->fetchColumn(); + + return [ + 'lot_id' => (int) $lotId, + 'qty_net' => $qtyNet === false ? 0.0 : (float) $qtyNet, + ]; +} + +function switch_current_lot(PDO $pdo, int $productId, int $oldCurrentLotId): int +{ + $closeStmt = $pdo->prepare( + "UPDATE public.stock_lot + SET status = 'closed', updated_at = NOW() + WHERE id = :id AND status = 'current'" + ); + $closeStmt->execute([':id' => $oldCurrentLotId]); + + $openStmt = $pdo->prepare( + "SELECT id + FROM public.stock_lot + WHERE product_id = :product_id + AND status = 'open' + ORDER BY id + LIMIT 1 + FOR UPDATE" + ); + $openStmt->execute([':product_id' => $productId]); + $newCurrentLotId = $openStmt->fetchColumn(); + if ($newCurrentLotId === false) { + throw new RuntimeException("No open lot available for product {$productId} during switch"); + } + + $makeCurrentStmt = $pdo->prepare( + "UPDATE public.stock_lot + SET status = 'current', updated_at = NOW() + WHERE id = :id" + ); + $makeCurrentStmt->execute([':id' => (int) $newCurrentLotId]); + + $createOpenStmt = $pdo->prepare( + "INSERT INTO public.stock_lot (product_id, lot_number, status, created_at, updated_at) + VALUES (:product_id, NULL, 'open', NOW(), NOW())" + ); + $createOpenStmt->execute([':product_id' => $productId]); + + return (int) $newCurrentLotId; +} + +function insert_stock_move_out( + PDO $pdo, + int $productId, + int $lotId, + float $qty, + int $fromLocationId, + int $toLocationId, + string $note +): int { + $stmt = $pdo->prepare( + "INSERT INTO public.stock_move ( + product_id, lot_id, from_location_id, to_location_id, qty, move_type, note, move_date, created_at, updated_at + ) VALUES ( + :product_id, :lot_id, :from_location_id, :to_location_id, :qty, 'out', :note, NOW(), NOW(), NOW() + ) + RETURNING id" + ); + $stmt->execute([ + ':product_id' => $productId, + ':lot_id' => $lotId, + ':from_location_id' => $fromLocationId, + ':to_location_id' => $toLocationId, + ':qty' => $qty, + ':note' => $note, + ]); + $id = $stmt->fetchColumn(); + if ($id === false) { + throw new RuntimeException('Could not create stock_move out'); + } + return (int) $id; +} + +function insert_stock_move_in( + PDO $pdo, + int $productId, + int $lotId, + float $qty, + int $toLocationId, + string $note +): int { + $stmt = $pdo->prepare( + "INSERT INTO public.stock_move ( + product_id, lot_id, from_location_id, to_location_id, qty, move_type, note, move_date, created_at, updated_at + ) VALUES ( + :product_id, :lot_id, NULL, :to_location_id, :qty, 'in', :note, NOW(), NOW(), NOW() + ) + RETURNING id" + ); + $stmt->execute([ + ':product_id' => $productId, + ':lot_id' => $lotId, + ':to_location_id' => $toLocationId, + ':qty' => $qty, + ':note' => $note, + ]); + $id = $stmt->fetchColumn(); + if ($id === false) { + throw new RuntimeException('Could not create stock_move in'); + } + return (int) $id; +} + +function reverse_existing_allocations_for_order(PDO $pdo, int $orderId, int $fallbackStorageLocationId): array +{ + $stmt = $pdo->prepare( + "SELECT + a.id AS allocation_id, + a.product_id, + a.lot_id, + a.qty, + a.stock_move_id, + sm.from_location_id + FROM public.sales_order_line sol + JOIN public.sales_order_line_lot_allocation a ON a.sales_order_line_id = sol.id + LEFT JOIN public.stock_move sm ON sm.id = a.stock_move_id + WHERE sol.sales_order_id = :order_id + AND a.stock_move_id IS NOT NULL" + ); + $stmt->execute([':order_id' => $orderId]); + $rows = $stmt->fetchAll(); + + $reversedMoves = 0; + $reversedQty = 0.0; + + foreach ($rows as $row) { + $qty = (float) $row['qty']; + if ($qty <= 0) { + continue; + } + + $toLocationId = $row['from_location_id'] !== null + ? (int) $row['from_location_id'] + : $fallbackStorageLocationId; + + insert_stock_move_in( + $pdo, + (int) $row['product_id'], + (int) $row['lot_id'], + $qty, + $toLocationId, + "order-import-reverse:order={$orderId}:alloc=" . (int) $row['allocation_id'] + ); + + $reversedMoves++; + $reversedQty += $qty; + } + + return [ + 'reversedMoves' => $reversedMoves, + 'reversedQty' => round($reversedQty, 4), + ]; +} + +function allocate_components_for_line( + PDO $pdo, + int $orderId, + int $lineId, + int $lineNo, + array $components, + float $lineQty, + array $locations +): array { + if ($components === []) { + return [ + 'allocated' => false, + 'reason' => 'no_components', + 'allocations' => [], + ]; + } + + $allocationInsert = $pdo->prepare( + "INSERT INTO public.sales_order_line_lot_allocation ( + sales_order_line_id, product_id, lot_id, qty, allocation_status, stock_move_id, created_at, updated_at + ) VALUES ( + :sales_order_line_id, :product_id, :lot_id, :qty, 'allocated', :stock_move_id, NOW(), NOW() + )" + ); + + $allocations = []; + + foreach ($components as $component) { + $productId = (int) $component['product_id']; + $required = $lineQty * (float) $component['qty_per_item']; + $remaining = $required; + $guard = 0; + + while ($remaining > 0.0000001) { + $guard++; + if ($guard > 100) { + throw new RuntimeException("Inventory allocation loop exceeded guard for product {$productId}"); + } + + $current = get_current_lot_balance_for_update($pdo, $productId); + $lotId = (int) $current['lot_id']; + $available = (float) $current['qty_net']; + + if ($available <= 0.0000001) { + switch_current_lot($pdo, $productId, $lotId); + continue; + } + + $take = min($remaining, $available); + $note = "order-import:order={$orderId}:line={$lineNo}:product={$productId}"; + $stockMoveId = insert_stock_move_out( + $pdo, + $productId, + $lotId, + $take, + $locations['storage'], + $locations['dispatch'], + $note + ); + + $allocationInsert->execute([ + ':sales_order_line_id' => $lineId, + ':product_id' => $productId, + ':lot_id' => $lotId, + ':qty' => $take, + ':stock_move_id' => $stockMoveId, + ]); + + $allocations[] = [ + 'productId' => $productId, + 'lotId' => $lotId, + 'qty' => round($take, 4), + 'stockMoveId' => $stockMoveId, + ]; + + $remaining -= $take; + } + } + + return [ + 'allocated' => true, + 'reason' => '', + 'allocations' => $allocations, + ]; +} + +function allocate_line_inventory( + PDO $pdo, + int $orderId, + int $lineId, + int $lineNo, + float $lineQty, + int $sellableItemId, + array $locations +): array { + $components = get_item_components($pdo, $sellableItemId); + return allocate_components_for_line($pdo, $orderId, $lineId, $lineNo, $components, $lineQty, $locations); +} + +function allocate_line_inventory_fallback_product( + PDO $pdo, + int $orderId, + int $lineId, + int $lineNo, + float $lineQty, + int $productId, + array $locations +): array { + $components = [[ + 'product_id' => $productId, + 'qty_per_item' => 1.0, + ]]; + return allocate_components_for_line($pdo, $orderId, $lineId, $lineNo, $components, $lineQty, $locations); +} + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { json_response(405, ['ok' => false, 'error' => 'Method Not Allowed']); } @@ -486,6 +936,8 @@ try { $pdo = connect_database($env); ensure_required_tables_exist($pdo); $pdo->beginTransaction(); + $locations = get_default_location_ids($pdo); + $existingOrderId = find_existing_order_id($pdo, $externalRef); $partyId = find_or_create_party($pdo, $data); upsert_addresses($pdo, $partyId, $data); @@ -539,6 +991,14 @@ try { } $orderId = (int) $orderId; + $inventoryRollback = [ + 'reversedMoves' => 0, + 'reversedQty' => 0.0, + ]; + if ($existingOrderId !== null) { + $inventoryRollback = reverse_existing_allocations_for_order($pdo, $existingOrderId, $locations['storage']); + } + $deleteLines = $pdo->prepare('DELETE FROM public.sales_order_line WHERE sales_order_id = :sales_order_id'); $deleteLines->execute([':sales_order_id' => $orderId]); @@ -547,17 +1007,28 @@ try { sales_order_id, line_no, sellable_item_id, raw_external_article_number, raw_external_title, qty, unit_price, line_total, created_at, updated_at ) VALUES ( - :sales_order_id, :line_no, NULL, :article_number, :title, + :sales_order_id, :line_no, :sellable_item_id, :article_number, :title, :qty, :unit_price, :line_total, NOW(), NOW() - )' + ) + RETURNING id' ); $insertedLines = 0; + $inventory = [ + 'linesMapped' => 0, + 'linesMappedViaFallbackProduct' => 0, + 'linesUnmapped' => 0, + 'allocationCount' => 0, + 'warnings' => [], + ]; + foreach ($lineItems as $index => $lineItem) { if (!is_array($lineItem)) { continue; } + $articleNumber = trim((string) ($lineItem['artikelnummer'] ?? '')); + $title = trim((string) ($lineItem['titel'] ?? '')); $qty = parse_number($lineItem['artikelanzahl'] ?? null); if ($qty === null || $qty <= 0) { continue; @@ -565,16 +1036,66 @@ try { $unitPrice = parse_number($lineItem['preisEinheit'] ?? null); $lineTotal = $unitPrice !== null ? round($qty * $unitPrice, 2) : null; + $sellableItemId = resolve_sellable_item_id($pdo, $articleNumber, $title); + $lineNo = $index + 1; $lineInsert->execute([ ':sales_order_id' => $orderId, - ':line_no' => $index + 1, - ':article_number' => trim((string) ($lineItem['artikelnummer'] ?? '')), - ':title' => trim((string) ($lineItem['titel'] ?? '')), + ':line_no' => $lineNo, + ':sellable_item_id' => $sellableItemId, + ':article_number' => $articleNumber, + ':title' => $title, ':qty' => $qty, ':unit_price' => $unitPrice, ':line_total' => $lineTotal, ]); + $lineId = $lineInsert->fetchColumn(); + if ($lineId === false) { + throw new RuntimeException("Could not insert sales_order_line for line {$lineNo}"); + } + $lineId = (int) $lineId; + + if ($sellableItemId === null) { + $fallbackProductId = resolve_product_id_fallback($pdo, $articleNumber, $title); + if ($fallbackProductId !== null) { + $inventory['linesMappedViaFallbackProduct']++; + $allocationResult = allocate_line_inventory_fallback_product( + $pdo, + $orderId, + $lineId, + $lineNo, + (float) $qty, + $fallbackProductId, + $locations + ); + if ($allocationResult['allocated'] === false) { + $inventory['warnings'][] = "Fallback product allocation missing for line {$lineNo}: " . $allocationResult['reason']; + } else { + $inventory['allocationCount'] += count($allocationResult['allocations']); + $inventory['warnings'][] = "Line {$lineNo} allocated via fallback product mapping (product_id={$fallbackProductId})"; + } + } else { + $inventory['linesUnmapped']++; + $inventory['warnings'][] = "No sellable item mapping for line {$lineNo} (artikelnummer='{$articleNumber}', titel='{$title}')"; + } + } else { + $inventory['linesMapped']++; + $allocationResult = allocate_line_inventory( + $pdo, + $orderId, + $lineId, + $lineNo, + (float) $qty, + $sellableItemId, + $locations + ); + if ($allocationResult['allocated'] === false) { + $inventory['warnings'][] = "No inventory allocation for line {$lineNo}: " . $allocationResult['reason']; + } else { + $inventory['allocationCount'] += count($allocationResult['allocations']); + } + } + $insertedLines++; } @@ -587,6 +1108,8 @@ try { 'orderId' => $orderId, 'externalRef' => $externalRef, 'lineItemsImported' => $insertedLines, + 'inventory' => $inventory, + 'inventoryRollback' => $inventoryRollback, 'labelTrigger' => $labelTrigger, ]); } catch (Throwable $e) {