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) + COALESCE(( SELECT SUM(open_v.qty_net) FROM stock_lot open_sl INNER JOIN v_stock_lot_balance open_v ON open_v.stock_lot_id = open_sl.id WHERE open_sl.product_id = p.id AND open_sl.status = 'open' ), 0) AS available_qty, COALESCE(v.qty_net, 0) AS current_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_net, 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'], 'current_qty' => (float) $row['current_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, sl.next_active, 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_net'], 'qty_net' => (float) $openLot['qty_net'], 'next_active' => (bool) $openLot['next_active'], ]; } foreach ($products as &$product) { $product['historical_lots'] = $historicalLotsByProduct[$product['id']] ?? []; $product['open_lots'] = $openLotsByProduct[$product['id']] ?? []; } unset($product); return $products; } function get_lager_product_available_qty(PDO $pdo, int $productId): float { $stmt = $pdo->prepare( "SELECT COALESCE(SUM(v.qty_net), 0) FROM stock_lot sl INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id WHERE sl.product_id = :product_id AND sl.status IN ('current', 'open')" ); $stmt->execute([':product_id' => $productId]); return (float) $stmt->fetchColumn(); } function get_lager_product_forecast(PDO $pdo, int $productId): array { $stmt = $pdo->prepare( "SELECT sellout_date, sellout_forecast_status, expiry_date FROM stock_lot WHERE product_id = :product_id AND status = 'current' LIMIT 1" ); $stmt->execute([':product_id' => $productId]); $forecast = $stmt->fetch(); if (!is_array($forecast)) { throw new RuntimeException('Keine aktuelle Charge fuer Forecast vorhanden'); } return [ 'date' => $forecast['sellout_date'], 'status' => (string) $forecast['sellout_forecast_status'], 'expiryDate' => $forecast['expiry_date'], ]; } 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, ?float $targetQty = null, bool $clearLotNumber = false ): 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 === '' && !$clearLotNumber) { throw new InvalidArgumentException('Naurua-Chargennummer fehlt'); } if ($lotNumber === '') { $lotNumber = null; } if (!is_finite($receiptQty) || !is_finite($adjustmentQty) || !is_finite($outQty)) { throw new InvalidArgumentException('Ungueltige Bewegungsmenge'); } if ($targetQty !== null && (!is_finite($targetQty) || $targetQty < 0)) { throw new InvalidArgumentException('Ungueltige Chargenmenge'); } 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 ($targetQty !== null && (string) $before['status'] !== 'open') { throw new InvalidArgumentException('Nur offene Chargen koennen mit einer Zielmenge bearbeitet 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 ($targetQty !== null) { $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]); $quantityDelta = $targetQty - (float) $balanceStmt->fetchColumn(); $receiptQty = $quantityDelta > 0 ? $quantityDelta : 0.0; $outQty = 0.0; $adjustmentQty = $quantityDelta < 0 ? $quantityDelta : 0.0; } if (($adjustmentQty !== 0.0 || $outQty > 0) && (string) $before['status'] !== 'current' && $targetQty === null) { throw new InvalidArgumentException('Korrekturen und Abgaenge koennen nur auf der aktiven Charge gebucht werden'); } $lotNumberAssignment = $clearLotNumber ? 'NULL' : ($lotNumber === null ? 'lot_number' : 'COALESCE(:lot_number, lot_number)'); $updateStmt = $pdo->prepare( "UPDATE stock_lot SET lot_number = {$lotNumberAssignment}, 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" ); $updateParams = [ ':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, ]; if (!$clearLotNumber && $lotNumber !== null) { $updateParams[':lot_number'] = $lotNumber; } $updateStmt->execute($updateParams); $after = $before; if ($clearLotNumber) { $after['lot_number'] = null; } elseif ($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 is_qualified_open_lot(PDO $pdo, int $lotId): bool { $stmt = $pdo->prepare( "SELECT EXISTS ( SELECT 1 FROM stock_lot sl INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id WHERE sl.id = :lot_id AND sl.status = 'open' AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL AND sl.manufacturer_party_id IS NOT NULL AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL AND sl.mfg_date IS NOT NULL AND sl.expiry_date IS NOT NULL AND sl.purchase_date IS NOT NULL AND v.qty_net > 0 )" ); $stmt->execute([':lot_id' => $lotId]); return (bool) $stmt->fetchColumn(); } function is_open_lot_selected_for_activation(PDO $pdo, int $lotId): bool { $stmt = $pdo->prepare( "SELECT next_active FROM stock_lot WHERE id = :lot_id AND status = 'open'" ); $stmt->execute([':lot_id' => $lotId]); return (bool) $stmt->fetchColumn(); } function sync_open_lot_activation_selection(PDO $pdo, int $productId): void { $startedTransaction = !$pdo->inTransaction(); if ($startedTransaction) { $pdo->beginTransaction(); } try { $lockStmt = $pdo->prepare( "SELECT id FROM stock_lot WHERE product_id = :product_id AND status = 'open' FOR UPDATE" ); $lockStmt->execute([':product_id' => $productId]); $qualifiedStmt = $pdo->prepare( "SELECT sl.id FROM stock_lot sl INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id WHERE sl.product_id = :product_id AND sl.status = 'open' AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL AND sl.manufacturer_party_id IS NOT NULL AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL AND sl.mfg_date IS NOT NULL AND sl.expiry_date IS NOT NULL AND sl.purchase_date IS NOT NULL AND v.qty_net > 0 ORDER BY sl.id" ); $qualifiedStmt->execute([':product_id' => $productId]); $qualifiedIds = array_map('intval', $qualifiedStmt->fetchAll(PDO::FETCH_COLUMN)); if (count($qualifiedIds) === 1) { $clearStmt = $pdo->prepare( "UPDATE stock_lot SET next_active = FALSE, updated_at = NOW() WHERE product_id = :product_id AND status = 'open'" ); $clearStmt->execute([':product_id' => $productId]); $selectStmt = $pdo->prepare( "UPDATE stock_lot SET next_active = TRUE, updated_at = NOW() WHERE id = :lot_id" ); $selectStmt->execute([':lot_id' => $qualifiedIds[0]]); } elseif ($qualifiedIds === []) { $clearStmt = $pdo->prepare( "UPDATE stock_lot SET next_active = FALSE, updated_at = NOW() WHERE product_id = :product_id AND status = 'open'" ); $clearStmt->execute([':product_id' => $productId]); } else { $selectedStmt = $pdo->prepare( "SELECT id FROM stock_lot WHERE product_id = :product_id AND status = 'open' AND next_active = TRUE LIMIT 1 FOR UPDATE" ); $selectedStmt->execute([':product_id' => $productId]); $selectedId = $selectedStmt->fetchColumn(); if ($selectedId === false || !in_array((int) $selectedId, $qualifiedIds, true)) { $clearStmt = $pdo->prepare( "UPDATE stock_lot SET next_active = FALSE, updated_at = NOW() WHERE product_id = :product_id AND status = 'open'" ); $clearStmt->execute([':product_id' => $productId]); } } if ($startedTransaction) { $pdo->commit(); } } catch (Throwable $error) { if ($startedTransaction && $pdo->inTransaction()) { $pdo->rollBack(); } throw $error; } } function promote_open_lot_when_virtual_shortage_is_current(PDO $pdo, int $productId, int $lotId, string $changedBy): bool { $startedTransaction = !$pdo->inTransaction(); if ($startedTransaction) { $pdo->beginTransaction(); } try { $currentStmt = $pdo->prepare( "SELECT id FROM stock_lot WHERE product_id = :product_id AND status = 'virtual_shortage' ORDER BY id LIMIT 1 FOR UPDATE" ); $currentStmt->execute([':product_id' => $productId]); $virtualShortageLotId = $currentStmt->fetchColumn(); if ($virtualShortageLotId === false || !is_qualified_open_lot($pdo, $lotId)) { if ($startedTransaction) { $pdo->commit(); } return false; } $realCurrentStmt = $pdo->prepare( "SELECT id FROM stock_lot WHERE product_id = :product_id AND status = 'current' LIMIT 1 FOR UPDATE" ); $realCurrentStmt->execute([':product_id' => $productId]); $realCurrent = $realCurrentStmt->fetch(); if (is_array($realCurrent)) { $realCurrentBalanceStmt = $pdo->prepare( "SELECT COALESCE(qty_net, 0) FROM v_stock_lot_balance WHERE stock_lot_id = :lot_id" ); $realCurrentBalanceStmt->execute([':lot_id' => (int) $realCurrent['id']]); $realCurrentQty = (float) ($realCurrentBalanceStmt->fetchColumn() ?: 0); } else { $realCurrentQty = 0.0; } if ($realCurrentQty > 0.0000001) { if ($startedTransaction) { $pdo->commit(); } return false; } $lotStmt = $pdo->prepare( "SELECT id FROM stock_lot WHERE id = :lot_id AND product_id = :product_id AND status = 'open' FOR UPDATE" ); $lotStmt->execute([':lot_id' => $lotId, ':product_id' => $productId]); if ($lotStmt->fetchColumn() === false) { if ($startedTransaction) { $pdo->commit(); } return false; } if (is_array($realCurrent)) { $closeStmt = $pdo->prepare( "UPDATE stock_lot SET status = 'closed', updated_at = NOW() WHERE id = :lot_id" ); $closeStmt->execute([':lot_id' => (int) $realCurrent['id']]); } $promoteStmt = $pdo->prepare( "UPDATE stock_lot SET status = 'current', next_active = FALSE, updated_at = NOW() WHERE id = :lot_id" ); $promoteStmt->execute([':lot_id' => $lotId]); reconcile_virtual_shortage_allocations( $pdo, (int) $virtualShortageLotId, $lotId, $changedBy, 'Automatische Verrechnung bei Aktivierung einer echten Charge' ); if ($startedTransaction) { $pdo->commit(); } return true; } catch (Throwable $error) { if ($startedTransaction && $pdo->inTransaction()) { $pdo->rollBack(); } throw $error; } } function reconcile_virtual_shortage_allocations( PDO $pdo, int $virtualLotId, int $realLotId, string $changedBy, string $reason ): void { $allocationStmt = $pdo->prepare( "SELECT id, qty FROM sales_order_line_lot_allocation WHERE lot_id = :lot_id AND allocation_status = 'reserved' ORDER BY id FOR UPDATE" ); $allocationStmt->execute([':lot_id' => $virtualLotId]); $allocations = $allocationStmt->fetchAll(); foreach ($allocations as $allocation) { $balanceStmt = $pdo->prepare( "SELECT COALESCE(qty_net, 0) FROM v_stock_lot_balance WHERE stock_lot_id = :lot_id" ); $balanceStmt->execute([':lot_id' => $realLotId]); $availableQty = (float) ($balanceStmt->fetchColumn() ?: 0); if ($availableQty <= 0.0000001) { break; } $replaceQty = min((float) $allocation['qty'], $availableQty); replace_virtual_lot_allocation( $pdo, (int) $allocation['id'], $realLotId, $replaceQty, $changedBy, $reason ); } $remainingStmt = $pdo->prepare( "SELECT COUNT(*) FROM sales_order_line_lot_allocation WHERE lot_id = :lot_id AND allocation_status = 'reserved'" ); $remainingStmt->execute([':lot_id' => $virtualLotId]); if ((int) $remainingStmt->fetchColumn() !== 0) { return; } $closeStmt = $pdo->prepare( "UPDATE stock_lot SET status = 'closed', next_active = FALSE, updated_at = NOW() WHERE id = :lot_id AND status = 'virtual_shortage'" ); $closeStmt->execute([':lot_id' => $virtualLotId]); if ($closeStmt->rowCount() !== 1) { throw new RuntimeException('Virtuelle Fehlbestandscharge konnte nicht aufgeloest werden'); } system_write_audit_log($pdo, 'stock_lot', (string) $virtualLotId, 'closed', $changedBy, [ 'status' => 'virtual_shortage', ], [ 'status' => 'closed', ], [ 'module' => 'erp.lager', 'reason' => $reason, ]); } function select_open_lot_for_activation(PDO $pdo, int $productId, int $lotId): void { if ($productId <= 0 || $lotId <= 0) { throw new InvalidArgumentException('Nur eine qualifizierte offene Charge kann ausgewaehlt werden'); } $startedTransaction = !$pdo->inTransaction(); if ($startedTransaction) { $pdo->beginTransaction(); } try { $lotStmt = $pdo->prepare( "SELECT product_id, status FROM stock_lot WHERE id = :lot_id FOR UPDATE" ); $lotStmt->execute([':lot_id' => $lotId]); $lot = $lotStmt->fetch(); if (!is_array($lot) || (int) $lot['product_id'] !== $productId || (string) $lot['status'] !== 'open') { throw new InvalidArgumentException('Offene Charge nicht gefunden'); } if (!is_qualified_open_lot($pdo, $lotId)) { throw new InvalidArgumentException('Nur eine qualifizierte offene Charge kann ausgewaehlt werden'); } $clearStmt = $pdo->prepare( "UPDATE stock_lot SET next_active = FALSE, updated_at = NOW() WHERE product_id = :product_id AND status = 'open'" ); $clearStmt->execute([':product_id' => $productId]); $selectStmt = $pdo->prepare( "UPDATE stock_lot SET next_active = TRUE, updated_at = NOW() WHERE id = :lot_id" ); $selectStmt->execute([':lot_id' => $lotId]); if ($startedTransaction) { $pdo->commit(); } } 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 create_open_lot(PDO $pdo, int $productId): int { $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 delete_open_stock_lot(PDO $pdo, int $lotId, string $changedBy): int { if ($lotId <= 0 || trim($changedBy) === '') { throw new InvalidArgumentException('Offene Charge und Bediener sind erforderlich'); } $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]); $lot = $lotStmt->fetch(); if (!is_array($lot)) { throw new InvalidArgumentException('Charge nicht gefunden'); } if ((string) $lot['status'] !== 'open') { throw new InvalidArgumentException('Nur offene Chargen koennen geloescht werden'); } system_write_audit_log($pdo, 'stock_lot', (string) $lotId, 'deleted', $changedBy, $lot, [], [ 'module' => 'erp.lager', ]); $moveStmt = $pdo->prepare('DELETE FROM stock_move WHERE lot_id = :lot_id'); $moveStmt->execute([':lot_id' => $lotId]); $deleteStmt = $pdo->prepare('DELETE FROM stock_lot WHERE id = :id AND status = \'open\''); $deleteStmt->execute([':id' => $lotId]); if ($deleteStmt->rowCount() !== 1) { throw new RuntimeException('Offene Charge konnte nicht geloescht werden'); } $productId = (int) $lot['product_id']; if ($startedTransaction) { $pdo->commit(); } return $productId; } catch (Throwable $error) { if ($startedTransaction && $pdo->inTransaction()) { $pdo->rollBack(); } throw $error; } } 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" ); $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); }