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.lot_number, sl.supplier_lot_number, sl.expiry_date, sl.sellout_date, sl.sellout_forecast_status, ( SELECT virtual_lot.lot_number FROM stock_lot virtual_lot WHERE virtual_lot.product_id = p.id AND virtual_lot.status = 'virtual_shortage' ORDER BY virtual_lot.id LIMIT 1 ) AS virtual_lot_number 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.lot_number, sl.supplier_lot_number, 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'], 'lot_number' => $row['lot_number'], 'supplier_lot_number' => $row['supplier_lot_number'], 'expiry_date' => $row['expiry_date'], 'sellout_date' => $row['sellout_date'], 'sellout_forecast_status' => (string) ($row['sellout_forecast_status'] ?? 'no_demand'), 'virtual_lot_number' => $row['virtual_lot_number'], ], $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; $forecastProductIds = []; foreach ($components as $component) { $productId = (int) $component['product_id']; $forecastProductIds[$productId] = true; $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; } } foreach (array_keys($forecastProductIds) as $productId) { refresh_lager_stock_lot_forecast_for_product($pdo, (int) $productId); } 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); }