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