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