fix: implement product line seasonality for forecast fallback

This commit is contained in:
christian.vidal
2026-02-01 15:47:33 +01:00
parent 01e20eb073
commit f3e13769d7
+39 -7
View File
@@ -1768,14 +1768,49 @@ export const calculateForecastViewData = (
monthMap.set(m, (monthMap.get(m) || 0) + r.units);
});
// 1b. Determine Line-Level Weights (NEW STRATEGY)
const lineWeightsMap = new Map<string, number[]>();
const linesMap = new Map<string, SalesRecord[]>();
historicalData.forEach(r => {
if (!r.line) return;
if (!linesMap.has(r.line)) linesMap.set(r.line, []);
linesMap.get(r.line)!.push(r);
});
linesMap.forEach((records, line) => {
// We use the same getWeightsInfo logic but for the whole line
const info = getWeightsInfo(records);
if (info) {
lineWeightsMap.set(line, info.weights);
} else {
// Fallback for line if it has data but odd distribution?
// Actually getWeightsInfo returns null only if total=0.
// If we have records but 0 units total, we skip map set, so it will fall to global.
}
});
return forecastData.map(fc => {
const identifier = fc.asin.toUpperCase();
const meta = asinMetadata.get(identifier);
// Resolve Line: Try meta first, then forecast file
const resolvedLine = meta?.line || fc.line || "Unassigned";
const avgWeeklySales = velocityMap?.get(identifier) || 0;
// 2. Determine weights for this ASIN
const productHistoricalRecords = dataByAsinHistorical.get(identifier) || [];
let productWeights = globalWeights;
// LAYERED FALLBACK STRATEGY:
// Level 1: Product's own history (Most accurate)
// Level 2: Product Line's history (Good for new items in known category e.g. Advent Calendars)
// Level 3: Global/Pan-EU history (Generic fallback)
const lineWeights = lineWeightsMap.get(resolvedLine);
const baselineWeights = lineWeights || globalWeights;
let finalWeights = baselineWeights;
if (productHistoricalRecords.length > 0) {
const historyToUse = isUkOnly
@@ -1785,12 +1820,9 @@ export const calculateForecastViewData = (
const info = getWeightsInfo(historyToUse);
if (info) {
// Adaptive Blending (Bayesian Shrinkage):
// We blend local seasonality with global seasonality based on how many months of data we have.
// 12 months = 85% local, 15% global (safety net)
// 6 months = 42.5% local, 57.5% global
// 0 months = 0% local, 100% global
// We blend local seasonality with baseline (Line or Global) based on data density.
const trustFactor = (info.monthsCount / 12) * 0.85;
productWeights = info.weights.map((w, i) => (w * trustFactor) + (globalWeights[i] * (1 - trustFactor)));
finalWeights = info.weights.map((w, i) => (w * trustFactor) + (baselineWeights[i] * (1 - trustFactor)));
}
}
@@ -1800,7 +1832,7 @@ export const calculateForecastViewData = (
let totalForecastUnits = 0;
MONTH_ORDER.forEach((m, idx) => {
const forecastUnits = Math.round(fc.annualForecast * productWeights[idx]);
const forecastUnits = Math.round(fc.annualForecast * finalWeights[idx]);
const actualUnits = actuals2026.get(identifier)?.get(m) || 0;
monthlyData[m] = {