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`
|
6. `db/migrations/0006_phase1_otc_products.sql`
|
||||||
7. `db/migrations/0007_phase1_excel_webhook.sql`
|
7. `db/migrations/0007_phase1_excel_webhook.sql`
|
||||||
8. `db/migrations/0008_shopify_sync_technical.sql`
|
8. `db/migrations/0008_shopify_sync_technical.sql`
|
||||||
|
9. `db/migrations/0013_virtual_shortage_lot_allocations.sql`
|
||||||
|
|
||||||
## Beispielausfuehrung
|
## 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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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`
|
- `party`, `address`, `party_external_identity`
|
||||||
- `sales_order`, `sales_order_line`
|
- `sales_order`, `sales_order_line`
|
||||||
- `external_item_alias` nur für fehlende Shopify-GIDs eines bereits bestätigten SKU-Alias
|
- `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
|
## 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
|
- eine Shopify Order-GID führt zu höchstens einer ERP-Bestellung
|
||||||
- jede Position ist eindeutig gemappt
|
- jede Position ist eindeutig gemappt
|
||||||
- die Chargenallokation entspricht den `sellable_item_component`-Mengen
|
- die Chargenallokation entspricht den `sellable_item_component`-Mengen
|
||||||
|
- jede Fehlmenge ist einer virtuellen, nicht bestandswirksamen Charge zugewiesen
|
||||||
- `dry_run` schreibt keine fachlichen Daten
|
- `dry_run` schreibt keine fachlichen Daten
|
||||||
- bestehende ERP-Bestellungen und ihre Chargenrelationen bleiben unverändert
|
- bestehende ERP-Bestellungen und ihre Chargenrelationen bleiben unverändert
|
||||||
|
|
||||||
## 8. Fehlerschranke
|
## 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
|
## 9. Fachliche Betriebsregel
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ Nicht anwendbar.
|
|||||||
"sales_order_id": 0,
|
"sales_order_id": 0,
|
||||||
"line_count": 0,
|
"line_count": 0,
|
||||||
"allocation_count": 0,
|
"allocation_count": 0,
|
||||||
|
"virtual_allocation_count": 0,
|
||||||
"clarification_reasons": []
|
"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
|
- nur Bestellungen nach dem Stichtag werden berücksichtigt
|
||||||
- stornierte/erstattete Bestellungen sind nicht lagerwirksam
|
- 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
|
- jede Shopify Order-GID wird höchstens einmal als ERP-Bestellung angelegt
|
||||||
- der Dry-Run verändert keine fachlichen Daten
|
- der Dry-Run verändert keine fachlichen Daten
|
||||||
- Dry-Run, übersprungene und bereits importierte Bestellungen bleiben ohne externe Wirkung.
|
- Dry-Run, übersprungene und bereits importierte Bestellungen bleiben ohne externe Wirkung.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Bestand, Charge, MHD und Bewegungen.
|
|||||||
- Warenabgang
|
- Warenabgang
|
||||||
- Umlagerung
|
- Umlagerung
|
||||||
- Chargenpflege
|
- Chargenpflege
|
||||||
|
- Virtuelle Fehlbestandschargen und menschlich bestaetigte Nachallokation
|
||||||
|
|
||||||
## Reads
|
## Reads
|
||||||
|
|
||||||
@@ -40,3 +41,5 @@ Bestand, Charge, MHD und Bewegungen.
|
|||||||
## Technisch umgesetzt
|
## Technisch umgesetzt
|
||||||
|
|
||||||
- `modules/erp/lager/service.php`
|
- `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 === []) {
|
if ($resolvedLines === []) {
|
||||||
throw new RuntimeException('Shopify-Bestellung enthaelt keine Positionen');
|
throw new RuntimeException('Shopify-Bestellung enthaelt keine Positionen');
|
||||||
}
|
}
|
||||||
shopify_projection_stock_check($pdo, $resolvedLines);
|
|
||||||
|
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
$customer = is_array($order['customer'] ?? null) ? $order['customer'] : [];
|
$customer = is_array($order['customer'] ?? null) ? $order['customer'] : [];
|
||||||
shopify_projection_customer($pdo, $customer, true);
|
shopify_projection_customer($pdo, $customer, true);
|
||||||
@@ -208,6 +206,7 @@ function project_shopify_order(PDO $pdo, string $orderGid, callable $orderLoader
|
|||||||
'sales_order_id' => null,
|
'sales_order_id' => null,
|
||||||
'line_count' => count($resolvedLines),
|
'line_count' => count($resolvedLines),
|
||||||
'allocation_count' => 0,
|
'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);
|
$locations = get_default_location_ids($pdo);
|
||||||
$lineNo = 0;
|
$lineNo = 0;
|
||||||
$allocationCount = 0;
|
$allocationCount = 0;
|
||||||
|
$virtualAllocationCount = 0;
|
||||||
foreach ($resolvedLines as $resolvedLine) {
|
foreach ($resolvedLines as $resolvedLine) {
|
||||||
$lineNo++;
|
$lineNo++;
|
||||||
$line = $resolvedLine['line'];
|
$line = $resolvedLine['line'];
|
||||||
@@ -289,6 +289,7 @@ function project_shopify_order(PDO $pdo, string $orderGid, callable $orderLoader
|
|||||||
$lineId = (int) $lineStmt->fetchColumn();
|
$lineId = (int) $lineStmt->fetchColumn();
|
||||||
$allocation = allocate_line_inventory($pdo, $orderId, $lineId, $lineNo, $qty, (int) $resolvedLine['sellable_item_id'], $locations, 'shopify-order');
|
$allocation = allocate_line_inventory($pdo, $orderId, $lineId, $lineNo, $qty, (int) $resolvedLine['sellable_item_id'], $locations, 'shopify-order');
|
||||||
$allocationCount += (int) ($allocation['allocationCount'] ?? 0);
|
$allocationCount += (int) ($allocation['allocationCount'] ?? 0);
|
||||||
|
$virtualAllocationCount += (int) ($allocation['virtualAllocationCount'] ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
shopify_projection_address($pdo, $partyId, (array) ($order['billingAddress'] ?? []), 'billing', false);
|
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,
|
'sales_order_id' => $orderId,
|
||||||
'line_count' => count($resolvedLines),
|
'line_count' => count($resolvedLines),
|
||||||
'allocation_count' => $allocationCount,
|
'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;
|
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(
|
function allocate_components_for_line(
|
||||||
PDO $pdo,
|
PDO $pdo,
|
||||||
int $orderId,
|
int $orderId,
|
||||||
@@ -304,14 +494,7 @@ function allocate_components_for_line(
|
|||||||
}
|
}
|
||||||
|
|
||||||
$allocationCount = 0;
|
$allocationCount = 0;
|
||||||
$allocationInsert = $pdo->prepare(
|
$virtualAllocationCount = 0;
|
||||||
"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()
|
|
||||||
)"
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach ($components as $component) {
|
foreach ($components as $component) {
|
||||||
$productId = (int) $component['product_id'];
|
$productId = (int) $component['product_id'];
|
||||||
$remaining = $lineQty * (float) $component['qty_per_item'];
|
$remaining = $lineQty * (float) $component['qty_per_item'];
|
||||||
@@ -328,25 +511,31 @@ function allocate_components_for_line(
|
|||||||
$available = $current['qty_net'];
|
$available = $current['qty_net'];
|
||||||
|
|
||||||
if ($available <= 0.0000001) {
|
if ($available <= 0.0000001) {
|
||||||
$lotId = switch_current_lot($pdo, $productId, $lotId, (int) $locations['storage']);
|
$take = $remaining;
|
||||||
$current = get_current_lot_balance_for_update($pdo, $productId);
|
$lotId = get_or_create_virtual_shortage_lot($pdo, $productId);
|
||||||
$available = $current['qty_net'];
|
$stockMoveId = null;
|
||||||
$lotId = $current['lot_id'];
|
$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) {
|
$allocationInsert = $pdo->prepare(
|
||||||
throw new RuntimeException("Kein verfuegbarer Bestand fuer Produkt {$productId}");
|
"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 (
|
||||||
$take = min($remaining, $available);
|
:sales_order_line_id, :product_id, :lot_id, :qty, :allocation_status, :stock_move_id, NOW(), NOW()
|
||||||
$stockMoveId = insert_stock_move_out(
|
)"
|
||||||
$pdo,
|
|
||||||
$productId,
|
|
||||||
$lotId,
|
|
||||||
$take,
|
|
||||||
(int) $locations['storage'],
|
|
||||||
(int) $locations['dispatch'],
|
|
||||||
"{$movementSource}:order={$orderId}:line={$lineNo}:product={$productId}"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$allocationInsert->execute([
|
$allocationInsert->execute([
|
||||||
@@ -354,6 +543,7 @@ function allocate_components_for_line(
|
|||||||
':product_id' => $productId,
|
':product_id' => $productId,
|
||||||
':lot_id' => $lotId,
|
':lot_id' => $lotId,
|
||||||
':qty' => $take,
|
':qty' => $take,
|
||||||
|
':allocation_status' => $allocationStatus,
|
||||||
':stock_move_id' => $stockMoveId,
|
':stock_move_id' => $stockMoveId,
|
||||||
]);
|
]);
|
||||||
$allocationCount++;
|
$allocationCount++;
|
||||||
@@ -365,6 +555,7 @@ function allocate_components_for_line(
|
|||||||
return [
|
return [
|
||||||
'allocated' => true,
|
'allocated' => true,
|
||||||
'allocationCount' => $allocationCount,
|
'allocationCount' => $allocationCount,
|
||||||
|
'virtualAllocationCount' => $virtualAllocationCount,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user