From 68fb6e138c01266f948f805409c7351cf7d37fe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Gla=CC=88ser?= Date: Sun, 29 Mar 2026 22:01:19 +0200 Subject: [PATCH] Support bundle title parsing into single inventory products --- order-import.php | 236 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 192 insertions(+), 44 deletions(-) diff --git a/order-import.php b/order-import.php index ae2b7c2..61f4156 100644 --- a/order-import.php +++ b/order-import.php @@ -582,18 +582,8 @@ function get_item_components(PDO $pdo, int $sellableItemId): array 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]); @@ -603,42 +593,188 @@ function resolve_product_id_fallback(PDO $pdo, string $articleNumber, string $ti } } - // Business fallback for known product families where Wix title differs from ERP product label. - $search = normalize_match_key($articleNumber . ' ' . $title); - if ($search !== '') { - $familyPatterns = []; + return null; +} - if (str_contains($search, 'lion') || str_contains($search, 'mane')) { - $familyPatterns[] = '%lionsmane%'; - $familyPatterns[] = '%lion%'; - } - if (str_contains($search, 'shiitake')) { - $familyPatterns[] = '%shiitake%'; - } - if (str_contains($search, 'chaga')) { - $familyPatterns[] = '%chaga%'; - } - if (str_contains($search, 'reishi')) { - $familyPatterns[] = '%reishi%'; - } +function detect_product_family_key(string $normalizedName): ?string +{ + if (str_contains($normalizedName, 'lion') && str_contains($normalizedName, 'mane')) { + return 'lionsmane'; + } + if (str_contains($normalizedName, 'chaga')) { + return 'chaga'; + } + if (str_contains($normalizedName, 'reishi')) { + return 'reishi'; + } + if (str_contains($normalizedName, 'shiitake')) { + return 'shiitake'; + } + return null; +} - foreach ($familyPatterns as $pattern) { - $stmt = $pdo->prepare( - 'SELECT id - FROM public.product - WHERE lower(name) LIKE :pattern - ORDER BY id - LIMIT 1' - ); - $stmt->execute([':pattern' => $pattern]); - $id = $stmt->fetchColumn(); - if ($id !== false) { - return (int) $id; - } - } +function title_contains_family(string $normalizedTitle, string $familyKey): bool +{ + if ($familyKey === 'lionsmane') { + return str_contains($normalizedTitle, 'lion') && str_contains($normalizedTitle, 'mane'); + } + return str_contains($normalizedTitle, $familyKey); +} + +function infer_components_from_title(PDO $pdo, string $title): array +{ + $normalizedTitle = normalize_match_key($title); + if ($normalizedTitle === '') { + return []; } - return null; + $products = $pdo->query("SELECT id, name FROM public.product WHERE status = 'active' ORDER BY id")->fetchAll(); + $components = []; + $usedProductIds = []; + + foreach ($products as $product) { + $productId = (int) $product['id']; + $productNameNorm = normalize_match_key((string) ($product['name'] ?? '')); + $familyKey = detect_product_family_key($productNameNorm); + if ($familyKey === null) { + continue; + } + + if (!title_contains_family($normalizedTitle, $familyKey)) { + continue; + } + + if (isset($usedProductIds[$productId])) { + continue; + } + + $components[] = [ + 'product_id' => $productId, + 'qty_per_item' => 1.0, + ]; + $usedProductIds[$productId] = true; + } + + return $components; +} + +function find_alias_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 ( + (: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 id + LIMIT 1 + FOR UPDATE" + ); + $stmt->execute([ + ':article_number' => $articleNumber, + ':title_norm' => $titleNorm, + ':title' => $title, + ]); + $id = $stmt->fetchColumn(); + return $id === false ? null : (int) $id; +} + +function ensure_sellable_mapping_from_title_components( + PDO $pdo, + string $articleNumber, + string $title, + array $components +): array { + if ($components === []) { + throw new RuntimeException('Cannot create sellable mapping without components'); + } + + $sellableItemId = find_alias_sellable_item_id($pdo, $articleNumber, $title); + $createdSellable = false; + + if ($sellableItemId === null || !sellable_item_exists($pdo, $sellableItemId)) { + $itemCodeSeed = trim($articleNumber) !== '' ? trim($articleNumber) : $title; + $itemCode = ensure_unique_sellable_item_code($pdo, $itemCodeSeed); + $displayName = trim($title) !== '' ? trim($title) : $itemCode; + + $insertSellable = $pdo->prepare( + 'INSERT INTO public.sellable_item (item_code, display_name, status, created_at, updated_at) + VALUES (:item_code, :display_name, \'active\', NOW(), NOW()) + RETURNING id' + ); + $insertSellable->execute([ + ':item_code' => $itemCode, + ':display_name' => $displayName, + ]); + $id = $insertSellable->fetchColumn(); + if ($id === false) { + throw new RuntimeException('Could not create sellable_item from title components'); + } + $sellableItemId = (int) $id; + $createdSellable = true; + } else { + $updateName = $pdo->prepare( + 'UPDATE public.sellable_item + SET display_name = :display_name, updated_at = NOW() + WHERE id = :id' + ); + $updateName->execute([ + ':display_name' => trim($title) !== '' ? trim($title) : "AUTO-ITEM-{$sellableItemId}", + ':id' => $sellableItemId, + ]); + } + + $syncComponent = $pdo->prepare( + 'INSERT INTO public.sellable_item_component (sellable_item_id, product_id, qty_per_item, created_at, updated_at) + VALUES (:sellable_item_id, :product_id, :qty_per_item, NOW(), NOW()) + ON CONFLICT (sellable_item_id, product_id) + DO UPDATE SET qty_per_item = EXCLUDED.qty_per_item, updated_at = NOW()' + ); + $componentIds = []; + foreach ($components as $component) { + $productId = (int) $component['product_id']; + $qtyPerItem = (float) $component['qty_per_item']; + if ($productId <= 0 || $qtyPerItem <= 0) { + continue; + } + $syncComponent->execute([ + ':sellable_item_id' => $sellableItemId, + ':product_id' => $productId, + ':qty_per_item' => $qtyPerItem, + ]); + $componentIds[] = $productId; + } + + if ($componentIds !== []) { + $placeholders = []; + $params = [':sellable_item_id' => $sellableItemId]; + foreach ($componentIds as $idx => $productId) { + $key = ':product_id_' . $idx; + $placeholders[] = $key; + $params[$key] = $productId; + } + $deleteStmt = $pdo->prepare( + 'DELETE FROM public.sellable_item_component + WHERE sellable_item_id = :sellable_item_id + AND product_id NOT IN (' . implode(', ', $placeholders) . ')' + ); + $deleteStmt->execute($params); + } + + $aliasChanged = ensure_alias_points_to_sellable_item($pdo, $sellableItemId, $articleNumber, $title); + + return [ + 'sellableItemId' => $sellableItemId, + 'createdSellableItem' => $createdSellable, + 'aliasCreatedOrUpdated' => $aliasChanged, + ]; } function sellable_item_exists(PDO $pdo, int $sellableItemId): bool @@ -1289,13 +1425,25 @@ try { $lineTotal = $unitPrice !== null ? round($qty * $unitPrice, 2) : null; $sellableItemId = resolve_sellable_item_id($pdo, $articleNumber, $title); $autoMappingMeta = null; + if ($sellableItemId === null) { + $inferredComponents = infer_components_from_title($pdo, $title); + if ($inferredComponents !== []) { + $autoMappingMeta = ensure_sellable_mapping_from_title_components( + $pdo, + $articleNumber, + $title, + $inferredComponents + ); + $sellableItemId = (int) $autoMappingMeta['sellableItemId']; + } + } if ($sellableItemId === null) { $fallbackProductId = resolve_product_id_fallback($pdo, $articleNumber, $title); if ($fallbackProductId !== null) { $autoMappingMeta = ensure_sellable_mapping_from_product_fallback( $pdo, $fallbackProductId, - $articleNumber, + '', $title ); $sellableItemId = (int) $autoMappingMeta['sellableItemId']; @@ -1325,7 +1473,7 @@ try { } else { if (is_array($autoMappingMeta)) { $inventory['linesMappedViaFallbackProduct']++; - $inventory['warnings'][] = "Line {$lineNo} auto-mapped from product fallback (sellable_item_id={$sellableItemId})"; + $inventory['warnings'][] = "Line {$lineNo} auto-mapped from title/product fallback (sellable_item_id={$sellableItemId})"; } $inventory['linesMapped']++; $allocationResult = allocate_line_inventory(