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 new file mode 100644 index 0000000..d148081 --- /dev/null +++ b/docs/modules/erp/bestellungen/processes/sub_processes/erp.bestellungen.shopify_order_projection.md @@ -0,0 +1,114 @@ +# erp.bestellungen.shopify_order_projection +Stand: 2026-08-12 +Status: Verbindliche Prozess-Spezifikation + +## 1. Zweck + +Eine qualifizierte Shopify-Bestellung anhand ihrer stabilen Shopify Order-GID idempotent als ERP-Bestellung, Position und Chargenallokation projizieren. + +## 2. Prozess-Einbettung + +Synchroner Sub-Prozess von `erp.import-integration.shopify_delta_order_import`. Das owning Modul `erp/bestellungen` besitzt die gesamte Bestellprojektion und die Übergabe an `erp/lager`. + +## 3. Input + +- `shopify_order_gid` +- optional `dry_run` + +Keine vorbereiteten Kunden-, Adress-, Positions- oder Shopify-Rohdaten werden übergeben. + +## 4. Exakter Prozessablauf + +1. Shopify Order-GID und `dry_run` validieren. +2. Die Bestellung über die definierte read-only Shopify-Quellschnittstelle laden. +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. +8. Shopify-Varianten- und Produkt-GIDs beim bereits bestätigten Shopify-Alias speichern. +9. Keine Shopify-Mutation, n8n-Zustellung, Klaviyo-Aktion, Kundenmail oder Label-Aktion ausführen. + +Read sources: + +- definierte read-only Shopify-Quellschnittstelle +- `party_external_identity`, `party`, `address` +- `external_item_alias`, `sellable_item`, `sellable_item_component` +- `product`, `stock_lot`, `v_stock_lot_balance` +- `sales_order` und `sales_order_line` + +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 + +## 5. Batch, Betriebsmodell und Einbettung + +Eine Shopify Order-GID pro synchronem Lauf. Die umgebende Delta-Verarbeitung ist seriell. + +## 6. Output + +Kompaktes Projektionsergebnis mit Status, ERP-Bestell-ID, Positionen und Allokationsanzahl. + +## 7. Erfolgskriterien + +- 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 +- `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. + +## 9. Fachliche Betriebsregel + +Nur qualifizierte neue Shopify-Bestellungen nach dem ERP-Stichtag werden lagerwirksam. Stornierte oder vollständig erstattete Bestellungen werden nicht an diesen Sub-Prozess übergeben. + +## 10. Sub-Prozess-Referenzen + +- `erp.artikel-mapping.shopify_item_resolution` +- `erp.lager.sales_order_line_allocation` + +## 11. End-to-End Sub-Prozess-Kette + +Order-GID → Shopify read-only laden → Kontakt und Artikel auflösen → Dry-Run oder atomare Projektion → Chargenallokation → Ergebnis. + +## 12. Sub-Prozess-Wiederverwendung + +Bestehende Kontakt-, Artikel-Mapping- und Lagerfunktionen werden verwendet. Neue Fachlogik im Import-Submodul ist ausgeschlossen. + +## 13. Step-Liste + +1. `load_shopify_order` +2. `resolve_shopify_party` +3. `resolve_shopify_line_items` +4. `allocate_sales_order_lines` +5. `finalize_shopify_projection` + +## 14. Parallelisierung + +Nicht anwendbar. + +## 15. Output-Payload + +```json +{ + "status": "done|already_imported|clarification_required|failed", + "shopify_order_gid": "string", + "sales_order_id": 0, + "line_count": 0, + "allocation_count": 0, + "clarification_reasons": [] +} +``` + +## 16. Done-Kriterien + +- Projektion ist Shopify-GID-idempotent. +- SKU/Varianten-Mapping und Lagerbestand werden vor Echtlauf vollständig geprüft. +- Allokationen verwenden die bestehende Chargenlogik mit Shopify-Herkunftsvermerk. +- Keine externen Kommunikations- oder Shopify-Schreibwirkungen entstehen. diff --git a/modules/erp/artikel-mapping/service.php b/modules/erp/artikel-mapping/service.php index 7462a51..fa0b2f5 100644 --- a/modules/erp/artikel-mapping/service.php +++ b/modules/erp/artikel-mapping/service.php @@ -257,6 +257,50 @@ function resolve_sellable_item_id(PDO $pdo, string $articleNumber, string $title return ensure_sellable_mapping_from_product_fallback($pdo, $articleNumber, $title); } +function find_shopify_sellable_item_id(PDO $pdo, string $sku, string $variantGid): ?int +{ + $stmt = $pdo->prepare( + "SELECT sellable_item_id + FROM external_item_alias + WHERE source_system = 'shopify' + AND is_active = TRUE + AND (shopify_variant_gid = :variant_gid OR external_article_number = :sku) + ORDER BY CASE WHEN shopify_variant_gid = :sort_variant_gid THEN 0 ELSE 1 END, id + LIMIT 1" + ); + $stmt->execute([ + ':sku' => trim($sku), + ':variant_gid' => trim($variantGid), + ':sort_variant_gid' => trim($variantGid), + ]); + + $id = $stmt->fetchColumn(); + return $id === false ? null : (int) $id; +} + +function bind_shopify_item_identity(PDO $pdo, int $sellableItemId, string $sku, string $productGid, string $variantGid): void +{ + $stmt = $pdo->prepare( + "UPDATE external_item_alias + SET shopify_product_gid = :product_gid, + shopify_variant_gid = :variant_gid, + updated_at = NOW() + WHERE source_system = 'shopify' + AND sellable_item_id = :sellable_item_id + AND external_article_number = :sku" + ); + $stmt->execute([ + ':product_gid' => trim($productGid) !== '' ? trim($productGid) : null, + ':variant_gid' => trim($variantGid) !== '' ? trim($variantGid) : null, + ':sellable_item_id' => $sellableItemId, + ':sku' => trim($sku), + ]); + + if ($stmt->rowCount() !== 1) { + throw new RuntimeException("Kein eindeutiger Shopify-Alias fuer SKU '{$sku}'"); + } +} + function get_item_components(PDO $pdo, int $sellableItemId): array { $stmt = $pdo->prepare( diff --git a/modules/erp/lager/service.php b/modules/erp/lager/service.php index 7a944da..9083733 100644 --- a/modules/erp/lager/service.php +++ b/modules/erp/lager/service.php @@ -264,7 +264,8 @@ function allocate_components_for_line( int $lineNo, array $components, float $lineQty, - array $locations + array $locations, + string $movementSource = 'otc-order' ): array { if ($components === []) { throw new RuntimeException("Keine Komponenten fuer Verkaufsposition {$lineNo} gefunden"); @@ -313,7 +314,7 @@ function allocate_components_for_line( $take, (int) $locations['storage'], (int) $locations['dispatch'], - "otc-order:order={$orderId}:line={$lineNo}:product={$productId}" + "{$movementSource}:order={$orderId}:line={$lineNo}:product={$productId}" ); $allocationInsert->execute([ @@ -342,10 +343,11 @@ function allocate_line_inventory( int $lineNo, float $lineQty, int $sellableItemId, - array $locations + array $locations, + string $movementSource = 'otc-order' ): array { $components = get_item_components($pdo, $sellableItemId); - return allocate_components_for_line($pdo, $orderId, $lineId, $lineNo, $components, $lineQty, $locations); + return allocate_components_for_line($pdo, $orderId, $lineId, $lineNo, $components, $lineQty, $locations, $movementSource); } function allocate_line_inventory_fallback_product(