Add daily stock lot sellout forecast
This commit is contained in:
1 parent
9a1192ae81
commit
1639960ab6
9 files changed
+440
-3
No files matched your search
@@ -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.
|
||||
@@ -15,6 +15,7 @@ Technische Laufzeit-, Start-, Trigger- und Supervisor-Logik.
|
||||
## Technisch umgesetzt
|
||||
|
||||
- `modules/system/deploy.php`
|
||||
- `modules/system/naurua-erp-cron.php`
|
||||
|
||||
## Wrapper
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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>'
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user