From ed8927b37d7a90c9d68a270fff9103c5ec70ac6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Gla=CC=88ser?= Date: Sun, 6 Sep 2026 08:05:43 +0200 Subject: [PATCH] Erfasse Shopify-Bestellungen trotz Fehlbestand --- db/README.md | 1 + .../0013_virtual_shortage_lot_allocations.sql | 35 +++ ...p.bestellungen.shopify_order_projection.md | 10 +- ...-integration.shopify_delta_order_import.md | 1 + docs/modules/erp/lager/README.md | 3 + .../erp/bestellungen/shopify-projection.php | 6 +- modules/erp/lager/service.php | 241 ++++++++++++++++-- 7 files changed, 266 insertions(+), 31 deletions(-) create mode 100644 db/migrations/0013_virtual_shortage_lot_allocations.sql diff --git a/db/README.md b/db/README.md index 9af2795..ccc0068 100644 --- a/db/README.md +++ b/db/README.md @@ -10,6 +10,7 @@ 6. `db/migrations/0006_phase1_otc_products.sql` 7. `db/migrations/0007_phase1_excel_webhook.sql` 8. `db/migrations/0008_shopify_sync_technical.sql` +9. `db/migrations/0013_virtual_shortage_lot_allocations.sql` ## Beispielausfuehrung diff --git a/db/migrations/0013_virtual_shortage_lot_allocations.sql b/db/migrations/0013_virtual_shortage_lot_allocations.sql new file mode 100644 index 0000000..581242e --- /dev/null +++ b/db/migrations/0013_virtual_shortage_lot_allocations.sql @@ -0,0 +1,35 @@ +-- Keep order capture independent from physical stock availability. + +ALTER TABLE stock_lot + DROP CONSTRAINT IF EXISTS chk_stock_lot_status; + +ALTER TABLE stock_lot + ADD CONSTRAINT chk_stock_lot_status + CHECK (status IN ('open', 'current', 'closed', 'virtual_shortage')); + +ALTER TABLE stock_lot + DROP CONSTRAINT IF EXISTS chk_stock_lot_number_required_for_non_open; + +ALTER TABLE stock_lot + ADD CONSTRAINT chk_stock_lot_number_required_for_non_open + CHECK (status = 'open' OR lot_number IS NOT NULL); + +CREATE TABLE stock_lot_allocation_replacement ( + id BIGSERIAL PRIMARY KEY, + from_allocation_id BIGINT NOT NULL REFERENCES sales_order_line_lot_allocation(id) ON DELETE RESTRICT, + to_allocation_id BIGINT NOT NULL REFERENCES sales_order_line_lot_allocation(id) ON DELETE RESTRICT, + from_lot_id BIGINT NOT NULL REFERENCES stock_lot(id) ON DELETE RESTRICT, + to_lot_id BIGINT NOT NULL REFERENCES stock_lot(id) ON DELETE RESTRICT, + product_id BIGINT NOT NULL REFERENCES product(id) ON DELETE RESTRICT, + qty NUMERIC(14, 4) NOT NULL, + changed_by TEXT NOT NULL, + reason TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT chk_stock_lot_allocation_replacement_qty_positive CHECK (qty > 0), + CONSTRAINT chk_stock_lot_allocation_replacement_distinct_lots CHECK (from_lot_id <> to_lot_id) +); + +CREATE INDEX idx_stock_lot_allocation_replacement_from + ON stock_lot_allocation_replacement(from_allocation_id); +CREATE INDEX idx_stock_lot_allocation_replacement_to + ON stock_lot_allocation_replacement(to_allocation_id); diff --git a/docs/modules/erp/bestellungen/processes/sub_processes/erp.bestellungen.shopify_order_projection.md b/docs/modules/erp/bestellungen/processes/sub_processes/erp.bestellungen.shopify_order_projection.md index e693f9d..7f972b3 100644 --- a/docs/modules/erp/bestellungen/processes/sub_processes/erp.bestellungen.shopify_order_projection.md +++ b/docs/modules/erp/bestellungen/processes/sub_processes/erp.bestellungen.shopify_order_projection.md @@ -24,8 +24,8 @@ Keine vorbereiteten Kunden-, Adress-, Positions- oder Shopify-Rohdaten werden ü 3. Vorhandene `sales_order.shopify_order_gid` idempotent prüfen. 4. Shopify-Customer-GID über `party_external_identity` auflösen oder einen Kontakt über die definierte Kontaktschnittstelle anlegen und die externe Identität speichern. 5. Jede Position ausschliesslich über den Shopify-Varianten-/SKU-Alias des Artikel-Mappings auf ein `sellable_item` auflösen. -6. Bei `dry_run` nur Mapping, Mengen und verfügbaren Chargenbestand prüfen. -7. Bei Echtlauf Bestellkopf und -positionen schreiben und jede Position genau einmal über die Lagerschnittstelle allokieren. +6. Bei `dry_run` nur Mapping und Mengen prüfen; fehlender Bestand ist kein Grund, die Bestellung zu verwerfen. +7. Bei Echtlauf Bestellkopf und -positionen schreiben und jede Position genau einmal über die Lagerschnittstelle allokieren. Reale Bestände werden als `allocated` mit Lagerbewegung zugewiesen; Fehlmengen als `reserved` auf einer virtuellen Fehlbestandscharge ohne Lagerbewegung. 8. Shopify-Varianten- und Produkt-GIDs beim bereits bestätigten Shopify-Alias speichern. 9. Keine Shopify-Mutation und keine externe Kommunikation ausführen. Die anschliessende n8n-Auslösung gehört zum owning Delta-Prozess. @@ -42,7 +42,7 @@ Write targets: - `party`, `address`, `party_external_identity` - `sales_order`, `sales_order_line` - `external_item_alias` nur für fehlende Shopify-GIDs eines bereits bestätigten SKU-Alias -- `sales_order_line_lot_allocation`, `stock_move` ausschliesslich über die Lagerschnittstelle +- `sales_order_line_lot_allocation`, `stock_lot`, `stock_move` ausschliesslich über die Lagerschnittstelle ## 5. Batch, Betriebsmodell und Einbettung @@ -57,12 +57,13 @@ Kompaktes Projektionsergebnis mit Status, ERP-Bestell-ID, Positionen und Allokat - eine Shopify Order-GID führt zu höchstens einer ERP-Bestellung - jede Position ist eindeutig gemappt - die Chargenallokation entspricht den `sellable_item_component`-Mengen +- jede Fehlmenge ist einer virtuellen, nicht bestandswirksamen Charge zugewiesen - `dry_run` schreibt keine fachlichen Daten - bestehende ERP-Bestellungen und ihre Chargenrelationen bleiben unverändert ## 8. Fehlerschranke -Ein fehlender Alias, unzureichender Lagerbestand oder ungültige Shopify-Daten ist ein fachlicher Klärfall und erzeugt keine Teilbestellung. Technische DB- oder Shopify-Fehler schlagen hart fehl. +Ein fehlender Alias oder ungültige Shopify-Daten ist ein fachlicher Klärfall und erzeugt keine Teilbestellung. Fehlender Bestand erzeugt eine virtuelle `reserved`-Allokation und keinen Lagerabbruch. Technische DB- oder Shopify-Fehler schlagen hart fehl. ## 9. Fachliche Betriebsregel @@ -102,6 +103,7 @@ Nicht anwendbar. "sales_order_id": 0, "line_count": 0, "allocation_count": 0, + "virtual_allocation_count": 0, "clarification_reasons": [] } ``` diff --git a/docs/modules/erp/import-integration/processes/main/erp.import-integration.shopify_delta_order_import.md b/docs/modules/erp/import-integration/processes/main/erp.import-integration.shopify_delta_order_import.md index 62594e2..889207f 100644 --- a/docs/modules/erp/import-integration/processes/main/erp.import-integration.shopify_delta_order_import.md +++ b/docs/modules/erp/import-integration/processes/main/erp.import-integration.shopify_delta_order_import.md @@ -52,6 +52,7 @@ Kompaktes Ergebnis mit geprüfter, übersprungener, bereits vorhandener und übe - nur Bestellungen nach dem Stichtag werden berücksichtigt - stornierte/erstattete Bestellungen sind nicht lagerwirksam +- eine nicht stornierte/erstattete Bestellung wird auch bei fehlendem Bestand vollständig erfasst; Fehlmengen werden als virtuelle Chargenallokation zur späteren menschlichen Nachallokation geführt - jede Shopify Order-GID wird höchstens einmal als ERP-Bestellung angelegt - der Dry-Run verändert keine fachlichen Daten - Dry-Run, übersprungene und bereits importierte Bestellungen bleiben ohne externe Wirkung. diff --git a/docs/modules/erp/lager/README.md b/docs/modules/erp/lager/README.md index b927cf3..a7ff902 100644 --- a/docs/modules/erp/lager/README.md +++ b/docs/modules/erp/lager/README.md @@ -18,6 +18,7 @@ Bestand, Charge, MHD und Bewegungen. - Warenabgang - Umlagerung - Chargenpflege +- Virtuelle Fehlbestandschargen und menschlich bestaetigte Nachallokation ## Reads @@ -40,3 +41,5 @@ Bestand, Charge, MHD und Bewegungen. ## Technisch umgesetzt - `modules/erp/lager/service.php` + +Virtuelle Fehlbestandschargen tragen den Status `virtual_shortage`, sind nicht bestandswirksam und werden bei einer menschlich bestaetigten Nachallokation durch eine neue reale Chargenzuordnung ersetzt. Die virtuelle Ausgangszuordnung bleibt als Historie erhalten. diff --git a/modules/erp/bestellungen/shopify-projection.php b/modules/erp/bestellungen/shopify-projection.php index 03f5f5f..1bc414f 100644 --- a/modules/erp/bestellungen/shopify-projection.php +++ b/modules/erp/bestellungen/shopify-projection.php @@ -197,8 +197,6 @@ function project_shopify_order(PDO $pdo, string $orderGid, callable $orderLoader if ($resolvedLines === []) { throw new RuntimeException('Shopify-Bestellung enthaelt keine Positionen'); } - shopify_projection_stock_check($pdo, $resolvedLines); - if ($dryRun) { $customer = is_array($order['customer'] ?? null) ? $order['customer'] : []; shopify_projection_customer($pdo, $customer, true); @@ -208,6 +206,7 @@ function project_shopify_order(PDO $pdo, string $orderGid, callable $orderLoader 'sales_order_id' => null, 'line_count' => count($resolvedLines), 'allocation_count' => 0, + 'virtual_allocation_count' => 0, ]; } @@ -256,6 +255,7 @@ function project_shopify_order(PDO $pdo, string $orderGid, callable $orderLoader $locations = get_default_location_ids($pdo); $lineNo = 0; $allocationCount = 0; + $virtualAllocationCount = 0; foreach ($resolvedLines as $resolvedLine) { $lineNo++; $line = $resolvedLine['line']; @@ -289,6 +289,7 @@ function project_shopify_order(PDO $pdo, string $orderGid, callable $orderLoader $lineId = (int) $lineStmt->fetchColumn(); $allocation = allocate_line_inventory($pdo, $orderId, $lineId, $lineNo, $qty, (int) $resolvedLine['sellable_item_id'], $locations, 'shopify-order'); $allocationCount += (int) ($allocation['allocationCount'] ?? 0); + $virtualAllocationCount += (int) ($allocation['virtualAllocationCount'] ?? 0); } shopify_projection_address($pdo, $partyId, (array) ($order['billingAddress'] ?? []), 'billing', false); @@ -307,5 +308,6 @@ function project_shopify_order(PDO $pdo, string $orderGid, callable $orderLoader 'sales_order_id' => $orderId, 'line_count' => count($resolvedLines), 'allocation_count' => $allocationCount, + 'virtual_allocation_count' => $virtualAllocationCount, ]; } diff --git a/modules/erp/lager/service.php b/modules/erp/lager/service.php index 384a176..2e100ca 100644 --- a/modules/erp/lager/service.php +++ b/modules/erp/lager/service.php @@ -289,6 +289,196 @@ function has_available_stock_for_product(PDO $pdo, int $productId, float $epsilo return $qty > $epsilon; } +function get_or_create_virtual_shortage_lot(PDO $pdo, int $productId): int +{ + $lotNumber = "VIRT-{$productId}-UNBEKANNT"; + $select = $pdo->prepare( + "SELECT id + FROM stock_lot + WHERE product_id = :product_id + AND status = 'virtual_shortage' + AND lot_number = :lot_number + FOR UPDATE" + ); + $select->execute([ + ':product_id' => $productId, + ':lot_number' => $lotNumber, + ]); + $lotId = $select->fetchColumn(); + if ($lotId !== false) { + return (int) $lotId; + } + + $insert = $pdo->prepare( + "INSERT INTO stock_lot (product_id, lot_number, status, created_at, updated_at) + VALUES (:product_id, :lot_number, 'virtual_shortage', NOW(), NOW()) + RETURNING id" + ); + $insert->execute([ + ':product_id' => $productId, + ':lot_number' => $lotNumber, + ]); + $newLotId = $insert->fetchColumn(); + if ($newLotId === false) { + throw new RuntimeException("Virtuelle Fehlbestandscharge fuer Produkt {$productId} konnte nicht angelegt werden"); + } + + return (int) $newLotId; +} + +function replace_virtual_lot_allocation( + PDO $pdo, + int $allocationId, + int $realLotId, + float $qty, + string $changedBy, + string $reason +): int { + if ($qty <= 0 || trim($changedBy) === '' || trim($reason) === '') { + throw new InvalidArgumentException('Menge, Bediener und Grund sind erforderlich'); + } + + $startedTransaction = !$pdo->inTransaction(); + if ($startedTransaction) { + $pdo->beginTransaction(); + } + + try { + $allocationStmt = $pdo->prepare( + "SELECT a.id, a.sales_order_line_id, a.product_id, a.lot_id, a.qty, a.allocation_status, + sol.sales_order_id, sol.line_no, + source_lot.status AS source_lot_status, + target_lot.product_id AS target_product_id, + target_lot.status AS target_lot_status + FROM sales_order_line_lot_allocation a + INNER JOIN sales_order_line sol ON sol.id = a.sales_order_line_id + INNER JOIN stock_lot source_lot ON source_lot.id = a.lot_id + INNER JOIN stock_lot target_lot ON target_lot.id = :target_lot_id + WHERE a.id = :allocation_id + FOR UPDATE" + ); + $allocationStmt->execute([ + ':allocation_id' => $allocationId, + ':target_lot_id' => $realLotId, + ]); + $allocation = $allocationStmt->fetch(); + if (!is_array($allocation)) { + throw new RuntimeException("Chargenzuordnung {$allocationId} fehlt"); + } + + if (($allocation['allocation_status'] ?? null) !== 'reserved' || $allocation['source_lot_status'] !== 'virtual_shortage') { + throw new RuntimeException('Nur reservierte virtuelle Chargenzuordnungen koennen ersetzt werden'); + } + if ((int) $allocation['product_id'] !== (int) $allocation['target_product_id']) { + throw new RuntimeException('Echte Charge gehoert zu einem anderen Produkt'); + } + if ($allocation['target_lot_status'] !== 'current') { + throw new RuntimeException('Echte Zielcharge muss aktuell sein'); + } + + $sourceQty = (float) $allocation['qty']; + if ($qty > $sourceQty + 0.0000001) { + throw new RuntimeException('Ersetzungsmenge uebersteigt virtuelle Zuordnung'); + } + + $balanceStmt = $pdo->prepare( + "SELECT COALESCE(v.qty_net, 0) + FROM v_stock_lot_balance v + WHERE v.stock_lot_id = :lot_id + FOR UPDATE" + ); + $balanceStmt->execute([':lot_id' => $realLotId]); + $available = (float) ($balanceStmt->fetchColumn() ?: 0); + if ($available + 0.0000001 < $qty) { + throw new RuntimeException('Nicht genuegend Bestand in der echten Zielcharge'); + } + + $locations = get_default_location_ids($pdo); + $stockMoveId = insert_stock_move_out( + $pdo, + (int) $allocation['product_id'], + $realLotId, + $qty, + (int) $locations['storage'], + (int) $locations['dispatch'], + "lot-replacement:order={$allocation['sales_order_id']}:line={$allocation['line_no']}:product={$allocation['product_id']}" + ); + + $releaseStmt = $pdo->prepare( + "UPDATE sales_order_line_lot_allocation + SET allocation_status = 'released', released_at = NOW(), updated_at = NOW() + WHERE id = :id" + ); + $releaseStmt->execute([':id' => $allocationId]); + + $remainingQty = $sourceQty - $qty; + if ($remainingQty > 0.0000001) { + $residualStmt = $pdo->prepare( + "INSERT INTO sales_order_line_lot_allocation ( + sales_order_line_id, product_id, lot_id, qty, allocation_status, created_at, updated_at + ) VALUES ( + :sales_order_line_id, :product_id, :lot_id, :qty, 'reserved', NOW(), NOW() + )" + ); + $residualStmt->execute([ + ':sales_order_line_id' => (int) $allocation['sales_order_line_id'], + ':product_id' => (int) $allocation['product_id'], + ':lot_id' => (int) $allocation['lot_id'], + ':qty' => $remainingQty, + ]); + } + + $newAllocationStmt = $pdo->prepare( + "INSERT INTO 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() + ) RETURNING id" + ); + $newAllocationStmt->execute([ + ':sales_order_line_id' => (int) $allocation['sales_order_line_id'], + ':product_id' => (int) $allocation['product_id'], + ':lot_id' => $realLotId, + ':qty' => $qty, + ':stock_move_id' => $stockMoveId, + ]); + $newAllocationId = $newAllocationStmt->fetchColumn(); + if ($newAllocationId === false) { + throw new RuntimeException('Neue reale Chargenzuordnung konnte nicht angelegt werden'); + } + + $replacementStmt = $pdo->prepare( + "INSERT INTO stock_lot_allocation_replacement ( + from_allocation_id, to_allocation_id, from_lot_id, to_lot_id, + product_id, qty, changed_by, reason, created_at + ) VALUES ( + :from_allocation_id, :to_allocation_id, :from_lot_id, :to_lot_id, + :product_id, :qty, :changed_by, :reason, NOW() + )" + ); + $replacementStmt->execute([ + ':from_allocation_id' => $allocationId, + ':to_allocation_id' => (int) $newAllocationId, + ':from_lot_id' => (int) $allocation['lot_id'], + ':to_lot_id' => $realLotId, + ':product_id' => (int) $allocation['product_id'], + ':qty' => $qty, + ':changed_by' => trim($changedBy), + ':reason' => trim($reason), + ]); + + if ($startedTransaction) { + $pdo->commit(); + } + return (int) $newAllocationId; + } catch (Throwable $exception) { + if ($startedTransaction && $pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $exception; + } +} + function allocate_components_for_line( PDO $pdo, int $orderId, @@ -304,14 +494,7 @@ function allocate_components_for_line( } $allocationCount = 0; - $allocationInsert = $pdo->prepare( - "INSERT INTO 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() - )" - ); - + $virtualAllocationCount = 0; foreach ($components as $component) { $productId = (int) $component['product_id']; $remaining = $lineQty * (float) $component['qty_per_item']; @@ -328,25 +511,31 @@ function allocate_components_for_line( $available = $current['qty_net']; if ($available <= 0.0000001) { - $lotId = switch_current_lot($pdo, $productId, $lotId, (int) $locations['storage']); - $current = get_current_lot_balance_for_update($pdo, $productId); - $available = $current['qty_net']; - $lotId = $current['lot_id']; + $take = $remaining; + $lotId = get_or_create_virtual_shortage_lot($pdo, $productId); + $stockMoveId = null; + $allocationStatus = 'reserved'; + $virtualAllocationCount++; + } else { + $take = min($remaining, $available); + $stockMoveId = insert_stock_move_out( + $pdo, + $productId, + $lotId, + $take, + (int) $locations['storage'], + (int) $locations['dispatch'], + "{$movementSource}:order={$orderId}:line={$lineNo}:product={$productId}" + ); + $allocationStatus = 'allocated'; } - if ($available <= 0.0000001) { - throw new RuntimeException("Kein verfuegbarer Bestand fuer Produkt {$productId}"); - } - - $take = min($remaining, $available); - $stockMoveId = insert_stock_move_out( - $pdo, - $productId, - $lotId, - $take, - (int) $locations['storage'], - (int) $locations['dispatch'], - "{$movementSource}:order={$orderId}:line={$lineNo}:product={$productId}" + $allocationInsert = $pdo->prepare( + "INSERT INTO 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, :allocation_status, :stock_move_id, NOW(), NOW() + )" ); $allocationInsert->execute([ @@ -354,6 +543,7 @@ function allocate_components_for_line( ':product_id' => $productId, ':lot_id' => $lotId, ':qty' => $take, + ':allocation_status' => $allocationStatus, ':stock_move_id' => $stockMoveId, ]); $allocationCount++; @@ -365,6 +555,7 @@ function allocate_components_for_line( return [ 'allocated' => true, 'allocationCount' => $allocationCount, + 'virtualAllocationCount' => $virtualAllocationCount, ]; }