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; } }