Bereite Shopify Bestellprojektion vor
This commit is contained in:
+114
@@ -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.
|
||||||
@@ -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);
|
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
|
function get_item_components(PDO $pdo, int $sellableItemId): array
|
||||||
{
|
{
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
|
|||||||
@@ -264,7 +264,8 @@ function allocate_components_for_line(
|
|||||||
int $lineNo,
|
int $lineNo,
|
||||||
array $components,
|
array $components,
|
||||||
float $lineQty,
|
float $lineQty,
|
||||||
array $locations
|
array $locations,
|
||||||
|
string $movementSource = 'otc-order'
|
||||||
): array {
|
): array {
|
||||||
if ($components === []) {
|
if ($components === []) {
|
||||||
throw new RuntimeException("Keine Komponenten fuer Verkaufsposition {$lineNo} gefunden");
|
throw new RuntimeException("Keine Komponenten fuer Verkaufsposition {$lineNo} gefunden");
|
||||||
@@ -313,7 +314,7 @@ function allocate_components_for_line(
|
|||||||
$take,
|
$take,
|
||||||
(int) $locations['storage'],
|
(int) $locations['storage'],
|
||||||
(int) $locations['dispatch'],
|
(int) $locations['dispatch'],
|
||||||
"otc-order:order={$orderId}:line={$lineNo}:product={$productId}"
|
"{$movementSource}:order={$orderId}:line={$lineNo}:product={$productId}"
|
||||||
);
|
);
|
||||||
|
|
||||||
$allocationInsert->execute([
|
$allocationInsert->execute([
|
||||||
@@ -342,10 +343,11 @@ function allocate_line_inventory(
|
|||||||
int $lineNo,
|
int $lineNo,
|
||||||
float $lineQty,
|
float $lineQty,
|
||||||
int $sellableItemId,
|
int $sellableItemId,
|
||||||
array $locations
|
array $locations,
|
||||||
|
string $movementSource = 'otc-order'
|
||||||
): array {
|
): array {
|
||||||
$components = get_item_components($pdo, $sellableItemId);
|
$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(
|
function allocate_line_inventory_fallback_product(
|
||||||
|
|||||||
Reference in New Issue
Block a user