Files
erp_naurua/modules/erp/lager/service.php
T

612 lines
20 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,
p.sku,
p.uom,
COALESCE(SUM(v.qty_net), 0) AS available_qty
FROM product p
LEFT JOIN stock_lot sl
ON sl.product_id = p.id
AND sl.status = 'current'
LEFT JOIN v_stock_lot_balance v
ON v.stock_lot_id = sl.id
WHERE p.status = 'active'
GROUP BY p.id, p.name, p.sku, p.uom
ORDER BY p.name ASC, p.id ASC"
);
$products = [];
foreach ($stmt->fetchAll() as $row) {
$products[] = [
'id' => (int) $row['id'],
'name' => (string) $row['name'],
'sku' => (string) $row['sku'],
'uom' => (string) $row['uom'],
'available_qty' => (float) $row['available_qty'],
'has_sellable_mapping' => false,
];
}
$mappingStmt = $pdo->query(
'SELECT DISTINCT product_id
FROM sellable_item_component'
);
$mappedProductIds = array_fill_keys(array_map('intval', $mappingStmt->fetchAll(PDO::FETCH_COLUMN)), true);
foreach ($products as &$product) {
$product['has_sellable_mapping'] = isset($mappedProductIds[$product['id']]);
}
unset($product);
return $products;
}
function get_lager_product_overview(PDO $pdo): array
{
$stmt = $pdo->query(
"SELECT
p.id,
p.name,
p.sku,
p.uom,
COALESCE(SUM(v.qty_net), 0) AS available_qty,
sl.expiry_date,
sl.sellout_date,
sl.sellout_forecast_status
FROM product p
LEFT JOIN stock_lot sl
ON sl.product_id = p.id
AND sl.status = 'current'
LEFT JOIN v_stock_lot_balance v
ON v.stock_lot_id = sl.id
WHERE p.status = 'active'
GROUP BY p.id, p.name, p.sku, p.uom, sl.expiry_date, sl.sellout_date, sl.sellout_forecast_status
ORDER BY p.name ASC, p.id ASC"
);
return array_map(
static fn (array $row): array => [
'id' => (int) $row['id'],
'name' => (string) $row['name'],
'sku' => (string) $row['sku'],
'uom' => (string) $row['uom'],
'available_qty' => (float) $row['available_qty'],
'expiry_date' => $row['expiry_date'],
'sellout_date' => $row['sellout_date'],
'sellout_forecast_status' => (string) ($row['sellout_forecast_status'] ?? 'no_demand'),
],
$stmt->fetchAll()
);
}
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'
AND lot_number IS NOT NULL
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 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,
int $lineId,
int $lineNo,
array $components,
float $lineQty,
array $locations,
string $movementSource = 'otc-order'
): array {
if ($components === []) {
throw new RuntimeException("Keine Komponenten fuer Verkaufsposition {$lineNo} gefunden");
}
$allocationCount = 0;
$virtualAllocationCount = 0;
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) {
$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';
}
$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([
':sales_order_line_id' => $lineId,
':product_id' => $productId,
':lot_id' => $lotId,
':qty' => $take,
':allocation_status' => $allocationStatus,
':stock_move_id' => $stockMoveId,
]);
$allocationCount++;
$remaining -= $take;
}
}
return [
'allocated' => true,
'allocationCount' => $allocationCount,
'virtualAllocationCount' => $virtualAllocationCount,
];
}
function allocate_line_inventory(
PDO $pdo,
int $orderId,
int $lineId,
int $lineNo,
float $lineQty,
int $sellableItemId,
array $locations,
string $movementSource = 'otc-order'
): array {
$components = get_item_components($pdo, $sellableItemId);
return allocate_components_for_line($pdo, $orderId, $lineId, $lineNo, $components, $lineQty, $locations, $movementSource);
}
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);
}