132 lines
5.8 KiB
SQL
132 lines
5.8 KiB
SQL
-- Description: Populates lifecycle forecast columns on product_metrics from product_forecasts.
|
|
-- Runs AFTER update_product_metrics.sql so that lead time / days of stock settings are available.
|
|
-- Dependencies: product_metrics (fully populated), product_forecasts, settings tables.
|
|
-- Frequency: After each metrics run and/or after forecast engine runs.
|
|
|
|
DO $$
|
|
DECLARE
|
|
_module_name TEXT := 'lifecycle_forecasts';
|
|
_start_time TIMESTAMPTZ := clock_timestamp();
|
|
_updated INT;
|
|
BEGIN
|
|
RAISE NOTICE 'Running % module. Start Time: %', _module_name, _start_time;
|
|
|
|
-- Step 1: Set lifecycle_phase from product_forecasts (one phase per product)
|
|
UPDATE product_metrics pm
|
|
SET lifecycle_phase = sub.lifecycle_phase
|
|
FROM (
|
|
SELECT DISTINCT ON (pid) pid, lifecycle_phase
|
|
FROM product_forecasts
|
|
ORDER BY pid, forecast_date
|
|
) sub
|
|
WHERE pm.pid = sub.pid
|
|
AND (pm.lifecycle_phase IS DISTINCT FROM sub.lifecycle_phase);
|
|
|
|
GET DIAGNOSTICS _updated = ROW_COUNT;
|
|
RAISE NOTICE 'Updated lifecycle_phase for % products', _updated;
|
|
|
|
-- Step 2: Compute lifecycle-based lead time and planning period forecasts
|
|
-- Uses each product's configured lead time and days of stock
|
|
WITH forecast_sums AS (
|
|
SELECT
|
|
pf.pid,
|
|
SUM(pf.forecast_units) FILTER (
|
|
WHERE pf.forecast_date <= CURRENT_DATE + s.effective_lead_time
|
|
) AS lt_forecast,
|
|
SUM(pf.forecast_units) FILTER (
|
|
WHERE pf.forecast_date <= CURRENT_DATE + s.effective_lead_time + s.effective_days_of_stock
|
|
) AS pp_forecast
|
|
FROM product_forecasts pf
|
|
JOIN (
|
|
SELECT
|
|
p.pid,
|
|
COALESCE(sp.lead_time_days, sv.default_lead_time_days,
|
|
(SELECT setting_value::int FROM settings_global WHERE setting_key = 'default_lead_time_days'), 14
|
|
) AS effective_lead_time,
|
|
COALESCE(sp.days_of_stock, sv.default_days_of_stock,
|
|
(SELECT setting_value::int FROM settings_global WHERE setting_key = 'default_days_of_stock'), 30
|
|
) AS effective_days_of_stock
|
|
FROM products p
|
|
LEFT JOIN settings_product sp ON p.pid = sp.pid
|
|
LEFT JOIN settings_vendor sv ON p.vendor = sv.vendor
|
|
) s ON s.pid = pf.pid
|
|
WHERE pf.forecast_date >= CURRENT_DATE
|
|
GROUP BY pf.pid
|
|
)
|
|
UPDATE product_metrics pm
|
|
SET
|
|
lifecycle_lead_time_forecast = COALESCE(fs.lt_forecast, 0),
|
|
lifecycle_planning_period_forecast = COALESCE(fs.pp_forecast, 0)
|
|
FROM forecast_sums fs
|
|
WHERE pm.pid = fs.pid
|
|
AND (pm.lifecycle_lead_time_forecast IS DISTINCT FROM COALESCE(fs.lt_forecast, 0)
|
|
OR pm.lifecycle_planning_period_forecast IS DISTINCT FROM COALESCE(fs.pp_forecast, 0));
|
|
|
|
GET DIAGNOSTICS _updated = ROW_COUNT;
|
|
RAISE NOTICE 'Updated lifecycle forecasts for % products', _updated;
|
|
|
|
-- Step 3: Reclassify demand_pattern using residual CV (de-trended)
|
|
-- For launch/decay products, raw CV is high because of expected lifecycle decay.
|
|
-- We subtract the expected brand curve value to get residuals, then compute CV on those.
|
|
-- Products that track their brand curve closely → low residual CV → "stable"
|
|
-- Products with erratic deviations from curve → higher residual CV → "variable"/"sporadic"
|
|
WITH product_curve AS (
|
|
-- Get each product's brand curve and age
|
|
SELECT
|
|
pm.pid,
|
|
pm.lifecycle_phase,
|
|
pm.date_first_received,
|
|
blc.amplitude,
|
|
blc.decay_rate,
|
|
blc.baseline
|
|
FROM product_metrics pm
|
|
JOIN products p ON p.pid = pm.pid
|
|
LEFT JOIN brand_lifecycle_curves blc
|
|
ON blc.brand = pm.brand
|
|
AND blc.root_category IS NULL -- brand-only curve
|
|
WHERE pm.lifecycle_phase IN ('launch', 'decay')
|
|
AND pm.date_first_received IS NOT NULL
|
|
AND blc.amplitude IS NOT NULL
|
|
),
|
|
daily_residuals AS (
|
|
-- Compute residual = actual - expected for each snapshot day
|
|
-- Curve params are in WEEKLY units; divide by 7 to get daily expected
|
|
SELECT
|
|
dps.pid,
|
|
dps.units_sold,
|
|
(pc.amplitude * EXP(-pc.decay_rate * (dps.snapshot_date - pc.date_first_received)::numeric / 7.0) + pc.baseline) / 7.0 AS expected,
|
|
dps.units_sold - (pc.amplitude * EXP(-pc.decay_rate * (dps.snapshot_date - pc.date_first_received)::numeric / 7.0) + pc.baseline) / 7.0 AS residual
|
|
FROM daily_product_snapshots dps
|
|
JOIN product_curve pc ON pc.pid = dps.pid
|
|
WHERE dps.snapshot_date >= CURRENT_DATE - INTERVAL '29 days'
|
|
AND dps.snapshot_date <= CURRENT_DATE
|
|
),
|
|
residual_cv AS (
|
|
SELECT
|
|
pid,
|
|
AVG(units_sold) AS avg_sales,
|
|
CASE WHEN COUNT(*) >= 7 AND AVG(ABS(expected)) > 0.01 THEN
|
|
STDDEV_POP(residual) / GREATEST(AVG(ABS(expected)), 0.1)
|
|
END AS res_cv
|
|
FROM daily_residuals
|
|
GROUP BY pid
|
|
)
|
|
UPDATE product_metrics pm
|
|
SET demand_pattern = classify_demand_pattern(rc.avg_sales, rc.res_cv)
|
|
FROM residual_cv rc
|
|
WHERE pm.pid = rc.pid
|
|
AND rc.res_cv IS NOT NULL
|
|
AND pm.demand_pattern IS DISTINCT FROM classify_demand_pattern(rc.avg_sales, rc.res_cv);
|
|
|
|
GET DIAGNOSTICS _updated = ROW_COUNT;
|
|
RAISE NOTICE 'Reclassified demand_pattern for % launch/decay products', _updated;
|
|
|
|
-- Update tracking
|
|
INSERT INTO public.calculate_status (module_name, last_calculation_timestamp)
|
|
VALUES (_module_name, clock_timestamp())
|
|
ON CONFLICT (module_name) DO UPDATE SET
|
|
last_calculation_timestamp = EXCLUDED.last_calculation_timestamp;
|
|
|
|
RAISE NOTICE '% module complete. Duration: %', _module_name, clock_timestamp() - _start_time;
|
|
END $$;
|