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, COALESCE(v.qty_in, 0) AS qty_in, sl.lot_number, sl.id AS lot_id, sl.manufacturer_party_id, sl.manufacturer_lot_number, mp.name AS manufacturer_name, sl.mfg_date, sl.expiry_date, sl.purchase_date, sl.sellout_date, sl.sellout_forecast_status, COALESCE(( SELECT SUM(a.qty) FROM sales_order_line_lot_allocation a INNER JOIN stock_lot virtual_lot ON virtual_lot.id = a.lot_id WHERE virtual_lot.product_id = p.id AND virtual_lot.status = 'virtual_shortage' AND a.allocation_status = 'reserved' ), 0) AS virtual_shortage_qty, ( 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 party mp ON mp.id = sl.manufacturer_party_id 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.id, sl.lot_number, sl.manufacturer_party_id, sl.manufacturer_lot_number, mp.name, sl.mfg_date, sl.expiry_date, sl.purchase_date, sl.sellout_date, sl.sellout_forecast_status, v.qty_in ORDER BY p.name ASC, p.id ASC" ); $products = 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'], 'qty_in' => (float) $row['qty_in'], 'lot_id' => $row['lot_id'] === null ? null : (int) $row['lot_id'], 'lot_number' => $row['lot_number'], 'manufacturer_party_id' => $row['manufacturer_party_id'] === null ? null : (int) $row['manufacturer_party_id'], 'manufacturer_lot_number' => $row['manufacturer_lot_number'], 'manufacturer_name' => $row['manufacturer_name'], 'mfg_date' => $row['mfg_date'], 'expiry_date' => $row['expiry_date'], 'purchase_date' => $row['purchase_date'], 'sellout_date' => $row['sellout_date'], 'sellout_forecast_status' => (string) ($row['sellout_forecast_status'] ?? 'no_demand'), 'virtual_shortage_qty' => (float) ($row['virtual_shortage_qty'] ?? 0), 'virtual_lot_number' => $row['virtual_lot_number'], ], $stmt->fetchAll() ); $historicalStmt = $pdo->query( "SELECT sl.product_id, sl.id AS lot_id, sl.lot_number, sl.mfg_date, sl.expiry_date, sl.purchase_date, sl.manufacturer_lot_number, mp.name AS manufacturer_name, COALESCE(v.qty_in, 0) AS qty_in, COALESCE(v.qty_out, 0) AS qty_out, COALESCE(v.qty_net, 0) AS qty_net, MIN(sm.move_date) FILTER (WHERE sm.move_type = 'in') AS first_in_at, MAX(sm.move_date) FILTER (WHERE sm.move_type = 'out') AS last_out_at FROM stock_lot sl LEFT JOIN party mp ON mp.id = sl.manufacturer_party_id LEFT JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id LEFT JOIN stock_move sm ON sm.lot_id = sl.id WHERE sl.status = 'closed' AND sl.lot_number NOT LIKE 'INIT-%' GROUP BY sl.product_id, sl.id, sl.lot_number, sl.mfg_date, sl.expiry_date, sl.purchase_date, sl.manufacturer_lot_number, mp.name, v.qty_in, v.qty_out, v.qty_net ORDER BY sl.product_id, sl.id DESC" ); $historicalLotsByProduct = []; foreach ($historicalStmt->fetchAll() as $row) { $historicalLotsByProduct[(int) $row['product_id']][] = [ 'lot_id' => (int) $row['lot_id'], 'lot_number' => $row['lot_number'], 'mfg_date' => $row['mfg_date'], 'expiry_date' => $row['expiry_date'], 'purchase_date' => $row['purchase_date'], 'manufacturer_lot_number' => $row['manufacturer_lot_number'], 'manufacturer_name' => $row['manufacturer_name'], 'qty_in' => (float) $row['qty_in'], 'qty_out' => (float) $row['qty_out'], 'qty_net' => (float) $row['qty_net'], 'first_in_at' => $row['first_in_at'], 'last_out_at' => $row['last_out_at'], ]; } $openStmt = $pdo->query( "SELECT sl.product_id, sl.id AS lot_id, sl.lot_number, sl.manufacturer_party_id, sl.manufacturer_lot_number, mp.name AS manufacturer_name, sl.mfg_date, sl.expiry_date, sl.purchase_date, COALESCE(v.qty_in, 0) AS qty_in, COALESCE(v.qty_net, 0) AS qty_net FROM stock_lot sl LEFT JOIN party mp ON mp.id = sl.manufacturer_party_id LEFT JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id WHERE sl.status = 'open' ORDER BY sl.product_id, sl.id" ); $openLotsByProduct = []; foreach ($openStmt->fetchAll() as $openLot) { $openLotsByProduct[(int) $openLot['product_id']][] = [ 'lot_id' => (int) $openLot['lot_id'], 'lot_number' => $openLot['lot_number'], 'manufacturer_party_id' => $openLot['manufacturer_party_id'] === null ? null : (int) $openLot['manufacturer_party_id'], 'manufacturer_lot_number' => $openLot['manufacturer_lot_number'], 'manufacturer_name' => $openLot['manufacturer_name'], 'mfg_date' => $openLot['mfg_date'], 'expiry_date' => $openLot['expiry_date'], 'purchase_date' => $openLot['purchase_date'], 'qty_in' => (float) $openLot['qty_in'], 'qty_net' => (float) $openLot['qty_net'], ]; } foreach ($products as &$product) { $product['historical_lots'] = $historicalLotsByProduct[$product['id']] ?? []; $product['open_lots'] = $openLotsByProduct[$product['id']] ?? []; } unset($product); return $products; } function update_stock_lot_traceability( PDO $pdo, int $lotId, ?int $manufacturerPartyId, ?string $manufacturerLotNumber, ?string $mfgDate, ?string $expiryDate, ?string $purchaseDate, string $changedBy, float $receiptQty = 0.0, float $adjustmentQty = 0.0, float $outQty = 0.0, ?string $lotNumber = null ): array { if ($lotId <= 0) { throw new InvalidArgumentException('Chargen-ID fehlt'); } $manufacturerLotNumber = $manufacturerLotNumber !== null ? trim($manufacturerLotNumber) : null; if ($manufacturerLotNumber === '') { $manufacturerLotNumber = null; } $lotNumber = $lotNumber !== null ? trim($lotNumber) : null; if ($lotNumber === '') { throw new InvalidArgumentException('Naurua-Chargennummer fehlt'); } if (!is_finite($receiptQty) || !is_finite($adjustmentQty) || !is_finite($outQty)) { throw new InvalidArgumentException('Ungueltige Bewegungsmenge'); } if ($receiptQty < 0 || $outQty < 0) { throw new InvalidArgumentException('Zugang und Abgang duerfen nicht negativ sein'); } if (fmod($adjustmentQty, 1.0) !== 0.0) { throw new InvalidArgumentException('Bestandskorrekturen muessen ganzzahlig sein'); } if ($adjustmentQty !== 0.0 && abs($adjustmentQty) < 0.0001) { $adjustmentQty = 0.0; } $dates = [ 'mfg_date' => $mfgDate, 'expiry_date' => $expiryDate, 'purchase_date' => $purchaseDate, ]; foreach ($dates as $field => $value) { if ($value !== null && $value !== '') { $parsed = DateTimeImmutable::createFromFormat('!Y-m-d', $value); $errors = DateTimeImmutable::getLastErrors(); if ($parsed === false || (is_array($errors) && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0))) { throw new InvalidArgumentException("Ungueltiges Datum fuer {$field}"); } } $dates[$field] = $value !== null && $value !== '' ? $value : null; } $startedTransaction = !$pdo->inTransaction(); if ($startedTransaction) { $pdo->beginTransaction(); } try { $lotStmt = $pdo->prepare( "SELECT id, product_id, lot_number, manufacturer_party_id, manufacturer_lot_number, mfg_date, expiry_date, purchase_date, status FROM stock_lot WHERE id = :id FOR UPDATE" ); $lotStmt->execute([':id' => $lotId]); $before = $lotStmt->fetch(); if (!is_array($before)) { throw new RuntimeException('Charge nicht gefunden'); } if ((string) $before['status'] === 'virtual_shortage') { throw new InvalidArgumentException('Virtuelle Fehlbestandschargen koennen nicht gepflegt werden'); } if ($lotNumber !== null && (string) $before['status'] !== 'open') { throw new InvalidArgumentException('Nur offene Chargen koennen neu angelegt werden'); } if ($manufacturerPartyId !== null) { $manufacturerStmt = $pdo->prepare( "SELECT p.id FROM party p INNER JOIN party_role pr ON pr.party_id = p.id AND pr.role_code = 'manufacturer' WHERE p.id = :id AND p.status = 'active' LIMIT 1" ); $manufacturerStmt->execute([':id' => $manufacturerPartyId]); if ($manufacturerStmt->fetchColumn() === false) { throw new InvalidArgumentException('Hersteller ist nicht aktiv oder nicht als Hersteller gepflegt'); } } if ($manufacturerPartyId === null && $manufacturerLotNumber !== null) { throw new InvalidArgumentException('Hersteller-Chargennummer benoetigt einen Hersteller'); } if ($manufacturerPartyId !== null && $manufacturerLotNumber === null && (string) $before['status'] !== 'open') { throw new InvalidArgumentException('Hersteller-Chargennummer fehlt'); } if (($adjustmentQty !== 0.0 || $outQty > 0) && (string) $before['status'] !== 'current') { throw new InvalidArgumentException('Korrekturen und Abgaenge koennen nur auf der aktiven Charge gebucht werden'); } $updateStmt = $pdo->prepare( "UPDATE stock_lot SET lot_number = COALESCE(:lot_number, lot_number), manufacturer_party_id = :manufacturer_party_id, manufacturer_lot_number = :manufacturer_lot_number, mfg_date = :mfg_date, expiry_date = :expiry_date, purchase_date = :purchase_date, updated_at = NOW() WHERE id = :id" ); $updateStmt->execute([ ':lot_number' => $lotNumber, ':manufacturer_party_id' => $manufacturerPartyId, ':manufacturer_lot_number' => $manufacturerLotNumber, ':mfg_date' => $dates['mfg_date'], ':expiry_date' => $dates['expiry_date'], ':purchase_date' => $dates['purchase_date'], ':id' => $lotId, ]); $after = $before; if ($lotNumber !== null) { $after['lot_number'] = $lotNumber; } $after['manufacturer_party_id'] = $manufacturerPartyId; $after['manufacturer_lot_number'] = $manufacturerLotNumber; $after['mfg_date'] = $dates['mfg_date']; $after['expiry_date'] = $dates['expiry_date']; $after['purchase_date'] = $dates['purchase_date']; system_write_audit_log($pdo, 'stock_lot', (string) $lotId, 'traceability_updated', $changedBy, $before, $after, [ 'module' => 'erp.lager', ]); if ($receiptQty > 0 || $adjustmentQty !== 0.0 || $outQty > 0) { book_stock_lot_movements($pdo, (int) $before['product_id'], $lotId, $receiptQty, $adjustmentQty, $outQty); } if ($startedTransaction) { $pdo->commit(); } return $after; } catch (Throwable $error) { if ($startedTransaction && $pdo->inTransaction()) { $pdo->rollBack(); } throw $error; } } 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 get_stock_lot_balance(PDO $pdo, int $lotId): array { $stmt = $pdo->prepare( "SELECT COALESCE(v.qty_net, 0) AS qty_net, COALESCE(v.qty_in, 0) AS qty_in, COALESCE(v.qty_out, 0) AS qty_out FROM stock_lot sl LEFT JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id WHERE sl.id = :lot_id" ); $stmt->execute([':lot_id' => $lotId]); $row = $stmt->fetch(); if (!is_array($row)) { throw new RuntimeException('Charge nicht gefunden'); } return [ 'qty_net' => (float) $row['qty_net'], 'qty_in' => (float) $row['qty_in'], 'qty_out' => (float) $row['qty_out'], ]; } 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 insert_stock_move_adjustment( 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, 'adjustment', 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 Bestandskorrektur nicht schreiben'); } return (int) $id; } function book_stock_lot_movements( PDO $pdo, int $productId, int $lotId, float $receiptQty, float $adjustmentQty, float $outQty ): void { $locations = get_default_location_ids($pdo); $balanceStmt = $pdo->prepare( "SELECT COALESCE( SUM(CASE WHEN move_type = 'in' THEN qty WHEN move_type = 'out' THEN -qty WHEN move_type = 'adjustment' AND to_location_id IS NOT NULL AND from_location_id IS NULL THEN qty WHEN move_type = 'adjustment' AND from_location_id IS NOT NULL AND to_location_id IS NULL THEN -qty ELSE 0 END), 0 ) FROM stock_move WHERE lot_id = :lot_id" ); $balanceStmt->execute([':lot_id' => $lotId]); $balance = (float) $balanceStmt->fetchColumn(); if ($receiptQty > 0) { insert_stock_move_in($pdo, $productId, $lotId, $receiptQty, $locations['storage'], $locations['storage'], 'Manueller Lagerzugang via Chargenformular'); $balance += $receiptQty; } if ($adjustmentQty !== 0.0) { if ($adjustmentQty > 0) { insert_stock_move_adjustment($pdo, $productId, $lotId, $adjustmentQty, null, $locations['storage'], 'Manuelle Bestandskorrektur via Chargenformular'); } else { $correctionQty = abs($adjustmentQty); if ($correctionQty > $balance) { throw new InvalidArgumentException('Bestandskorrektur wuerde den Chargensaldo negativ machen'); } insert_stock_move_adjustment($pdo, $productId, $lotId, $correctionQty, $locations['storage'], null, 'Manuelle Bestandskorrektur via Chargenformular'); } $balance += $adjustmentQty; } if ($outQty > $balance) { throw new InvalidArgumentException('Lagerabgang ist groesser als der aktuelle Chargenbestand'); } if ($outQty > 0) { insert_stock_move_out($pdo, $productId, $lotId, $outQty, $locations['storage'], $locations['dispatch'], 'Manueller Lagerabgang via Chargenformular'); } } 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 get_or_create_open_lot(PDO $pdo, int $productId): int { $select = $pdo->prepare( "SELECT id FROM stock_lot WHERE product_id = :product_id AND status = 'open' ORDER BY id LIMIT 1 FOR UPDATE" ); $select->execute([':product_id' => $productId]); $lotId = $select->fetchColumn(); if ($lotId !== false) { return (int) $lotId; } $insert = $pdo->prepare( "INSERT INTO stock_lot (product_id, status, created_at, updated_at) VALUES (:product_id, 'open', NOW(), NOW()) RETURNING id" ); $insert->execute([':product_id' => $productId]); $newLotId = $insert->fetchColumn(); if ($newLotId === false) { throw new RuntimeException('Offene Charge 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); }