diff --git a/docs/modules/erp/lager/processes/main/erp.lager.stock_lot_forecast_refresh.md b/docs/modules/erp/lager/processes/main/erp.lager.stock_lot_forecast_refresh.md index 640927d..246a5f1 100644 --- a/docs/modules/erp/lager/processes/main/erp.lager.stock_lot_forecast_refresh.md +++ b/docs/modules/erp/lager/processes/main/erp.lager.stock_lot_forecast_refresh.md @@ -8,27 +8,28 @@ Tägliche Berechnung und Speicherung des voraussichtlichen Leer-Datums jeder akt ## 2. Prozess-Einbettung -Direkt ausgeführter, täglich um 06:00 Uhr gestarteter Lager-Prozess über den zentralen ERP-Cron-Einstieg. +Direkt ausgeführter Lager-Prozess mit zwei Triggern: synchron nach jeder erfolgreichen Verkaufsallokation in der Lagerschnittstelle sowie täglich um 06:00 Uhr über den zentralen ERP-Cron-Einstieg. ## 3. Input -- keine externen Prozessparameter +- optional eine einzelne `product_id` bei synchronem Verkaufstrigger - aktive Produkte, aktuelle Chargen, physischer Chargenbestand und gültige Verkaufsallokationen aus dem eigenen Datenbestand ## 4. Exakter Prozessablauf -1. Aktive Produkte und ihre aktuelle Charge aus `product`, `stock_lot` und `v_stock_lot_balance` laden. -2. Verkaufsallokationen der letzten 365 Tage aus `sales_order_line_lot_allocation`, `sales_order_line` und `sales_order` laden. -3. `allocated`- und `reserved`-Mengen nach Bestell- beziehungsweise OTC-Verkaufsdatum je Produkt und Kalendertag aggregieren. -4. Stornierte Auftrags- und Positionsmengen aus der Nachfrage herausrechnen. -5. Den gewichteten Tagesabsatz mit einer Halbwertszeit von 60 Tagen berechnen. -6. Für jede aktuelle Charge das Leer-Datum aus physischem Bestand und gewichteter Nachfrage berechnen. -7. Jede erfolgreiche Berechnung in `stock_lot_forecast_history` speichern und den aktuellen Snapshot in `stock_lot` aktualisieren. -8. Fehler einzelner Produkte sammeln und mit den übrigen Produkten fortfahren. +1. Bei einem Verkaufstrigger die betroffenen Produkt-IDs aus der erfolgreichen Allokation übernehmen; beim Cron-Trigger alle aktiven Produkte bestimmen. +2. Aktive Produkte und ihre aktuelle Charge aus `product`, `stock_lot` und `v_stock_lot_balance` laden. +3. Verkaufsallokationen der letzten 365 Tage aus `sales_order_line_lot_allocation`, `sales_order_line` und `sales_order` laden. +4. `allocated`- und `reserved`-Mengen nach Bestell- beziehungsweise OTC-Verkaufsdatum je Produkt und Kalendertag aggregieren. +5. Stornierte Auftrags- und Positionsmengen aus der Nachfrage herausrechnen. +6. Den gewichteten Tagesabsatz mit einer Halbwertszeit von 60 Tagen berechnen. +7. Für jede aktuelle Charge das Leer-Datum aus physischem Bestand und gewichteter Nachfrage berechnen. +8. Jede erfolgreiche Berechnung in `stock_lot_forecast_history` speichern und den aktuellen Snapshot in `stock_lot` aktualisieren. +9. Im Cron Fehler einzelner Produkte sammeln und mit den übrigen Produkten fortfahren. ## 5. Batch, Betriebsmodell und Einbettung -Ein täglicher Lauf um 06:00 Uhr Europe/Zurich verarbeitet alle aktiven Produkte deterministisch nach `product.id`. Produktfehler sind batch-tolerant; technische Gesamtfehler schlagen den Lauf hart fehl. +Der Cron läuft täglich um 06:00 Uhr Europe/Zurich und verarbeitet alle aktiven Produkte deterministisch nach `product.id`. Verkaufstrigger aktualisieren die betroffenen Produkte synchron innerhalb der laufenden Verkaufs-Transaktion. Produktfehler im Cron sind batch-tolerant; technische Gesamtfehler schlagen den jeweiligen Lauf hart fehl. ## 6. Output diff --git a/modules/erp/lager/forecast.php b/modules/erp/lager/forecast.php index 6030786..d19326f 100644 --- a/modules/erp/lager/forecast.php +++ b/modules/erp/lager/forecast.php @@ -45,11 +45,27 @@ function lager_forecast_load_current_lots(PDO $pdo): array return $stmt->fetchAll(); } -function lager_forecast_load_daily_sales(PDO $pdo, DateTimeImmutable $asOfDate, int $lookbackDays): array +function lager_forecast_load_current_lot(PDO $pdo, int $productId): ?array +{ + $stmt = $pdo->prepare( + "SELECT p.id AS product_id, p.sku, sl.id AS lot_id, + COALESCE(v.qty_net, 0) AS physical_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' AND p.id = :product_id + LIMIT 1" + ); + $stmt->execute([':product_id' => $productId]); + $row = $stmt->fetch(); + return is_array($row) ? $row : null; +} + +function lager_forecast_load_daily_sales(PDO $pdo, DateTimeImmutable $asOfDate, int $lookbackDays, ?int $productId = null): array { $startDate = $asOfDate->modify('-' . ($lookbackDays - 1) . ' days')->format('Y-m-d'); $endDate = $asOfDate->modify('+1 day')->format('Y-m-d'); - $stmt = $pdo->prepare( + $sql = "SELECT a.product_id, so.order_date::DATE AS sale_date, SUM(a.qty * GREATEST(sol.qty - sol.qty_cancelled, 0) / NULLIF(sol.qty, 0)) AS units FROM sales_order_line_lot_allocation a @@ -61,11 +77,15 @@ function lager_forecast_load_daily_sales(PDO $pdo, DateTimeImmutable $asOfDate, AND sol.line_status <> 'cancelled' AND so.order_status <> 'cancelled' AND so.order_date >= :start_date::DATE - AND so.order_date < :end_date::DATE - GROUP BY a.product_id, so.order_date::DATE - ORDER BY a.product_id, sale_date" - ); - $stmt->execute([':start_date' => $startDate, ':end_date' => $endDate]); + AND so.order_date < :end_date::DATE"; + $params = [':start_date' => $startDate, ':end_date' => $endDate]; + if ($productId !== null) { + $sql .= ' AND a.product_id = :product_id'; + $params[':product_id'] = $productId; + } + $sql .= ' GROUP BY a.product_id, so.order_date::DATE ORDER BY a.product_id, sale_date'; + $stmt = $pdo->prepare($sql); + $stmt->execute($params); $sales = []; foreach ($stmt->fetchAll() as $row) { @@ -136,7 +156,10 @@ function lager_forecast_calculate(DateTimeImmutable $asOfDate, float $physicalQt function lager_forecast_persist(PDO $pdo, array $lot, array $forecast, DateTimeImmutable $asOfDate): void { - $pdo->beginTransaction(); + $startedTransaction = !$pdo->inTransaction(); + if ($startedTransaction) { + $pdo->beginTransaction(); + } try { $stmt = $pdo->prepare( "INSERT INTO stock_lot_forecast_history ( @@ -172,15 +195,38 @@ function lager_forecast_persist(PDO $pdo, array $lot, array $forecast, DateTimeI ':confidence' => $forecast['confidence'], ':daily_demand' => $forecast['daily_demand'], ':lot_id' => (int) $lot['lot_id'], ]); - $pdo->commit(); + if ($startedTransaction) { + $pdo->commit(); + } } catch (Throwable $error) { - if ($pdo->inTransaction()) { + if ($startedTransaction && $pdo->inTransaction()) { $pdo->rollBack(); } throw $error; } } +function refresh_lager_stock_lot_forecast_for_product(PDO $pdo, int $productId): void +{ + if ($productId <= 0) { + throw new InvalidArgumentException('Produkt-ID fuer Forecast fehlt'); + } + + $lot = lager_forecast_load_current_lot($pdo, $productId); + if ($lot === null || $lot['lot_id'] === null) { + throw new RuntimeException("Keine aktuelle Charge fuer Produkt {$productId} vorhanden"); + } + + $asOfDate = new DateTimeImmutable('today', new DateTimeZone('Europe/Zurich')); + $dailySales = lager_forecast_load_daily_sales($pdo, $asOfDate, 365, $productId); + $forecast = lager_forecast_calculate( + $asOfDate, + (float) $lot['physical_qty'], + $dailySales[$productId] ?? [] + ); + lager_forecast_persist($pdo, $lot, $forecast, $asOfDate); +} + function run_lager_stock_lot_forecast(PDO $pdo): array { $runId = lager_forecast_start_run($pdo); diff --git a/modules/erp/lager/service.php b/modules/erp/lager/service.php index 10f4426..4097c21 100644 --- a/modules/erp/lager/service.php +++ b/modules/erp/lager/service.php @@ -1,6 +1,8 @@ query( @@ -530,8 +532,10 @@ function allocate_components_for_line( $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; @@ -587,6 +591,10 @@ function allocate_components_for_line( } } + foreach (array_keys($forecastProductIds) as $productId) { + refresh_lager_stock_lot_forecast_for_product($pdo, (int) $productId); + } + return [ 'allocated' => true, 'allocationCount' => $allocationCount,