366 lines
11 KiB
PHP
366 lines
11 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
function get_default_location_ids(PDO $pdo): array
|
|
{
|
|
$row = $pdo->query(
|
|
"SELECT
|
|
(SELECT id FROM location WHERE type = 'storage' ORDER BY id LIMIT 1) AS storage_id,
|
|
(SELECT id FROM location WHERE type = 'dispatch' ORDER BY id LIMIT 1) AS dispatch_id"
|
|
)->fetch();
|
|
|
|
if (!is_array($row) || $row['storage_id'] === null || $row['dispatch_id'] === null) {
|
|
throw new RuntimeException('Erforderliche Lagerorte fehlen');
|
|
}
|
|
|
|
return [
|
|
'storage' => (int) $row['storage_id'],
|
|
'dispatch' => (int) $row['dispatch_id'],
|
|
];
|
|
}
|
|
|
|
function get_otc_order_form_products(PDO $pdo): array
|
|
{
|
|
$stmt = $pdo->query(
|
|
"SELECT
|
|
p.id,
|
|
p.name,
|
|
COALESCE(SUM(v.qty_net), 0) AS available_qty
|
|
FROM product p
|
|
INNER JOIN stock_lot sl
|
|
ON sl.product_id = p.id
|
|
AND sl.status = 'current'
|
|
INNER JOIN v_stock_lot_balance v
|
|
ON v.stock_lot_id = sl.id
|
|
WHERE p.status = 'active'
|
|
GROUP BY p.id, p.name
|
|
HAVING COALESCE(SUM(v.qty_net), 0) > 0
|
|
ORDER BY p.name ASC, p.id ASC"
|
|
);
|
|
|
|
$products = [];
|
|
foreach ($stmt->fetchAll() as $row) {
|
|
$products[] = [
|
|
'id' => (int) $row['id'],
|
|
'name' => (string) $row['name'],
|
|
'available_qty' => (float) $row['available_qty'],
|
|
];
|
|
}
|
|
|
|
return $products;
|
|
}
|
|
|
|
function get_current_lot_balance_for_update(PDO $pdo, int $productId): array
|
|
{
|
|
$stmt = $pdo->prepare(
|
|
"SELECT
|
|
sl.id AS lot_id,
|
|
COALESCE((
|
|
SELECT v.qty_net
|
|
FROM v_stock_lot_balance v
|
|
WHERE v.stock_lot_id = sl.id
|
|
), 0) AS qty_net
|
|
FROM stock_lot sl
|
|
WHERE sl.product_id = :product_id
|
|
AND sl.status = 'current'
|
|
LIMIT 1
|
|
FOR UPDATE"
|
|
);
|
|
$stmt->execute([':product_id' => $productId]);
|
|
$row = $stmt->fetch();
|
|
|
|
if ($row === false) {
|
|
throw new RuntimeException("Keine aktuelle Charge fuer Produkt {$productId} vorhanden");
|
|
}
|
|
|
|
return [
|
|
'lot_id' => (int) $row['lot_id'],
|
|
'qty_net' => (float) $row['qty_net'],
|
|
];
|
|
}
|
|
|
|
function insert_stock_move_out(
|
|
PDO $pdo,
|
|
int $productId,
|
|
int $lotId,
|
|
float $qty,
|
|
int $fromLocationId,
|
|
int $toLocationId,
|
|
string $note
|
|
): int {
|
|
$stmt = $pdo->prepare(
|
|
"INSERT INTO stock_move (
|
|
product_id, lot_id, from_location_id, to_location_id, qty, move_type, move_date, note, created_at, updated_at
|
|
) VALUES (
|
|
:product_id, :lot_id, :from_location_id, :to_location_id, :qty, 'out', NOW(), :note, NOW(), NOW()
|
|
)
|
|
RETURNING id"
|
|
);
|
|
$stmt->execute([
|
|
':product_id' => $productId,
|
|
':lot_id' => $lotId,
|
|
':from_location_id' => $fromLocationId,
|
|
':to_location_id' => $toLocationId,
|
|
':qty' => $qty,
|
|
':note' => $note,
|
|
]);
|
|
|
|
$id = $stmt->fetchColumn();
|
|
if ($id === false) {
|
|
throw new RuntimeException('Konnte Lagerabgang nicht schreiben');
|
|
}
|
|
|
|
return (int) $id;
|
|
}
|
|
|
|
function insert_stock_move_in(
|
|
PDO $pdo,
|
|
int $productId,
|
|
int $lotId,
|
|
float $qty,
|
|
int $fromLocationId,
|
|
int $toLocationId,
|
|
string $note
|
|
): int {
|
|
$stmt = $pdo->prepare(
|
|
"INSERT INTO stock_move (
|
|
product_id, lot_id, from_location_id, to_location_id, qty, move_type, move_date, note, created_at, updated_at
|
|
) VALUES (
|
|
:product_id, :lot_id, :from_location_id, :to_location_id, :qty, 'in', NOW(), :note, NOW(), NOW()
|
|
)
|
|
RETURNING id"
|
|
);
|
|
$stmt->execute([
|
|
':product_id' => $productId,
|
|
':lot_id' => $lotId,
|
|
':from_location_id' => $fromLocationId,
|
|
':to_location_id' => $toLocationId,
|
|
':qty' => $qty,
|
|
':note' => $note,
|
|
]);
|
|
|
|
$id = $stmt->fetchColumn();
|
|
if ($id === false) {
|
|
throw new RuntimeException('Konnte Lagerzugang nicht schreiben');
|
|
}
|
|
|
|
return (int) $id;
|
|
}
|
|
|
|
function switch_current_lot(PDO $pdo, int $productId, int $oldCurrentLotId, int $storageLocationId): int
|
|
{
|
|
$closeStmt = $pdo->prepare(
|
|
"UPDATE stock_lot
|
|
SET status = 'closed', updated_at = NOW()
|
|
WHERE id = :id"
|
|
);
|
|
$closeStmt->execute([':id' => $oldCurrentLotId]);
|
|
|
|
$openStmt = $pdo->prepare(
|
|
"SELECT id
|
|
FROM stock_lot
|
|
WHERE product_id = :product_id
|
|
AND status = 'open'
|
|
ORDER BY id
|
|
LIMIT 1
|
|
FOR UPDATE"
|
|
);
|
|
$openStmt->execute([':product_id' => $productId]);
|
|
$newCurrentLotId = $openStmt->fetchColumn();
|
|
|
|
if ($newCurrentLotId === false) {
|
|
throw new RuntimeException("Keine offene Platzhalter-Charge fuer Produkt {$productId} vorhanden");
|
|
}
|
|
|
|
$promoteStmt = $pdo->prepare(
|
|
"UPDATE stock_lot
|
|
SET status = 'current',
|
|
updated_at = NOW()
|
|
WHERE id = :id"
|
|
);
|
|
$promoteStmt->execute([':id' => (int) $newCurrentLotId]);
|
|
|
|
$createOpenStmt = $pdo->prepare(
|
|
"INSERT INTO stock_lot (product_id, status, created_at, updated_at)
|
|
VALUES (:product_id, 'open', NOW(), NOW())
|
|
RETURNING id"
|
|
);
|
|
$createOpenStmt->execute([':product_id' => $productId]);
|
|
$createdOpenLotId = $createOpenStmt->fetchColumn();
|
|
if ($createdOpenLotId === false) {
|
|
throw new RuntimeException("Konnte keine neue offene Platzhalter-Charge fuer Produkt {$productId} anlegen");
|
|
}
|
|
|
|
return (int) $newCurrentLotId;
|
|
}
|
|
|
|
function reverse_existing_allocations_for_order(PDO $pdo, int $orderId, int $fallbackStorageLocationId): array
|
|
{
|
|
$stmt = $pdo->prepare(
|
|
"SELECT
|
|
a.stock_move_id,
|
|
a.product_id,
|
|
a.lot_id,
|
|
a.qty,
|
|
sm.from_location_id,
|
|
sm.to_location_id
|
|
FROM sales_order_line_lot_allocation a
|
|
LEFT JOIN stock_move sm ON sm.id = a.stock_move_id
|
|
INNER JOIN sales_order_line sol ON sol.id = a.sales_order_line_id
|
|
WHERE sol.sales_order_id = :order_id
|
|
ORDER BY a.id DESC"
|
|
);
|
|
$stmt->execute([':order_id' => $orderId]);
|
|
$rows = $stmt->fetchAll();
|
|
|
|
$reverseCount = 0;
|
|
$reverseQty = 0.0;
|
|
|
|
foreach ($rows as $row) {
|
|
$productId = (int) $row['product_id'];
|
|
$lotId = (int) $row['lot_id'];
|
|
$qty = (float) $row['qty'];
|
|
$fromLocationId = (int) ($row['from_location_id'] ?? $fallbackStorageLocationId);
|
|
$toLocationId = (int) ($row['to_location_id'] ?? $fallbackStorageLocationId);
|
|
|
|
insert_stock_move_in(
|
|
$pdo,
|
|
$productId,
|
|
$lotId,
|
|
$qty,
|
|
$toLocationId,
|
|
$fromLocationId,
|
|
"reverse-order={$orderId}"
|
|
);
|
|
|
|
$reverseCount++;
|
|
$reverseQty += $qty;
|
|
}
|
|
|
|
return [
|
|
'reversedMoves' => $reverseCount,
|
|
'reversedQty' => $reverseQty,
|
|
];
|
|
}
|
|
|
|
function has_available_stock_for_product(PDO $pdo, int $productId, float $epsilon = 0.0000001): bool
|
|
{
|
|
$stmt = $pdo->prepare(
|
|
"SELECT COALESCE(SUM(v.qty_net), 0) AS qty_net
|
|
FROM v_stock_lot_balance v
|
|
INNER JOIN stock_lot sl ON sl.id = v.stock_lot_id
|
|
WHERE sl.product_id = :product_id
|
|
AND sl.status = 'current'"
|
|
);
|
|
$stmt->execute([':product_id' => $productId]);
|
|
$qty = (float) ($stmt->fetchColumn() ?: 0);
|
|
return $qty > $epsilon;
|
|
}
|
|
|
|
function allocate_components_for_line(
|
|
PDO $pdo,
|
|
int $orderId,
|
|
int $lineId,
|
|
int $lineNo,
|
|
array $components,
|
|
float $lineQty,
|
|
array $locations
|
|
): array {
|
|
if ($components === []) {
|
|
throw new RuntimeException("Keine Komponenten fuer Verkaufsposition {$lineNo} gefunden");
|
|
}
|
|
|
|
$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()
|
|
)"
|
|
);
|
|
|
|
foreach ($components as $component) {
|
|
$productId = (int) $component['product_id'];
|
|
$remaining = $lineQty * (float) $component['qty_per_item'];
|
|
$guard = 0;
|
|
|
|
while ($remaining > 0.0000001) {
|
|
$guard++;
|
|
if ($guard > 100) {
|
|
throw new RuntimeException("Allokationsschutz ausgelost fuer Produkt {$productId}");
|
|
}
|
|
|
|
$current = get_current_lot_balance_for_update($pdo, $productId);
|
|
$lotId = $current['lot_id'];
|
|
$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'];
|
|
}
|
|
|
|
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'],
|
|
"otc-order:order={$orderId}:line={$lineNo}:product={$productId}"
|
|
);
|
|
|
|
$allocationInsert->execute([
|
|
':sales_order_line_id' => $lineId,
|
|
':product_id' => $productId,
|
|
':lot_id' => $lotId,
|
|
':qty' => $take,
|
|
':stock_move_id' => $stockMoveId,
|
|
]);
|
|
$allocationCount++;
|
|
|
|
$remaining -= $take;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'allocated' => true,
|
|
'allocationCount' => $allocationCount,
|
|
];
|
|
}
|
|
|
|
function allocate_line_inventory(
|
|
PDO $pdo,
|
|
int $orderId,
|
|
int $lineId,
|
|
int $lineNo,
|
|
float $lineQty,
|
|
int $sellableItemId,
|
|
array $locations
|
|
): array {
|
|
$components = get_item_components($pdo, $sellableItemId);
|
|
return allocate_components_for_line($pdo, $orderId, $lineId, $lineNo, $components, $lineQty, $locations);
|
|
}
|
|
|
|
function allocate_line_inventory_fallback_product(
|
|
PDO $pdo,
|
|
int $orderId,
|
|
int $lineId,
|
|
int $lineNo,
|
|
float $lineQty,
|
|
int $productId,
|
|
array $locations
|
|
): array {
|
|
$components = [[
|
|
'product_id' => $productId,
|
|
'qty_per_item' => 1.0,
|
|
]];
|
|
return allocate_components_for_line($pdo, $orderId, $lineId, $lineNo, $components, $lineQty, $locations);
|
|
}
|