Compare commits

...
5 changed files with 138 additions and 24 deletions

No files matched your search

@@ -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 zentralen Lagerschnittstelle für Shopify- und OTC-Verkäufe 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
+56 -10
View File
@@ -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);
+8
View File
@@ -1,6 +1,8 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/forecast.php';
function get_default_location_ids(PDO $pdo): array
{
$row = $pdo->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,
+36 -3
View File
@@ -643,6 +643,37 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " const contentRoot = document.querySelector('[data-left-navigation-content-body]');";
echo " if (!contentRoot || !contentRoot.querySelector('[data-lager-content]')) { return; }";
echo " lagerBindingsInstalled = true;";
echo " let lagerMobileDetailHost = null;";
echo " let lagerMobileDetailOpen = false;";
echo " const escapeLagerHtml = (value) => String(value ?? '').replace(/[&<>\"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[character] || character));";
echo " const renderLagerMobileDetail = (trigger) => {";
echo " const type = trigger.dataset.lagerDrawerType || 'Lager';";
echo " const name = trigger.dataset.lagerDrawerName || '';";
echo " const value = trigger.dataset.lagerDrawerValue || '';";
echo " const isProduct = type === 'Produkt';";
echo " const forecast = trigger.dataset.lagerDrawerForecast || 'Keine Prognose';";
echo " const warning = trigger.dataset.lagerDrawerForecastWarning === 'true' || trigger.dataset.lagerDrawerForecastMhdWarning === 'true';";
echo " const warningText = warning ? forecast : 'Keine akute Warnung.';";
echo " const lotNumber = trigger.dataset.lagerDrawerLotNumber || 'Nicht hinterlegt';";
echo " const supplierLotNumber = trigger.dataset.lagerDrawerSupplierLotNumber || 'Nicht hinterlegt';";
echo " const expiryDate = trigger.dataset.lagerDrawerExpiryDate || 'Nicht hinterlegt';";
echo " const virtualLot = trigger.dataset.lagerDrawerVirtualLotNumber || '';";
echo " return ['<div class=\"sg-lager-mobile-detail-host\" data-lager-mobile-detail-host=\"true\">', '<div class=\"sg-navigation-card-layout sg-mobile-detail-back-card\">', '<div class=\"sg-navigation-card-block\">', '<article class=\"sg-card\" data-component=\"card\" data-pattern=\"navigation-card\" aria-label=\"Navigation Detailansicht zurück oben\">', '<div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\" data-pattern-part=\"navigation-card-segment\">', '<div class=\"sg-navigation-card-center\">', '<button class=\"sg-interaction-element sg-hyperlink\" type=\"button\" data-lager-detail-back>zurück</button>', '</div></div></article></div></div>', '<article class=\"sg-card sg-vsf-drawer-card\" data-component=\"card\" data-pattern=\"card\" aria-label=\"Lager-Details\">', '<header class=\"sg-card-segment sg-card-segment--header sg-card-segment--darkblue\"><div class=\"sg-strong\">' + escapeLagerHtml(type) + ': ' + escapeLagerHtml(name) + '</div></header>', '<div class=\"sg-card-segment sg-card-segment--body sg-vsf-drawer-card__content\"><p class=\"sg-body\"><strong>Name:</strong> ' + escapeLagerHtml(name) + '</p><p class=\"sg-body\"><strong>' + (isProduct ? 'Anzahl im Lager:' : 'Artikelnummer:') + '</strong> ' + escapeLagerHtml(value) + '</p>' + (isProduct ? '<p class=\"sg-body\"><strong>Warnungen:</strong> ' + escapeLagerHtml(warningText) + '</p><p class=\"sg-body\"><strong>Naurua-Chargennummer:</strong> ' + escapeLagerHtml(lotNumber) + '<br><strong>Hersteller-Chargennummer:</strong> ' + escapeLagerHtml(supplierLotNumber) + '<br><strong>MHD:</strong> ' + escapeLagerHtml(expiryDate) + '</p><p class=\"sg-body\"><strong>Virtuelle Charge:</strong> ' + escapeLagerHtml(virtualLot || 'Keine virtuelle Fehlbestandscharge vorhanden.') + '</p>' : '') + '</div></article></div>'].join('');";
echo " };";
echo " const restoreLagerMobileDetail = () => {";
echo " if (lagerMobileDetailHost && lagerMobileDetailHost.parentNode) { lagerMobileDetailHost.parentNode.removeChild(lagerMobileDetailHost); }";
echo " lagerMobileDetailHost = null; lagerMobileDetailOpen = false; document.body.classList.remove('sg-lager-mobile-detail-open');";
echo " };";
echo " const openLagerMobileDetail = (trigger) => {";
echo " restoreLagerMobileDetail(); const host = document.createElement('div'); host.innerHTML = renderLagerMobileDetail(trigger); lagerMobileDetailHost = host.firstElementChild;";
echo " if (lagerMobileDetailHost) { document.body.appendChild(lagerMobileDetailHost); document.body.classList.add('sg-lager-mobile-detail-open'); }";
echo " lagerMobileDetailOpen = true; window.history.pushState({ ...(window.history.state || {}), lagerView: 'detail' }, '', window.location.href);";
echo " };";
echo " const closeLagerMobileDetail = () => {";
echo " if (!lagerMobileDetailOpen) { return; }";
echo " if (window.history.state && window.history.state.lagerView === 'detail') { window.history.back(); return; }";
echo " restoreLagerMobileDetail();";
echo " };";
echo " const getDrawer = () => contentRoot.querySelector('[data-lager-drawer]');";
echo " const setDrawerState = (open) => {";
echo " const drawer = getDrawer(); const backdrop = contentRoot.querySelector('[data-lager-drawer-backdrop]');";
@@ -652,10 +683,12 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " const closeDrawer = () => { setDrawerState(false); contentRoot.querySelectorAll('[data-lager-drawer-open]').forEach((trigger) => trigger.setAttribute('aria-expanded', 'false')); };";
echo " contentRoot.addEventListener('click', (event) => {";
echo " const trigger = event.target.closest('[data-lager-drawer-open]');";
echo " if (trigger) { event.preventDefault(); const drawer = getDrawer(); if (!drawer) { return; } const type = trigger.dataset.lagerDrawerType || 'Lager'; const name = trigger.dataset.lagerDrawerName || ''; const value = trigger.dataset.lagerDrawerValue || ''; drawer.querySelector('[data-lager-drawer-title]').textContent = type + ': ' + name; drawer.querySelector('[data-lager-drawer-name-value]').textContent = name; const valueRow = drawer.querySelector('[data-lager-drawer-value-row]'); if (valueRow) { valueRow.hidden = false; } drawer.querySelector('[data-lager-drawer-value-label]').textContent = type === 'Produkt' ? 'Anzahl im Lager:' : 'Artikelnummer:'; drawer.querySelector('[data-lager-drawer-value-value]').textContent = value; drawer.querySelectorAll('[data-lager-drawer-topic]').forEach((card) => { card.hidden = type !== 'Produkt'; }); if (type === 'Produkt') { const forecast = trigger.dataset.lagerDrawerForecast || 'Keine Prognose'; const warning = trigger.dataset.lagerDrawerForecastWarning === 'true' || trigger.dataset.lagerDrawerForecastMhdWarning === 'true'; const warningValue = drawer.querySelector('[data-lager-drawer-warning-value]'); if (warningValue) { warningValue.textContent = warning ? forecast : 'Keine akute Warnung.'; warningValue.classList.toggle('sg-strong', warning); warningValue.classList.toggle('lager-forecast-warning', trigger.dataset.lagerDrawerForecastWarning === 'true'); warningValue.classList.toggle('lager-forecast-mhd-warning', trigger.dataset.lagerDrawerForecastMhdWarning === 'true'); } drawer.querySelector('[data-lager-drawer-lot-number]').textContent = trigger.dataset.lagerDrawerLotNumber || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-supplier-lot-number]').textContent = trigger.dataset.lagerDrawerSupplierLotNumber || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-expiry-date]').textContent = trigger.dataset.lagerDrawerExpiryDate || 'Nicht hinterlegt'; const virtualLot = trigger.dataset.lagerDrawerVirtualLotNumber || ''; drawer.querySelector('[data-lager-drawer-virtual-lot-value]').textContent = virtualLot ? 'Virtuelle Charge: ' + virtualLot : 'Keine virtuelle Fehlbestandscharge vorhanden.'; } setDrawerState(true); contentRoot.querySelectorAll('[data-lager-drawer-open]').forEach((button) => button.setAttribute('aria-expanded', String(button === trigger))); return; }";
echo " if (trigger) { event.preventDefault(); if (window.matchMedia('(max-width: 767px)').matches) { openLagerMobileDetail(trigger); return; } const drawer = getDrawer(); if (!drawer) { return; } const type = trigger.dataset.lagerDrawerType || 'Lager'; const name = trigger.dataset.lagerDrawerName || ''; const value = trigger.dataset.lagerDrawerValue || ''; drawer.querySelector('[data-lager-drawer-title]').textContent = type + ': ' + name; drawer.querySelector('[data-lager-drawer-name-value]').textContent = name; const valueRow = drawer.querySelector('[data-lager-drawer-value-row]'); if (valueRow) { valueRow.hidden = false; } drawer.querySelector('[data-lager-drawer-value-label]').textContent = type === 'Produkt' ? 'Anzahl im Lager:' : 'Artikelnummer:'; drawer.querySelector('[data-lager-drawer-value-value]').textContent = value; drawer.querySelectorAll('[data-lager-drawer-topic]').forEach((card) => { card.hidden = type !== 'Produkt'; }); if (type === 'Produkt') { const forecast = trigger.dataset.lagerDrawerForecast || 'Keine Prognose'; const warning = trigger.dataset.lagerDrawerForecastWarning === 'true' || trigger.dataset.lagerDrawerForecastMhdWarning === 'true'; const warningValue = drawer.querySelector('[data-lager-drawer-warning-value]'); if (warningValue) { warningValue.textContent = warning ? forecast : 'Keine akute Warnung.'; warningValue.classList.toggle('sg-strong', warning); warningValue.classList.toggle('lager-forecast-warning', trigger.dataset.lagerDrawerForecastWarning === 'true'); warningValue.classList.toggle('lager-forecast-mhd-warning', trigger.dataset.lagerDrawerForecastMhdWarning === 'true'); } drawer.querySelector('[data-lager-drawer-lot-number]').textContent = trigger.dataset.lagerDrawerLotNumber || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-supplier-lot-number]').textContent = trigger.dataset.lagerDrawerSupplierLotNumber || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-expiry-date]').textContent = trigger.dataset.lagerDrawerExpiryDate || 'Nicht hinterlegt'; const virtualLot = trigger.dataset.lagerDrawerVirtualLotNumber || ''; drawer.querySelector('[data-lager-drawer-virtual-lot-value]').textContent = virtualLot ? 'Virtuelle Charge: ' + virtualLot : 'Keine virtuelle Fehlbestandscharge vorhanden.'; } setDrawerState(true); contentRoot.querySelectorAll('[data-lager-drawer-open]').forEach((button) => button.setAttribute('aria-expanded', String(button === trigger))); return; }";
echo " if (event.target.closest('[data-lager-drawer-close]') || event.target.closest('[data-lager-drawer-backdrop]')) { closeDrawer(); }";
echo " });";
echo " document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { closeDrawer(); } });";
echo " document.addEventListener('click', (event) => { if (event.target.closest('[data-lager-detail-back]')) { event.preventDefault(); closeLagerMobileDetail(); } });";
echo " document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { closeDrawer(); closeLagerMobileDetail(); } });";
echo " window.addEventListener('popstate', (event) => { if (lagerMobileDetailOpen && (!event.state || event.state.lagerView !== 'detail')) { restoreLagerMobileDetail(); } });";
echo "}";
echo "function initBestellungenBindings() {";
echo " const contentRoot = document.querySelector('[data-left-navigation-content-body]');";
@@ -717,7 +750,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " const cityLine = [zip, city].filter((part) => part && part.length > 0).join(' ').trim();";
echo " return [";
echo " '<div class=\"sg-bestellungen-mobile-detail-host\" data-bestellungen-mobile-detail-host=\"true\">',";
echo " '<div class=\"sg-navigation-card-layout\">',";
echo " '<div class=\"sg-navigation-card-layout sg-mobile-detail-back-card\">',";
echo " '<div class=\"sg-navigation-card-block\">',";
echo " '<article class=\"sg-card\" data-component=\"card\" data-pattern=\"navigation-card\" aria-label=\"Navigation Detailansicht zurück oben\">',";
echo " '<div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\" data-pattern-part=\"navigation-card-segment\">',";
+26
View File
@@ -58,6 +58,10 @@ body.sg-bestellungen-mobile-detail-open > :not([data-bestellungen-mobile-detail-
display: none !important;
}
body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
display: none !important;
}
.sg-bestellungen-mobile-detail-host {
position: fixed;
inset: 0;
@@ -75,6 +79,28 @@ body.sg-bestellungen-mobile-detail-open > :not([data-bestellungen-mobile-detail-
width: 100%;
}
.sg-lager-mobile-detail-host {
position: fixed;
inset: 0;
z-index: 1500;
display: flex;
flex-direction: column;
gap: var(--spacing-large);
padding: var(--spacing-small);
box-sizing: border-box;
overflow: auto;
background: var(--color-background-purple);
}
.sg-lager-mobile-detail-host .sg-vsf-drawer-card {
width: 100%;
}
.sg-mobile-detail-back-card,
.sg-mobile-detail-back-card * {
cursor: pointer;
}
.sg-otc-order-overlay {
position: fixed;
inset: 0;