225 lines
9.4 KiB
PHP
225 lines
9.4 KiB
PHP
<?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' => [], 'errors' => [],
|
|
];
|
|
|
|
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;
|
|
$result['errors'][] = ['product_id' => $productId, 'message' => $error->getMessage()];
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
}
|