Split inventory forecast by current and next lot

This commit is contained in:
gitea_admin committed 2026-09-08 18:48:53 +02:00
1 parent 77f6fee5ac
commit 959586ecc9
5 files changed
+164 -59

No files matched your search

@@ -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.
+46 -12
View File
@@ -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']++;
+59 -9
View File
@@ -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'],
],
];
}
+51 -31
View File
@@ -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[] = '<article class="sg-card sg-large-table sg-large-table--lager" data-component="large-table" data-lager-table="true" data-lager-table-key="' . auth_escape_html($tableKey) . '" role="table" aria-label="' . auth_escape_html($title) . '">';
$html[] = '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-large-table__title-segment" data-component-part="large-table-header"><div class="sg-strong">' . auth_escape_html($title) . '</div></div>';
@@ -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[] = '<div class="sg-large-table__cell" role="cell"><a class="sg-hyperlink" href="#" aria-haspopup="dialog" aria-expanded="false" data-lager-drawer-open="true"' . $nameData . $valueData . $typeData . $detailData . '>' . auth_escape_html($name) . '</a></div>';
$html[] = '<div class="sg-large-table__cell" role="cell"><a class="sg-hyperlink" href="#" aria-haspopup="dialog" aria-expanded="false" data-lager-drawer-open="true"' . $nameData . $valueData . $typeData . $detailData . '>' . auth_escape_html($value) . '</a></div>';
if ($tableKey === 'products') {
$forecastClass = $forecastWarning
$forecastClass = $currentForecast['warning']
? ' sg-strong lager-forecast-warning'
: ($forecastMhdWarning ? ' sg-strong lager-forecast-mhd-warning' : '');
$html[] = '<div class="sg-large-table__cell' . $forecastClass . '" role="cell">' . auth_escape_html($forecastText) . '</div>';
: ($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[] = '<div class="sg-large-table__cell' . $forecastClass . '" role="cell">' . auth_escape_html($currentForecast['text']) . '</div>';
$html[] = '<div class="sg-large-table__cell" role="cell">' . auth_escape_html((string) max(0, (int) round((float) ($row['next_qty'] ?? 0)))) . '</div>';
$html[] = '<div class="sg-large-table__cell' . $nextForecastClass . '" role="cell">' . auth_escape_html($nextForecast['text']) . '</div>';
}
$html[] = '</div>';
}
@@ -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,
+1 -1
View File
@@ -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 {