Refresh stock forecast after each sale
This commit is contained in:
1 parent
5dd41a4bb2
commit
03ea914d04
3 files changed
+76
-21
No files matched your search
@@ -8,27 +8,28 @@ Tägliche Berechnung und Speicherung des voraussichtlichen Leer-Datums jeder akt
|
|||||||
|
|
||||||
## 2. Prozess-Einbettung
|
## 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
|
## 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
|
- aktive Produkte, aktuelle Chargen, physischer Chargenbestand und gültige Verkaufsallokationen aus dem eigenen Datenbestand
|
||||||
|
|
||||||
## 4. Exakter Prozessablauf
|
## 4. Exakter Prozessablauf
|
||||||
|
|
||||||
1. Aktive Produkte und ihre aktuelle Charge aus `product`, `stock_lot` und `v_stock_lot_balance` laden.
|
1. Bei einem Verkaufstrigger die betroffenen Produkt-IDs aus der erfolgreichen Allokation übernehmen; beim Cron-Trigger alle aktiven Produkte bestimmen.
|
||||||
2. Verkaufsallokationen der letzten 365 Tage aus `sales_order_line_lot_allocation`, `sales_order_line` und `sales_order` laden.
|
2. Aktive Produkte und ihre aktuelle Charge aus `product`, `stock_lot` und `v_stock_lot_balance` laden.
|
||||||
3. `allocated`- und `reserved`-Mengen nach Bestell- beziehungsweise OTC-Verkaufsdatum je Produkt und Kalendertag aggregieren.
|
3. Verkaufsallokationen der letzten 365 Tage aus `sales_order_line_lot_allocation`, `sales_order_line` und `sales_order` laden.
|
||||||
4. Stornierte Auftrags- und Positionsmengen aus der Nachfrage herausrechnen.
|
4. `allocated`- und `reserved`-Mengen nach Bestell- beziehungsweise OTC-Verkaufsdatum je Produkt und Kalendertag aggregieren.
|
||||||
5. Den gewichteten Tagesabsatz mit einer Halbwertszeit von 60 Tagen berechnen.
|
5. Stornierte Auftrags- und Positionsmengen aus der Nachfrage herausrechnen.
|
||||||
6. Für jede aktuelle Charge das Leer-Datum aus physischem Bestand und gewichteter Nachfrage berechnen.
|
6. Den gewichteten Tagesabsatz mit einer Halbwertszeit von 60 Tagen berechnen.
|
||||||
7. Jede erfolgreiche Berechnung in `stock_lot_forecast_history` speichern und den aktuellen Snapshot in `stock_lot` aktualisieren.
|
7. Für jede aktuelle Charge das Leer-Datum aus physischem Bestand und gewichteter Nachfrage berechnen.
|
||||||
8. Fehler einzelner Produkte sammeln und mit den übrigen Produkten fortfahren.
|
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
|
## 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
|
## 6. Output
|
||||||
|
|
||||||
|
|||||||
@@ -45,11 +45,27 @@ function lager_forecast_load_current_lots(PDO $pdo): array
|
|||||||
return $stmt->fetchAll();
|
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');
|
$startDate = $asOfDate->modify('-' . ($lookbackDays - 1) . ' days')->format('Y-m-d');
|
||||||
$endDate = $asOfDate->modify('+1 day')->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,
|
"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
|
SUM(a.qty * GREATEST(sol.qty - sol.qty_cancelled, 0) / NULLIF(sol.qty, 0)) AS units
|
||||||
FROM sales_order_line_lot_allocation a
|
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 sol.line_status <> 'cancelled'
|
||||||
AND so.order_status <> 'cancelled'
|
AND so.order_status <> 'cancelled'
|
||||||
AND so.order_date >= :start_date::DATE
|
AND so.order_date >= :start_date::DATE
|
||||||
AND so.order_date < :end_date::DATE
|
AND so.order_date < :end_date::DATE";
|
||||||
GROUP BY a.product_id, so.order_date::DATE
|
$params = [':start_date' => $startDate, ':end_date' => $endDate];
|
||||||
ORDER BY a.product_id, sale_date"
|
if ($productId !== null) {
|
||||||
);
|
$sql .= ' AND a.product_id = :product_id';
|
||||||
$stmt->execute([':start_date' => $startDate, ':end_date' => $endDate]);
|
$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 = [];
|
$sales = [];
|
||||||
foreach ($stmt->fetchAll() as $row) {
|
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
|
function lager_forecast_persist(PDO $pdo, array $lot, array $forecast, DateTimeImmutable $asOfDate): void
|
||||||
{
|
{
|
||||||
$pdo->beginTransaction();
|
$startedTransaction = !$pdo->inTransaction();
|
||||||
|
if ($startedTransaction) {
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
"INSERT INTO stock_lot_forecast_history (
|
"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'],
|
':confidence' => $forecast['confidence'], ':daily_demand' => $forecast['daily_demand'],
|
||||||
':lot_id' => (int) $lot['lot_id'],
|
':lot_id' => (int) $lot['lot_id'],
|
||||||
]);
|
]);
|
||||||
$pdo->commit();
|
if ($startedTransaction) {
|
||||||
|
$pdo->commit();
|
||||||
|
}
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
if ($pdo->inTransaction()) {
|
if ($startedTransaction && $pdo->inTransaction()) {
|
||||||
$pdo->rollBack();
|
$pdo->rollBack();
|
||||||
}
|
}
|
||||||
throw $error;
|
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
|
function run_lager_stock_lot_forecast(PDO $pdo): array
|
||||||
{
|
{
|
||||||
$runId = lager_forecast_start_run($pdo);
|
$runId = lager_forecast_start_run($pdo);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/forecast.php';
|
||||||
|
|
||||||
function get_default_location_ids(PDO $pdo): array
|
function get_default_location_ids(PDO $pdo): array
|
||||||
{
|
{
|
||||||
$row = $pdo->query(
|
$row = $pdo->query(
|
||||||
@@ -530,8 +532,10 @@ function allocate_components_for_line(
|
|||||||
|
|
||||||
$allocationCount = 0;
|
$allocationCount = 0;
|
||||||
$virtualAllocationCount = 0;
|
$virtualAllocationCount = 0;
|
||||||
|
$forecastProductIds = [];
|
||||||
foreach ($components as $component) {
|
foreach ($components as $component) {
|
||||||
$productId = (int) $component['product_id'];
|
$productId = (int) $component['product_id'];
|
||||||
|
$forecastProductIds[$productId] = true;
|
||||||
$remaining = $lineQty * (float) $component['qty_per_item'];
|
$remaining = $lineQty * (float) $component['qty_per_item'];
|
||||||
$guard = 0;
|
$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 [
|
return [
|
||||||
'allocated' => true,
|
'allocated' => true,
|
||||||
'allocationCount' => $allocationCount,
|
'allocationCount' => $allocationCount,
|
||||||
|
|||||||
Reference in new issue
Block a user