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 e21356d..c89238b 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 @@ -4,7 +4,7 @@ Status: Verbindliche Prozess-Spezifikation ## 1. Zweck -Tägliche Berechnung und Speicherung des voraussichtlichen Leer-Datums jeder aktuellen Charge aktiver Produkte. +Tägliche Berechnung und Speicherung des voraussichtlichen Leer-Datums der aktuellen Charge und – sofern vorhanden – der qualifizierten, als nächste Charge markierten offenen Charge aktiver Produkte. ## 2. Prozess-Einbettung @@ -13,18 +13,18 @@ Direkt ausgeführter Lager-Prozess mit zwei Triggern: synchron nach jeder erfolg ## 3. Input - optional eine einzelne `product_id` bei synchronem Verkaufstrigger -- aktive Produkte, aktuelle Chargen, eingetragene Mengen aller offenen Folgechargen, physischer Chargenbestand und gültige Verkaufsallokationen aus dem eigenen Datenbestand +- aktive Produkte, aktuelle Charge, qualifizierte als nächste Charge markierte offene Charge, physische Chargenbestände und gültige Verkaufsallokationen aus dem eigenen Datenbestand ## 4. Exakter Prozessablauf 1. Bei einem Verkaufstrigger die betroffenen Produkt-IDs aus der erfolgreichen Allokation übernehmen; beim Cron-Trigger alle aktiven Produkte bestimmen. -2. Aktive Produkte, ihre aktuelle Charge und die Nettobestände aller bereits angelegten offenen Folgechargen aus `product`, `stock_lot` und `v_stock_lot_balance` laden. Je offene Folgecharge wird nur eine positive Menge berücksichtigt. +2. Aktive Produkte, ihre aktuelle Charge und die qualifizierte, als nächste Charge markierte offene Charge aus `product`, `stock_lot` und `v_stock_lot_balance` laden. Für jede Charge wird nur ihr eigener positiver Nettobestand berücksichtigt. 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 ihrem physischen Bestand zuzüglich der eingetragenen Mengen aller offenen Folgechargen und der gewichteten Nachfrage berechnen. -8. Jede erfolgreiche Berechnung in `stock_lot_forecast_history` speichern und den aktuellen Snapshot in `stock_lot` aktualisieren. +7. Das Leer-Datum der aktuellen Charge und – sofern vorhanden – der nächsten Charge getrennt aus dem jeweiligen physischen Bestand und derselben gewichteten Nachfrage berechnen. +8. Jede erfolgreiche Berechnung je Charge in `stock_lot_forecast_history` speichern und den jeweiligen Snapshot in `stock_lot` aktualisieren. 9. Im Cron Fehler einzelner Produkte sammeln und mit den übrigen Produkten fortfahren. ## 5. Batch, Betriebsmodell und Einbettung @@ -37,7 +37,8 @@ Ein normalisiertes JSON-Ergebnis mit Gesamtzahl, erfolgreich verarbeiteten Produ ## 7. Erfolgskriterien -- Jede aktuelle Charge erhält täglich einen aktuellen Prognosesnapshot, der eingetragene Mengen aller offenen Folgechargen berücksichtigt. +- Jede aktuelle Charge erhält täglich einen Prognosesnapshot nur für ihren eigenen Bestand. +- Eine qualifizierte, als nächste Charge markierte offene Charge erhält einen separaten Prognosesnapshot nur für ihren eigenen Bestand. - Verkäufe ohne Bestand über virtuelle `reserved`-Allokationen beeinflussen die Nachfrage. - Die vollständige Berechnungshistorie bleibt erhalten. - Ein einzelner Produktfehler verhindert keine weiteren Berechnungen. diff --git a/modules/erp/lager/forecast.php b/modules/erp/lager/forecast.php index 9e1606f..ff3ea50 100644 --- a/modules/erp/lager/forecast.php +++ b/modules/erp/lager/forecast.php @@ -35,15 +35,25 @@ function lager_forecast_load_current_lots(PDO $pdo): array { $stmt = $pdo->query( "SELECT p.id AS product_id, p.sku, sl.id AS lot_id, - COALESCE(v.qty_net, 0) + COALESCE(( - SELECT SUM(GREATEST(COALESCE(open_v.qty_net, 0), 0)) - FROM stock_lot open_lot - LEFT JOIN v_stock_lot_balance open_v ON open_v.stock_lot_id = open_lot.id - WHERE open_lot.product_id = p.id AND open_lot.status = 'open' - ), 0) AS physical_qty + COALESCE(v.qty_net, 0) AS physical_qty, + next_sl.id AS next_lot_id, + COALESCE(next_v.qty_net, 0) AS next_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 + LEFT JOIN stock_lot next_sl + ON next_sl.product_id = p.id + AND next_sl.status = 'open' + AND next_sl.next_active = TRUE + AND NULLIF(TRIM(next_sl.lot_number), '') IS NOT NULL + AND next_sl.manufacturer_party_id IS NOT NULL + AND NULLIF(TRIM(next_sl.manufacturer_lot_number), '') IS NOT NULL + AND next_sl.mfg_date IS NOT NULL + AND next_sl.expiry_date IS NOT NULL + AND next_sl.purchase_date IS NOT NULL + LEFT JOIN v_stock_lot_balance next_v + ON next_v.stock_lot_id = next_sl.id + AND next_v.qty_net > 0 WHERE p.status = 'active' ORDER BY p.id" ); @@ -54,15 +64,25 @@ 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) + COALESCE(( - SELECT SUM(GREATEST(COALESCE(open_v.qty_net, 0), 0)) - FROM stock_lot open_lot - LEFT JOIN v_stock_lot_balance open_v ON open_v.stock_lot_id = open_lot.id - WHERE open_lot.product_id = p.id AND open_lot.status = 'open' - ), 0) AS physical_qty + COALESCE(v.qty_net, 0) AS physical_qty, + next_sl.id AS next_lot_id, + COALESCE(next_v.qty_net, 0) AS next_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 + LEFT JOIN stock_lot next_sl + ON next_sl.product_id = p.id + AND next_sl.status = 'open' + AND next_sl.next_active = TRUE + AND NULLIF(TRIM(next_sl.lot_number), '') IS NOT NULL + AND next_sl.manufacturer_party_id IS NOT NULL + AND NULLIF(TRIM(next_sl.manufacturer_lot_number), '') IS NOT NULL + AND next_sl.mfg_date IS NOT NULL + AND next_sl.expiry_date IS NOT NULL + AND next_sl.purchase_date IS NOT NULL + LEFT JOIN v_stock_lot_balance next_v + ON next_v.stock_lot_id = next_sl.id + AND next_v.qty_net > 0 WHERE p.status = 'active' AND p.id = :product_id LIMIT 1" ); @@ -235,6 +255,13 @@ function refresh_lager_stock_lot_forecast_for_product(PDO $pdo, int $productId): $dailySales[$productId] ?? [] ); lager_forecast_persist($pdo, $lot, $forecast, $asOfDate); + if ($lot['next_lot_id'] !== null) { + lager_forecast_persist($pdo, [ + 'lot_id' => $lot['next_lot_id'], + 'product_id' => $productId, + 'physical_qty' => $lot['next_physical_qty'], + ], lager_forecast_calculate($asOfDate, (float) $lot['next_physical_qty'], $dailySales[$productId] ?? []), $asOfDate); + } } function run_lager_stock_lot_forecast(PDO $pdo): array @@ -260,6 +287,13 @@ function run_lager_stock_lot_forecast(PDO $pdo): array try { $forecast = lager_forecast_calculate($asOfDate, (float) $lot['physical_qty'], $dailySales[$productId] ?? []); lager_forecast_persist($pdo, $lot, $forecast, $asOfDate); + if ($lot['next_lot_id'] !== null) { + lager_forecast_persist($pdo, [ + 'lot_id' => $lot['next_lot_id'], + 'product_id' => $productId, + 'physical_qty' => $lot['next_physical_qty'], + ], lager_forecast_calculate($asOfDate, (float) $lot['next_physical_qty'], $dailySales[$productId] ?? []), $asOfDate); + } $result['processed_products']++; } catch (Throwable $error) { $result['failed_products']++; diff --git a/modules/erp/lager/service.php b/modules/erp/lager/service.php index a5fa65c..13b51f6 100644 --- a/modules/erp/lager/service.php +++ b/modules/erp/lager/service.php @@ -75,13 +75,7 @@ function get_lager_product_overview(PDO $pdo): array 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 available_qty, COALESCE(v.qty_net, 0) AS current_qty, COALESCE(v.qty_in, 0) AS qty_in, sl.lot_number, @@ -94,6 +88,11 @@ function get_lager_product_overview(PDO $pdo): array sl.purchase_date, sl.sellout_date, sl.sellout_forecast_status, + next_sl.id AS next_lot_id, + COALESCE(next_v.qty_net, 0) AS next_qty, + next_sl.sellout_date AS next_sellout_date, + next_sl.sellout_forecast_status AS next_sellout_forecast_status, + next_sl.expiry_date AS next_expiry_date, COALESCE(( SELECT SUM(a.qty) FROM sales_order_line_lot_allocation a @@ -117,10 +116,25 @@ function get_lager_product_overview(PDO $pdo): array 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_lot next_sl + ON next_sl.product_id = p.id + AND next_sl.status = 'open' + AND next_sl.next_active = TRUE + AND NULLIF(TRIM(next_sl.lot_number), '') IS NOT NULL + AND next_sl.manufacturer_party_id IS NOT NULL + AND NULLIF(TRIM(next_sl.manufacturer_lot_number), '') IS NOT NULL + AND next_sl.mfg_date IS NOT NULL + AND next_sl.expiry_date IS NOT NULL + AND next_sl.purchase_date IS NOT NULL + LEFT JOIN v_stock_lot_balance next_v + ON next_v.stock_lot_id = next_sl.id + AND next_v.qty_net > 0 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 + sl.sellout_date, sl.sellout_forecast_status, v.qty_net, v.qty_in, + next_sl.id, next_v.qty_net, next_sl.sellout_date, + next_sl.sellout_forecast_status, next_sl.expiry_date ORDER BY p.name ASC, p.id ASC" ); @@ -143,6 +157,11 @@ function get_lager_product_overview(PDO $pdo): array 'purchase_date' => $row['purchase_date'], 'sellout_date' => $row['sellout_date'], 'sellout_forecast_status' => (string) ($row['sellout_forecast_status'] ?? 'no_demand'), + 'next_lot_id' => $row['next_lot_id'] === null ? null : (int) $row['next_lot_id'], + 'next_qty' => (float) ($row['next_qty'] ?? 0), + 'next_sellout_date' => $row['next_sellout_date'], + 'next_sellout_forecast_status' => (string) ($row['next_sellout_forecast_status'] ?? 'no_demand'), + 'next_expiry_date' => $row['next_expiry_date'], 'virtual_shortage_qty' => (float) ($row['virtual_shortage_qty'] ?? 0), 'virtual_lot_number' => $row['virtual_lot_number'], ], @@ -256,8 +275,27 @@ function get_lager_product_available_qty(PDO $pdo, int $productId): float function get_lager_product_forecast(PDO $pdo, int $productId): array { $stmt = $pdo->prepare( - "SELECT sellout_date, sellout_forecast_status, expiry_date + "SELECT sellout_date, sellout_forecast_status, expiry_date, + next_lot.sellout_date AS next_sellout_date, + next_lot.sellout_forecast_status AS next_sellout_forecast_status, + next_lot.expiry_date AS next_expiry_date FROM stock_lot + LEFT JOIN LATERAL ( + SELECT sl.sellout_date, sl.sellout_forecast_status, sl.expiry_date + FROM stock_lot sl + INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id + WHERE sl.product_id = stock_lot.product_id + AND sl.status = 'open' + AND sl.next_active = TRUE + 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 + LIMIT 1 + ) next_lot ON TRUE WHERE product_id = :product_id AND status = 'current' LIMIT 1" @@ -271,6 +309,18 @@ function get_lager_product_forecast(PDO $pdo, int $productId): array 'date' => $forecast['sellout_date'], 'status' => (string) $forecast['sellout_forecast_status'], 'expiryDate' => $forecast['expiry_date'], + 'current' => [ + 'date' => $forecast['sellout_date'], + 'status' => (string) $forecast['sellout_forecast_status'], + 'expiryDate' => $forecast['expiry_date'], + ], + 'next' => $forecast['next_sellout_date'] === null && $forecast['next_expiry_date'] === null + ? null + : [ + 'date' => $forecast['next_sellout_date'], + 'status' => (string) ($forecast['next_sellout_forecast_status'] ?? 'no_demand'), + 'expiryDate' => $forecast['next_expiry_date'], + ], ]; } diff --git a/modules/shared/auth/ui/home.php b/modules/shared/auth/ui/home.php index 17262a9..821c31b 100644 --- a/modules/shared/auth/ui/home.php +++ b/modules/shared/auth/ui/home.php @@ -189,6 +189,30 @@ function auth_render_bestellungen_content(array $bestellungenTable): string function auth_render_lager_table(string $title, array $rows, array $columns, string $tableKey): string { + $renderForecast = static function (?string $date, string $status, ?string $expiryDate, bool $available): array { + if (!$available) { + return ['text' => '-', 'warning' => false, 'mhdWarning' => false]; + } + if ($date === null || $date === '') { + return ['text' => 'Keine Prognose', 'warning' => false, 'mhdWarning' => false]; + } + $dateObject = new DateTimeImmutable($date); + $text = $dateObject->format('d.m.Y'); + if ($status === 'depleted') { + return ['text' => $text . ' (BESTAND ENDE)', 'warning' => true, 'mhdWarning' => false]; + } + $warning = false; + $mhdWarning = false; + if ($expiryDate !== null && $expiryDate !== '') { + $expiryObject = new DateTimeImmutable($expiryDate); + $warning = $dateObject >= $expiryObject->modify('-3 months') && $dateObject <= $expiryObject; + $mhdWarning = $dateObject > $expiryObject; + if ($warning || $mhdWarning) { + $text .= ' (MHD: ' . $expiryObject->format('d.m.Y') . ')'; + } + } + return ['text' => $text, 'warning' => $warning, 'mhdWarning' => $mhdWarning]; + }; $html = []; $html[] = '
'; $html[] = '
' . auth_escape_html($title) . '
'; @@ -209,40 +233,31 @@ function auth_render_lager_table(string $title, array $rows, array $columns, str $value = $tableKey === 'products' ? (string) max(0, (int) round((float) ($row['available_qty'] ?? 0))) : (string) ($row['item_code'] ?? ''); - $forecastText = ''; - $forecastWarning = false; - $forecastMhdWarning = false; + $currentForecast = ['text' => '', 'warning' => false, 'mhdWarning' => false]; + $nextForecast = ['text' => '-', 'warning' => false, 'mhdWarning' => false]; if ($tableKey === 'products') { - $forecastStatus = (string) ($row['sellout_forecast_status'] ?? 'no_demand'); - $forecastDate = $row['sellout_date'] ?? null; - if ($forecastDate !== null && $forecastDate !== '') { - $forecastDateObject = new DateTimeImmutable((string) $forecastDate); - $forecastText = $forecastDateObject->format('d.m.Y'); - if ($forecastStatus === 'depleted') { - $forecastWarning = true; - $forecastText .= ' (BESTAND ENDE)'; - } elseif (!empty($row['expiry_date'])) { - $expiryDate = new DateTimeImmutable((string) $row['expiry_date']); - $forecastWarning = $forecastDateObject >= $expiryDate->modify('-3 months') - && $forecastDateObject <= $expiryDate; - $forecastMhdWarning = $forecastDateObject > $expiryDate; - if ($forecastWarning || $forecastMhdWarning) { - $forecastText .= ' (MHD: ' . $expiryDate->format('d.m.Y') . ')'; - } - } - } else { - $forecastText = 'Keine Prognose'; - } + $currentForecast = $renderForecast( + $row['sellout_date'] ?? null, + (string) ($row['sellout_forecast_status'] ?? 'no_demand'), + $row['expiry_date'] ?? null, + true + ); + $nextForecast = $renderForecast( + $row['next_sellout_date'] ?? null, + (string) ($row['next_sellout_forecast_status'] ?? 'no_demand'), + $row['next_expiry_date'] ?? null, + $row['next_lot_id'] !== null + ); } $nameData = ' data-lager-drawer-name="' . auth_escape_html($name) . '"'; $valueData = ' data-lager-drawer-value="' . auth_escape_html($value) . '"'; $typeData = ' data-lager-drawer-type="' . auth_escape_html($tableKey === 'products' ? 'Produkt' : 'Artikel') . '"'; $detailData = ''; if ($tableKey === 'products') { - $detailData = ' data-lager-drawer-forecast="' . auth_escape_html($forecastText) . '"' - . ' data-lager-drawer-forecast-status="' . auth_escape_html($forecastStatus) . '"' - . ' data-lager-drawer-forecast-warning="' . ($forecastWarning ? 'true' : 'false') . '"' - . ' data-lager-drawer-forecast-mhd-warning="' . ($forecastMhdWarning ? 'true' : 'false') . '"' + $detailData = ' data-lager-drawer-forecast="' . auth_escape_html($currentForecast['text']) . '"' + . ' data-lager-drawer-forecast-status="' . auth_escape_html((string) ($row['sellout_forecast_status'] ?? 'no_demand')) . '"' + . ' data-lager-drawer-forecast-warning="' . ($currentForecast['warning'] ? 'true' : 'false') . '"' + . ' data-lager-drawer-forecast-mhd-warning="' . ($currentForecast['mhdWarning'] ? 'true' : 'false') . '"' . ' data-lager-drawer-current-qty="' . auth_escape_html(auth_format_lager_quantity((float) ($row['current_qty'] ?? 0))) . '"' . ' data-lager-drawer-qty-in="' . auth_escape_html(auth_format_lager_quantity((float) ($row['qty_in'] ?? 0))) . '"' . ' data-lager-drawer-virtual-shortage-qty="' . auth_escape_html(auth_format_lager_quantity((float) ($row['virtual_shortage_qty'] ?? 0))) . '"' @@ -269,10 +284,15 @@ function auth_render_lager_table(string $title, array $rows, array $columns, str $html[] = '
'; $html[] = '
'; if ($tableKey === 'products') { - $forecastClass = $forecastWarning + $forecastClass = $currentForecast['warning'] ? ' sg-strong lager-forecast-warning' - : ($forecastMhdWarning ? ' sg-strong lager-forecast-mhd-warning' : ''); - $html[] = '
' . auth_escape_html($forecastText) . '
'; + : ($currentForecast['mhdWarning'] ? ' sg-strong lager-forecast-mhd-warning' : ''); + $nextForecastClass = $nextForecast['warning'] + ? ' sg-strong lager-forecast-warning' + : ($nextForecast['mhdWarning'] ? ' sg-strong lager-forecast-mhd-warning' : ''); + $html[] = '
' . auth_escape_html($currentForecast['text']) . '
'; + $html[] = '
' . auth_escape_html((string) max(0, (int) round((float) ($row['next_qty'] ?? 0)))) . '
'; + $html[] = '
' . auth_escape_html($nextForecast['text']) . '
'; } $html[] = ''; } @@ -382,7 +402,7 @@ function auth_render_lager_content(array $lagerProducts, array $lagerArtikel, ar } $tables = $sectionTitle === 'Lager' - ? auth_render_lager_table('Produkte', $lagerProducts, ['Produktname', 'Anzahl im Lager', 'Verbraucht bis'], 'products') + ? auth_render_lager_table('Produkte', $lagerProducts, ['Produktname', 'Lager aktuell', 'Verkauf bis aktuell', 'Lager neu', 'Verkauf bis neu'], 'products') : auth_render_lager_table('Artikel', $lagerArtikel, ['Artikel-Name', 'Artikelnummer'], 'articles'); $openLotCardTemplate = auth_render_lager_open_lots([[ 'id' => 0, diff --git a/public/assets/styles.portal.css b/public/assets/styles.portal.css index 9ae7644..240760b 100644 --- a/public/assets/styles.portal.css +++ b/public/assets/styles.portal.css @@ -247,7 +247,7 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) { } .sg-large-table--lager[data-lager-table-key="products"] .sg-large-table__row { - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(5, minmax(0, 1fr)); } .sg-large-table--lager[data-lager-table-key="products"] .sg-large-table__cell:last-child {