feat: implement Forecast 2026 (Fc 26) tab with seasonality logic and actual sales comparison

This commit is contained in:
Christian Vidal Wolf
2026-01-26 15:47:36 +01:00
parent 18c4ef375b
commit e213ef0d16
7 changed files with 402 additions and 4 deletions
+94 -1
View File
@@ -1,4 +1,4 @@
import { SalesRecord, AdsRecord, TrafficRecord, CombinedKPIs, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint } from '../types';
import { SalesRecord, AdsRecord, TrafficRecord, CombinedKPIs, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint, ForecastRecord, MonthlyForecastPoint, ProductForecastData } from '../types';
import * as XLSX from 'xlsx';
import Papa from 'papaparse';
@@ -1496,3 +1496,96 @@ export const pivotWeeklySalesData = (data: CombinedKPIs[]): {
weeks: sortedWeeks
};
};
export const processForecastExcel = async (fileOrBuffer: File | ArrayBuffer): Promise<ForecastRecord[]> => {
try {
const arrayBuffer = fileOrBuffer instanceof File
? await fileOrBuffer.arrayBuffer()
: fileOrBuffer;
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData: any[] = XLSX.utils.sheet_to_json(worksheet, { defval: "" });
return jsonData.map(row => ({
asin: String(row['ASIN'] || row['asin'] || '').trim().toUpperCase(),
annualForecast: parseUnits(String(row['Forecast 2026'] || row['forecast 2026'] || '0'))
})).filter(r => r.asin && r.annualForecast > 0);
} catch (error) {
console.error("Error processing Forecast Excel:", error);
throw error;
}
};
export const calculateForecastViewData = (
rawData: SalesRecord[],
forecastData: ForecastRecord[],
asinMetadata: Map<string, { sku: string; title: string; line: string }>
): ProductForecastData[] => {
const data2025 = rawData.filter(r => r.year === 2025);
const data2026 = rawData.filter(r => r.year === 2026);
// Calculate Global Seasonality weights for 2025
const getWeights = (records: SalesRecord[]) => {
const weights = new Array(12).fill(0);
let total = 0;
records.forEach(r => {
const m = r.month.split('-')[0];
const idx = MONTH_ORDER.indexOf(m);
if (idx !== -1) {
weights[idx] += r.units;
total += r.units;
}
});
if (total === 0) return new Array(12).fill(1 / 12);
return weights.map(w => w / total);
};
const globalWeights = getWeights(data2025);
// Map 2025 data by ASIN for quick access
const dataByAsin2025 = new Map<string, SalesRecord[]>();
data2025.forEach(r => {
const key = r.asin.trim().toUpperCase();
if (!dataByAsin2025.has(key)) dataByAsin2025.set(key, []);
dataByAsin2025.get(key)!.push(r);
});
// Map 2026 actual sales by ASIN and Month
const actuals2026 = new Map<string, Map<string, number>>();
data2026.forEach(r => {
const key = r.asin.trim().toUpperCase();
const m = r.month.split('-')[0];
if (!actuals2026.has(key)) actuals2026.set(key, new Map());
const monthMap = actuals2026.get(key)!;
monthMap.set(m, (monthMap.get(m) || 0) + r.units);
});
return forecastData.map(fc => {
const identifier = fc.asin.toUpperCase();
const meta = asinMetadata.get(identifier);
// 1. Determine weights (Product specific or global backup)
const productRecords2025 = dataByAsin2025.get(identifier) || [];
const weights = productRecords2025.length > 0 ? getWeights(productRecords2025) : globalWeights;
// 2. Build monthly points
const monthlyData: MonthlyForecastPoint[] = MONTH_ORDER.map((m, idx) => {
const forecastUnits = Math.round(fc.annualForecast * weights[idx]);
const actualUnits = actuals2026.get(identifier)?.get(m) || 0;
return {
month: m,
forecastUnits,
actualUnits
};
});
return {
asin: identifier,
sku: meta?.sku || identifier, // Fallback to ASIN if SKU not found
title: meta?.title || identifier,
annualForecast: fc.annualForecast,
monthlyData
};
});
};