diff --git a/db/README.md b/db/README.md index 56192f3..1b71e4a 100644 --- a/db/README.md +++ b/db/README.md @@ -6,6 +6,7 @@ 2. `db/migrations/0002_phase1_seed_methods.sql` 3. `db/migrations/0003_phase1_inventory_forecast.sql` 4. `db/migrations/0004_phase1_direct_sales.sql` +5. `db/migrations/0005_phase1_auto_switch_fix.sql` ## Beispielausfuehrung @@ -14,6 +15,7 @@ psql "$DATABASE_URL" -f db/migrations/0001_phase1_core.sql psql "$DATABASE_URL" -f db/migrations/0002_phase1_seed_methods.sql psql "$DATABASE_URL" -f db/migrations/0003_phase1_inventory_forecast.sql psql "$DATABASE_URL" -f db/migrations/0004_phase1_direct_sales.sql +psql "$DATABASE_URL" -f db/migrations/0005_phase1_auto_switch_fix.sql ``` ## Enthaltene Kernlogik in `0001` diff --git a/db/migrations/0001_phase1_core.sql b/db/migrations/0001_phase1_core.sql index bfdb9e4..0f17af4 100644 --- a/db/migrations/0001_phase1_core.sql +++ b/db/migrations/0001_phase1_core.sql @@ -665,7 +665,10 @@ BEGIN END IF; UPDATE stock_lot - SET status = 'current', updated_at = NOW() + SET + status = 'current', + lot_number = COALESCE(lot_number, format('AUTO-%s-%s', v_product_id, v_open_lot_id)), + updated_at = NOW() WHERE id = v_open_lot_id; INSERT INTO stock_lot (product_id, lot_number, status) diff --git a/db/migrations/0005_phase1_auto_switch_fix.sql b/db/migrations/0005_phase1_auto_switch_fix.sql new file mode 100644 index 0000000..1e0d67c --- /dev/null +++ b/db/migrations/0005_phase1_auto_switch_fix.sql @@ -0,0 +1,99 @@ +BEGIN; + +-- Fix lot auto-switch when current lot reaches zero: +-- if the next open lot has no lot_number yet, assign one before switching to status=current. +-- This avoids violating chk_stock_lot_number_required_for_non_open. + +CREATE OR REPLACE FUNCTION fn_auto_switch_lot_when_depleted() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + v_product_id BIGINT; + v_open_lot_id BIGINT; + v_current_net NUMERIC; + v_event_key TEXT; +BEGIN + -- only relevant when the updated lot is currently active + SELECT product_id + INTO v_product_id + FROM stock_lot + WHERE id = NEW.lot_id; + + -- if lot is not current anymore, nothing to do + IF NOT EXISTS ( + SELECT 1 FROM stock_lot WHERE id = NEW.lot_id AND status = 'current' + ) THEN + RETURN NEW; + END IF; + + SELECT qty_net + INTO v_current_net + FROM v_stock_lot_balance + WHERE stock_lot_id = NEW.lot_id; + + IF COALESCE(v_current_net, 0) > 0 THEN + RETURN NEW; + END IF; + + -- lock all lots for this product to avoid race conditions + PERFORM 1 + FROM stock_lot + WHERE product_id = v_product_id + FOR UPDATE; + + -- confirm current lot is still current after lock + IF NOT EXISTS ( + SELECT 1 FROM stock_lot WHERE id = NEW.lot_id AND status = 'current' + ) THEN + RETURN NEW; + END IF; + + UPDATE stock_lot + SET status = 'closed', updated_at = NOW() + WHERE id = NEW.lot_id; + + SELECT id + INTO v_open_lot_id + FROM stock_lot + WHERE product_id = v_product_id + AND status = 'open' + ORDER BY id + LIMIT 1 + FOR UPDATE; + + IF v_open_lot_id IS NULL THEN + RAISE EXCEPTION 'No open lot available for product % during auto switch', v_product_id; + END IF; + + -- current/closed lots must have a lot_number due to chk_stock_lot_number_required_for_non_open. + UPDATE stock_lot + SET + status = 'current', + lot_number = COALESCE(lot_number, format('AUTO-%s-%s', v_product_id, v_open_lot_id)), + updated_at = NOW() + WHERE id = v_open_lot_id; + + INSERT INTO stock_lot (product_id, lot_number, status) + VALUES (v_product_id, NULL, 'open'); + + v_event_key := format('lot.auto_switched:%s:%s', v_product_id, txid_current()); + + PERFORM fn_enqueue_event( + 'lot.auto_switched', + v_event_key, + 'stock_lot', + NEW.lot_id::TEXT, + jsonb_build_object( + 'productId', v_product_id, + 'closedLotId', NEW.lot_id, + 'newCurrentLotId', v_open_lot_id, + 'occurredAt', NOW() + ) + ); + + RETURN NEW; +END; +$$; + +COMMIT; diff --git a/order-import.php b/order-import.php index cb9a2a9..ae2b7c2 100644 --- a/order-import.php +++ b/order-import.php @@ -437,6 +437,27 @@ function normalize_title_key(string $value): string return $value; } +function normalize_match_key(string $value): string +{ + $value = trim($value); + if ($value === '') { + return ''; + } + + $ascii = $value; + if (function_exists('iconv')) { + $converted = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value); + if (is_string($converted) && $converted !== '') { + $ascii = $converted; + } + } + + $ascii = strtolower($ascii); + $ascii = preg_replace('/[^a-z0-9]+/', ' ', $ascii) ?? $ascii; + $ascii = preg_replace('/\s+/', ' ', trim($ascii)) ?? $ascii; + return $ascii; +} + 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'); @@ -582,9 +603,234 @@ 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 = []; + + 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%'; + } + + 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; + } + } + } + return null; } +function sellable_item_exists(PDO $pdo, int $sellableItemId): bool +{ + $stmt = $pdo->prepare('SELECT 1 FROM public.sellable_item WHERE id = :id LIMIT 1'); + $stmt->execute([':id' => $sellableItemId]); + return $stmt->fetchColumn() !== false; +} + +function find_sellable_item_for_product(PDO $pdo, int $productId): ?int +{ + $stmt = $pdo->prepare( + 'SELECT sellable_item_id + FROM public.sellable_item_component + WHERE product_id = :product_id + ORDER BY id + LIMIT 1' + ); + $stmt->execute([':product_id' => $productId]); + $id = $stmt->fetchColumn(); + return $id === false ? null : (int) $id; +} + +function find_product_name(PDO $pdo, int $productId): ?string +{ + $stmt = $pdo->prepare('SELECT name FROM public.product WHERE id = :id LIMIT 1'); + $stmt->execute([':id' => $productId]); + $name = $stmt->fetchColumn(); + if ($name === false) { + return null; + } + $name = trim((string) $name); + return $name === '' ? null : $name; +} + +function ensure_unique_sellable_item_code(PDO $pdo, string $preferred): string +{ + $base = preg_replace('/[^A-Za-z0-9._-]+/', '-', trim($preferred)) ?? ''; + $base = trim($base, '-'); + if ($base === '') { + $base = 'AUTO-ITEM'; + } + $base = strtoupper(substr($base, 0, 60)); + + $existsStmt = $pdo->prepare('SELECT 1 FROM public.sellable_item WHERE item_code = :item_code LIMIT 1'); + $candidate = $base; + $suffix = 1; + + while (true) { + $existsStmt->execute([':item_code' => $candidate]); + if ($existsStmt->fetchColumn() === false) { + return $candidate; + } + + $suffix++; + $prefixMaxLen = max(1, 60 - strlen((string) $suffix) - 1); + $candidate = substr($base, 0, $prefixMaxLen) . '-' . $suffix; + } +} + +function ensure_alias_points_to_sellable_item( + PDO $pdo, + int $sellableItemId, + string $articleNumber, + string $title +): bool { + $articleNumber = trim($articleNumber); + $title = trim($title); + $titleNorm = normalize_title_key($title); + + if ($articleNumber === '' && $title === '') { + return false; + } + + $findExisting = $pdo->prepare( + "SELECT id, 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" + ); + $findExisting->execute([ + ':article_number' => $articleNumber, + ':title_norm' => $titleNorm, + ':title' => $title, + ]); + $existing = $findExisting->fetch(); + + if (is_array($existing)) { + $existingSellable = isset($existing['sellable_item_id']) ? (int) $existing['sellable_item_id'] : 0; + $aliasId = isset($existing['id']) ? (int) $existing['id'] : 0; + if ($existingSellable === $sellableItemId) { + $touchStmt = $pdo->prepare('UPDATE public.external_item_alias SET is_active = TRUE, updated_at = NOW() WHERE id = :id'); + $touchStmt->execute([':id' => $aliasId]); + return false; + } + + $updateStmt = $pdo->prepare( + 'UPDATE public.external_item_alias + SET sellable_item_id = :sellable_item_id, + external_article_number = :article_number, + external_title = :title, + title_normalized = :title_norm, + is_active = TRUE, + updated_at = NOW() + WHERE id = :id' + ); + $updateStmt->execute([ + ':sellable_item_id' => $sellableItemId, + ':article_number' => $articleNumber !== '' ? $articleNumber : null, + ':title' => $title !== '' ? $title : null, + ':title_norm' => $titleNorm !== '' ? $titleNorm : null, + ':id' => $aliasId, + ]); + return true; + } + + $insertStmt = $pdo->prepare( + "INSERT INTO public.external_item_alias ( + source_system, external_article_number, external_title, title_normalized, sellable_item_id, is_active, created_at, updated_at + ) VALUES ( + 'wix', :article_number, :title, :title_norm, :sellable_item_id, TRUE, NOW(), NOW() + )" + ); + $insertStmt->execute([ + ':article_number' => $articleNumber !== '' ? $articleNumber : null, + ':title' => $title !== '' ? $title : null, + ':title_norm' => $titleNorm !== '' ? $titleNorm : null, + ':sellable_item_id' => $sellableItemId, + ]); + return true; +} + +function ensure_sellable_mapping_from_product_fallback( + PDO $pdo, + int $productId, + string $articleNumber, + string $title +): array { + $articleNumber = trim($articleNumber); + $title = trim($title); + + $sellableItemId = find_sellable_item_for_product($pdo, $productId); + $createdSellable = false; + if ($sellableItemId === null || !sellable_item_exists($pdo, $sellableItemId)) { + $productName = find_product_name($pdo, $productId); + $itemCodeSeed = $articleNumber !== '' ? $articleNumber : "AUTO-PROD-{$productId}"; + $itemCode = ensure_unique_sellable_item_code($pdo, $itemCodeSeed); + $displayName = $title !== '' ? $title : ($productName ?? $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 for fallback product {$productId}"); + } + $sellableItemId = (int) $id; + $createdSellable = true; + } + + $insertComponent = $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, 1.0, NOW(), NOW()) + ON CONFLICT (sellable_item_id, product_id) DO NOTHING' + ); + $insertComponent->execute([ + ':sellable_item_id' => $sellableItemId, + ':product_id' => $productId, + ]); + + $aliasChanged = ensure_alias_points_to_sellable_item($pdo, $sellableItemId, $articleNumber, $title); + + return [ + 'sellableItemId' => $sellableItemId, + 'createdSellableItem' => $createdSellable, + 'aliasCreatedOrUpdated' => $aliasChanged, + ]; +} + function get_current_lot_balance_for_update(PDO $pdo, int $productId): array { $lotStmt = $pdo->prepare( @@ -638,10 +884,15 @@ function switch_current_lot(PDO $pdo, int $productId, int $oldCurrentLotId): int $makeCurrentStmt = $pdo->prepare( "UPDATE public.stock_lot - SET status = 'current', updated_at = NOW() + SET status = 'current', + lot_number = COALESCE(lot_number, :auto_lot_number), + updated_at = NOW() WHERE id = :id" ); - $makeCurrentStmt->execute([':id' => (int) $newCurrentLotId]); + $makeCurrentStmt->execute([ + ':id' => (int) $newCurrentLotId, + ':auto_lot_number' => 'AUTO-' . $productId . '-' . (int) $newCurrentLotId, + ]); $createOpenStmt = $pdo->prepare( "INSERT INTO public.stock_lot (product_id, lot_number, status, created_at, updated_at) @@ -1037,6 +1288,19 @@ try { $unitPrice = parse_number($lineItem['preisEinheit'] ?? null); $lineTotal = $unitPrice !== null ? round($qty * $unitPrice, 2) : null; $sellableItemId = resolve_sellable_item_id($pdo, $articleNumber, $title); + $autoMappingMeta = null; + 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']; + } + } $lineNo = $index + 1; $lineInsert->execute([ @@ -1056,29 +1320,13 @@ try { $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}')"; - } + $inventory['linesUnmapped']++; + $inventory['warnings'][] = "No sellable item mapping for line {$lineNo} (artikelnummer='{$articleNumber}', titel='{$title}')"; } else { + if (is_array($autoMappingMeta)) { + $inventory['linesMappedViaFallbackProduct']++; + $inventory['warnings'][] = "Line {$lineNo} auto-mapped from product fallback (sellable_item_id={$sellableItemId})"; + } $inventory['linesMapped']++; $allocationResult = allocate_line_inventory( $pdo,