Erfasse Shopify-Bestellungen trotz Fehlbestand

This commit is contained in:
gitea_admin committed 2026-09-06 08:05:43 +02:00
1 parent ba23c58dea
commit ed8927b37d
7 files changed
+266 -31

No files matched your search

+1
View File
@@ -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);
@@ -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": []
}
```
@@ -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.
+3
View File
@@ -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
View File
@@ -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,
];
}