Add daily stock lot sellout forecast

This commit is contained in:
gitea_admin committed 2026-09-06 08:55:10 +02:00
1 parent 9a1192ae81
commit 1639960ab6
9 files changed
+440 -3

No files matched your search

+1
View File
@@ -13,6 +13,7 @@
9. `db/migrations/0013_virtual_shortage_lot_allocations.sql`
10. `db/migrations/0014_require_human_lot_activation.sql`
11. `db/migrations/0015_sales_order_payments.sql`
12. `db/migrations/0016_stock_lot_forecast_history.sql`
## Beispielausfuehrung
@@ -0,0 +1,50 @@
BEGIN;
ALTER TABLE stock_lot
ADD COLUMN IF NOT EXISTS sellout_forecast_status TEXT NOT NULL DEFAULT 'no_demand',
ADD COLUMN IF NOT EXISTS sellout_forecast_confidence TEXT NOT NULL DEFAULT 'none',
ADD COLUMN IF NOT EXISTS sellout_forecast_daily_demand NUMERIC(14, 6) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS sellout_forecast_calculated_at TIMESTAMP;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_stock_lot_forecast_status') THEN
ALTER TABLE stock_lot ADD CONSTRAINT chk_stock_lot_forecast_status
CHECK (sellout_forecast_status IN ('forecast', 'depleted', 'no_demand'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_stock_lot_forecast_confidence') THEN
ALTER TABLE stock_lot ADD CONSTRAINT chk_stock_lot_forecast_confidence
CHECK (sellout_forecast_confidence IN ('high', 'medium', 'low', 'none'));
END IF;
END;
$$;
CREATE TABLE IF NOT EXISTS stock_lot_forecast_history (
id BIGSERIAL PRIMARY KEY,
stock_lot_id BIGINT NOT NULL REFERENCES stock_lot(id) ON DELETE RESTRICT,
product_id BIGINT NOT NULL REFERENCES product(id) ON DELETE RESTRICT,
calculated_at TIMESTAMP NOT NULL DEFAULT NOW(),
as_of_date DATE NOT NULL,
physical_qty NUMERIC(14, 4) NOT NULL,
weighted_daily_demand NUMERIC(14, 6) NOT NULL DEFAULT 0,
sales_units NUMERIC(14, 4) NOT NULL DEFAULT 0,
sales_days INTEGER NOT NULL DEFAULT 0,
observed_span_days INTEGER NOT NULL DEFAULT 0,
lookback_days INTEGER NOT NULL,
half_life_days INTEGER NOT NULL,
forecast_date DATE,
forecast_status TEXT NOT NULL,
confidence TEXT NOT NULL,
error_message TEXT,
CONSTRAINT chk_stock_lot_forecast_history_status
CHECK (forecast_status IN ('forecast', 'depleted', 'no_demand')),
CONSTRAINT chk_stock_lot_forecast_history_confidence
CHECK (confidence IN ('high', 'medium', 'low', 'none'))
);
CREATE INDEX IF NOT EXISTS idx_stock_lot_forecast_history_lot_date
ON stock_lot_forecast_history(stock_lot_id, calculated_at DESC);
CREATE INDEX IF NOT EXISTS idx_stock_lot_forecast_history_product_date
ON stock_lot_forecast_history(product_id, calculated_at DESC);
COMMIT;
@@ -0,0 +1,94 @@
# erp.lager.stock_lot_forecast_refresh
Stand: 2026-09-06
Status: Verbindliche Prozess-Spezifikation
## 1. Zweck
Tägliche Berechnung und Speicherung des voraussichtlichen Leer-Datums jeder aktuellen Charge aktiver Produkte.
## 2. Prozess-Einbettung
Direkt ausgeführter, täglich um 06:00 Uhr gestarteter Lager-Prozess über den zentralen ERP-Cron-Einstieg.
## 3. Input
- keine externen Prozessparameter
- 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.
## 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.
## 6. Output
Ein normalisiertes JSON-Ergebnis mit Gesamtzahl, erfolgreich verarbeiteten Produkten, Fehleranzahl und fehlerhaften Produkt-IDs.
## 7. Erfolgskriterien
- Jede aktuelle Charge erhält täglich einen aktuellen Prognosesnapshot.
- Verkäufe ohne Bestand über virtuelle `reserved`-Allokationen beeinflussen die Nachfrage.
- Die vollständige Berechnungshistorie bleibt erhalten.
- Ein einzelner Produktfehler verhindert keine weiteren Berechnungen.
- Eine leere Charge erhält das aktuelle Datum als Leer-Datum.
## 8. Fehlerschranke
Einzelne fachliche oder technische Fehler je Produkt führen zu `partial_success` und werden mit Produkt-ID erfasst. Nicht verfügbare Kernquellen, Datenbankfehler oder fehlende Prozessinfrastruktur führen zu `failed`.
## 9. Fachliche Betriebsregel
Die GUI zeigt das aktuelle Leer-Datum in der Produkttabelle. Die Darstellung wird rot und fett, wenn die Charge leer ist oder das Leer-Datum innerhalb von drei Kalendermonaten vor dem MHD liegt.
## 10. Sub-Prozess-Referenzen
Keine.
## 11. End-to-End Sub-Prozess-Kette
ERP-Cron um 06:00 Uhr → Forecast-Prozess → Nachfrageaggregation → Chargenprognose → Historie und aktueller Snapshot → Lager/Produkte-Tabelle.
## 12. Sub-Prozess-Wiederverwendung
Die bestehende Lagerbestands-View `v_stock_lot_balance` und die bestehende Prozesslauf-Tabelle `process_runs` werden wiederverwendet. Ein zusätzlicher Sub-Prozess ist für den kleinen, deterministischen Batch nicht erforderlich.
## 13. Step-Liste
1. `load_current_lots`
2. `load_daily_sales`
3. `calculate_weighted_demand`
4. `persist_forecast`
5. `aggregate_result`
## 14. Parallelisierung
Nicht erforderlich. Die Produktmenge ist klein; die Verarbeitung erfolgt deterministisch und seriell mit toleranter Einzelfehlerbehandlung.
## 15. Output-Payload
```json
{
"status": "done|partial_success|failed",
"total_products": 0,
"processed_products": 0,
"failed_products": 0,
"failed_product_ids": []
}
```
## 16. Done-Kriterien
- Prozesslauf in `process_runs` abgeschlossen.
- Erfolgreiche Prognosen in `stock_lot_forecast_history` gespeichert.
- Aktuelle Chargen-Snapshots aktualisiert.
- Produktfehler vollständig und kompakt im Prozessresultat erfasst.
+1
View File
@@ -15,6 +15,7 @@ Technische Laufzeit-, Start-, Trigger- und Supervisor-Logik.
## Technisch umgesetzt
- `modules/system/deploy.php`
- `modules/system/naurua-erp-cron.php`
## Wrapper
+223
View File
@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
function lager_forecast_start_run(PDO $pdo): int
{
$stmt = $pdo->prepare(
"INSERT INTO process_runs (process_name, status, scope_json, requested_by)
VALUES ('erp.lager.stock_lot_forecast_refresh', 'running', :scope_json::jsonb, 'cron')
RETURNING id"
);
$stmt->execute([
':scope_json' => json_encode(['lookback_days' => 365, 'half_life_days' => 60], JSON_THROW_ON_ERROR),
]);
return (int) $stmt->fetchColumn();
}
function lager_forecast_finish_run(PDO $pdo, int $runId, string $status, array $result, ?Throwable $error = null): void
{
$stmt = $pdo->prepare(
'UPDATE process_runs
SET status = :status, result_json = :result_json::jsonb, finished_at = NOW(),
error_code = :error_code, error_message = :error_message
WHERE id = :id'
);
$stmt->execute([
':status' => $status,
':result_json' => json_encode($result, JSON_THROW_ON_ERROR),
':error_code' => $error === null ? null : 'stock_lot_forecast_failed',
':error_message' => $error?->getMessage(),
':id' => $runId,
]);
}
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) 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'
ORDER BY p.id"
);
return $stmt->fetchAll();
}
function lager_forecast_load_daily_sales(PDO $pdo, DateTimeImmutable $asOfDate, int $lookbackDays): array
{
$startDate = $asOfDate->modify('-' . ($lookbackDays - 1) . ' days')->format('Y-m-d');
$endDate = $asOfDate->modify('+1 day')->format('Y-m-d');
$stmt = $pdo->prepare(
"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
INNER JOIN sales_order_line sol ON sol.id = a.sales_order_line_id
INNER JOIN sales_order so ON so.id = sol.sales_order_id
INNER JOIN product p ON p.id = a.product_id
WHERE p.status = 'active'
AND a.allocation_status IN ('allocated', 'reserved')
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]);
$sales = [];
foreach ($stmt->fetchAll() as $row) {
$sales[(int) $row['product_id']][(string) $row['sale_date']] = (float) $row['units'];
}
return $sales;
}
function lager_forecast_calculate(DateTimeImmutable $asOfDate, float $physicalQty, array $dailySales): array
{
$lookbackDays = 365;
$halfLifeDays = 60;
$weightedUnits = 0.0;
$weightTotal = 0.0;
$salesUnits = 0.0;
$salesDays = 0;
$firstSaleDate = null;
$lastSaleDate = null;
for ($age = 0; $age < $lookbackDays; $age++) {
$date = $asOfDate->modify('-' . $age . ' days');
$dateKey = $date->format('Y-m-d');
$units = max(0.0, (float) ($dailySales[$dateKey] ?? 0.0));
$weight = 2 ** (-$age / $halfLifeDays);
$weightedUnits += $units * $weight;
$weightTotal += $weight;
if ($units > 0) {
$salesUnits += $units;
$salesDays++;
$firstSaleDate = $dateKey;
$lastSaleDate ??= $dateKey;
}
}
$dailyDemand = $weightTotal > 0 ? $weightedUnits / $weightTotal : 0.0;
$observedSpanDays = $firstSaleDate === null
? 0
: (new DateTimeImmutable($firstSaleDate))->diff(new DateTimeImmutable($lastSaleDate))->days + 1;
if ($physicalQty <= 0.0000001) {
return [
'forecast_date' => $asOfDate->format('Y-m-d'), 'forecast_status' => 'depleted',
'confidence' => $dailyDemand > 0 ? 'low' : 'none', 'daily_demand' => $dailyDemand,
'sales_units' => $salesUnits, 'sales_days' => $salesDays, 'observed_span_days' => $observedSpanDays,
'lookback_days' => $lookbackDays, 'half_life_days' => $halfLifeDays,
];
}
if ($dailyDemand <= 0.0000001) {
return [
'forecast_date' => null, 'forecast_status' => 'no_demand', 'confidence' => 'none',
'daily_demand' => 0.0, 'sales_units' => $salesUnits, 'sales_days' => $salesDays,
'observed_span_days' => $observedSpanDays, 'lookback_days' => $lookbackDays,
'half_life_days' => $halfLifeDays,
];
}
$confidence = $salesDays >= 20 && $observedSpanDays >= 180
? 'high'
: ($salesDays >= 10 ? 'medium' : 'low');
$daysUntilDepleted = (int) ceil($physicalQty / $dailyDemand);
return [
'forecast_date' => $asOfDate->modify('+' . $daysUntilDepleted . ' days')->format('Y-m-d'),
'forecast_status' => 'forecast', 'confidence' => $confidence, 'daily_demand' => $dailyDemand,
'sales_units' => $salesUnits, 'sales_days' => $salesDays, 'observed_span_days' => $observedSpanDays,
'lookback_days' => $lookbackDays, 'half_life_days' => $halfLifeDays,
];
}
function lager_forecast_persist(PDO $pdo, array $lot, array $forecast, DateTimeImmutable $asOfDate): void
{
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare(
"INSERT INTO stock_lot_forecast_history (
stock_lot_id, product_id, as_of_date, physical_qty, weighted_daily_demand,
sales_units, sales_days, observed_span_days, lookback_days, half_life_days,
forecast_date, forecast_status, confidence
) VALUES (
:stock_lot_id, :product_id, :as_of_date, :physical_qty, :weighted_daily_demand,
:sales_units, :sales_days, :observed_span_days, :lookback_days, :half_life_days,
:forecast_date, :forecast_status, :confidence
)"
);
$stmt->execute([
':stock_lot_id' => (int) $lot['lot_id'], ':product_id' => (int) $lot['product_id'],
':as_of_date' => $asOfDate->format('Y-m-d'), ':physical_qty' => (float) $lot['physical_qty'],
':weighted_daily_demand' => $forecast['daily_demand'], ':sales_units' => $forecast['sales_units'],
':sales_days' => $forecast['sales_days'], ':observed_span_days' => $forecast['observed_span_days'],
':lookback_days' => $forecast['lookback_days'], ':half_life_days' => $forecast['half_life_days'],
':forecast_date' => $forecast['forecast_date'], ':forecast_status' => $forecast['forecast_status'],
':confidence' => $forecast['confidence'],
]);
$stmt = $pdo->prepare(
"UPDATE stock_lot SET sellout_date = :sellout_date,
sellout_forecast_status = :forecast_status,
sellout_forecast_confidence = :confidence,
sellout_forecast_daily_demand = :daily_demand,
sellout_forecast_calculated_at = NOW(), updated_at = NOW()
WHERE id = :lot_id"
);
$stmt->execute([
':sellout_date' => $forecast['forecast_date'], ':forecast_status' => $forecast['forecast_status'],
':confidence' => $forecast['confidence'], ':daily_demand' => $forecast['daily_demand'],
':lot_id' => (int) $lot['lot_id'],
]);
$pdo->commit();
} catch (Throwable $error) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $error;
}
}
function run_lager_stock_lot_forecast(PDO $pdo): array
{
$runId = lager_forecast_start_run($pdo);
$asOfDate = new DateTimeImmutable('today', new DateTimeZone('Europe/Zurich'));
$result = [
'status' => 'done', 'total_products' => 0, 'processed_products' => 0,
'failed_products' => 0, 'failed_product_ids' => [],
];
try {
$lots = lager_forecast_load_current_lots($pdo);
$result['total_products'] = count($lots);
$dailySales = lager_forecast_load_daily_sales($pdo, $asOfDate, 365);
foreach ($lots as $lot) {
$productId = (int) $lot['product_id'];
if ($lot['lot_id'] === null) {
$result['failed_products']++;
$result['failed_product_ids'][] = $productId;
continue;
}
try {
$forecast = lager_forecast_calculate($asOfDate, (float) $lot['physical_qty'], $dailySales[$productId] ?? []);
lager_forecast_persist($pdo, $lot, $forecast, $asOfDate);
$result['processed_products']++;
} catch (Throwable $error) {
$result['failed_products']++;
$result['failed_product_ids'][] = $productId;
}
}
if ($result['failed_products'] > 0) {
$result['status'] = 'partial_success';
}
lager_forecast_finish_run($pdo, $runId, $result['status'], $result);
return $result;
} catch (Throwable $error) {
$result['status'] = 'failed';
lager_forecast_finish_run($pdo, $runId, 'failed', $result, $error);
throw $error;
}
}
+8 -2
View File
@@ -72,7 +72,10 @@ function get_lager_product_overview(PDO $pdo): array
p.name,
p.sku,
p.uom,
COALESCE(SUM(v.qty_net), 0) AS available_qty
COALESCE(SUM(v.qty_net), 0) AS available_qty,
sl.expiry_date,
sl.sellout_date,
sl.sellout_forecast_status
FROM product p
LEFT JOIN stock_lot sl
ON sl.product_id = p.id
@@ -80,7 +83,7 @@ function get_lager_product_overview(PDO $pdo): array
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
GROUP BY p.id, p.name, p.sku, p.uom, sl.expiry_date, sl.sellout_date, sl.sellout_forecast_status
ORDER BY p.name ASC, p.id ASC"
);
@@ -91,6 +94,9 @@ function get_lager_product_overview(PDO $pdo): array
'sku' => (string) $row['sku'],
'uom' => (string) $row['uom'],
'available_qty' => (float) $row['available_qty'],
'expiry_date' => $row['expiry_date'],
'sellout_date' => $row['sellout_date'],
'sellout_forecast_status' => (string) ($row['sellout_forecast_status'] ?? 'no_demand'),
],
$stmt->fetchAll()
);
+22 -1
View File
@@ -223,6 +223,23 @@ 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;
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');
$forecastWarning = $forecastStatus === 'depleted';
if (!$forecastWarning && !empty($row['expiry_date'])) {
$expiryDate = new DateTimeImmutable((string) $row['expiry_date']);
$forecastWarning = $forecastDateObject >= $expiryDate->modify('-3 months');
}
} else {
$forecastText = 'Keine Prognose';
}
}
$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') . '"';
@@ -230,6 +247,10 @@ function auth_render_lager_table(string $title, array $rows, array $columns, str
$html[] = '<div class="' . $rowClass . '" role="row" data-component-part="large-table-row" data-lager-row="true" data-lager-id="' . $id . '">';
$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 . '>' . 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 . '>' . auth_escape_html($value) . '</a></div>';
if ($tableKey === 'products') {
$forecastClass = $forecastWarning ? ' sg-strong lager-forecast-warning' : '';
$html[] = '<div class="sg-large-table__cell' . $forecastClass . '" role="cell">' . auth_escape_html($forecastText) . '</div>';
}
$html[] = '</div>';
}
@@ -241,7 +262,7 @@ function auth_render_lager_content(array $lagerProducts, array $lagerArtikel): s
{
return '<div data-lager-content="true">'
. '<section class="sg-card-list-page" aria-label="Lager">'
. auth_render_lager_table('Produkte', $lagerProducts, ['Produktname', 'Anzahl im Lager'], 'products')
. auth_render_lager_table('Produkte', $lagerProducts, ['Produktname', 'Anzahl im Lager', 'Verbraucht bis'], 'products')
. auth_render_lager_table('Artikel', $lagerArtikel, ['Artikel-Name', 'Artikelnummer'], 'articles')
. '</section>'
. '<div class="sg-card-list-page-drawer-backdrop" aria-hidden="true" data-lager-drawer-backdrop="true" data-open="false"></div>'
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../shared/db.php';
require_once __DIR__ . '/../erp/lager/forecast.php';
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
$lockHandle = fopen(dirname(__DIR__, 2) . '/runtime/naurua-erp-cron.lock', 'c');
if ($lockHandle === false || !flock($lockHandle, LOCK_EX | LOCK_NB)) {
echo json_encode(['status' => 'skipped', 'reason' => 'already_running'], JSON_THROW_ON_ERROR) . PHP_EOL;
exit;
}
$env = expand_env_values(parse_env_file(dirname(__DIR__, 2) . '/.env'));
$pdo = connect_database($env);
try {
$jobs = [
'erp.lager.stock_lot_forecast_refresh' => static fn (): array => run_lager_stock_lot_forecast($pdo),
];
$results = [];
foreach ($jobs as $name => $job) {
$results[$name] = $job();
}
echo json_encode(['status' => 'done', 'jobs' => $results], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL;
} catch (Throwable $error) {
fwrite(STDERR, $error->getMessage() . PHP_EOL);
exit(1);
}
+8
View File
@@ -161,10 +161,18 @@ body.sg-bestellungen-mobile-detail-open > :not([data-bestellungen-mobile-detail-
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sg-large-table--lager[data-lager-table-key="products"] .sg-large-table__row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.sg-large-table--lager[data-lager-table-key="products"] .sg-large-table__cell:last-child {
text-align: left;
}
.sg-large-table--lager .lager-forecast-warning {
color: var(--color-signal-red);
}
.sg-card-list-page-drawer__title {
color: var(--color-white);
}