Erfasse Shopify-Bestellungen trotz Fehlbestand
This commit is contained in:
1 parent
ba23c58dea
commit
ed8927b37d
7 files changed
+266
-31
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
+6
-4
@@ -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": []
|
||||
}
|
||||
```
|
||||
|
||||
+1
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
+216
-25
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user