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

@@ -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,
];
}