fix: improve forecast seasonality logic and catalog data integrity

This commit is contained in:
Christian Vidal Wolf
2026-01-30 09:10:02 +01:00
parent f26ea171ce
commit a678880112
+86 -61
View File
@@ -1319,8 +1319,16 @@ export const pivotSalesData = (data: any[], dimensions: string[] = ['title', 'cu
const keyParts = new Array(dimensions.length);
for (let i = 0; i < dimensions.length; i++) {
const dim = dimensions[i];
if (dim === 'customer') keyParts[i] = String(record.customer || record.marketplace || '');
else keyParts[i] = String(record[dim] || '');
let val = '';
if (dim === 'customer') val = String(record.customer || record.marketplace || '');
else val = String(record[dim] || '');
// Normalize ASIN and SKU in keys to fold duplicates
if (dim === 'asin' || dim === 'sku' || dim === 'customer') {
keyParts[i] = val.trim().toUpperCase();
} else {
keyParts[i] = val;
}
}
const key = keyParts.join('||');
@@ -1578,7 +1586,9 @@ export const pivotWeeklySalesData = (data: CombinedKPIs[]): {
const weekKey = getWeekKey(record.year, record.week);
weekKeysSet.add(weekKey);
const key = record.asin || record.sku || `${record.title}-${record.line}`;
const recordAsin = (record.asin || '').trim().toUpperCase();
const recordSku = (record.sku || '').trim().toUpperCase();
const key = recordAsin || recordSku || `${record.title}-${record.line}`;
if (!key) continue;
let row = map.get(key);
@@ -1643,7 +1653,7 @@ export const calculateForecastViewData = (
): ProductForecastData[] => {
// Use referenceData if provided (for global weights), otherwise fallback to rawData
const seasonalitySource = referenceData || rawData;
const data2025 = seasonalitySource.filter(r => r.year === 2025);
const historicalData = seasonalitySource.filter(r => r.year < 2026);
const data2026 = seasonalitySource.filter(r => r.year === 2026);
// Calculate Seasonality weights for 2025
@@ -1673,7 +1683,7 @@ export const calculateForecastViewData = (
};
// 1. Determine Global/Default Weights
const panEuData2025 = data2025.filter(r => PAN_EU_COUNTRIES.includes(r.customer));
const panEuHistoricalData = historicalData.filter(r => PAN_EU_COUNTRIES.includes(r.customer));
// For Global Weights, we do NOT return null on sparse data (we accept whatever we have for the whole catalog)
// We recreate a simple version of getWeights that doesn't return null for the global set
const getGlobalWeightsInner = (records: SalesRecord[]) => {
@@ -1701,7 +1711,7 @@ export const calculateForecastViewData = (
return weights.map(w => w / total);
};
const panEuWeights = getGlobalWeightsInner(panEuData2025);
const panEuWeights = getGlobalWeightsInner(panEuHistoricalData);
// Check if we are in UK-only mode
const isUkOnly = filters?.customer?.includes('Amazon UK') && filters.customer.length === 1;
@@ -1729,15 +1739,15 @@ export const calculateForecastViewData = (
};
const globalWeights = isUkOnly
? getHybridWeights(panEuData2025, data2025.filter(r => r.customer === 'Amazon UK'))
? getHybridWeights(panEuHistoricalData, historicalData.filter(r => r.customer === 'Amazon UK'))
: panEuWeights;
// Map 2025 data by ASIN for quick access
const dataByAsin2025 = new Map<string, SalesRecord[]>();
data2025.forEach(r => {
// Map historical data by ASIN for quick access
const dataByAsinHistorical = new Map<string, SalesRecord[]>();
historicalData.forEach(r => {
const key = r.asin.trim().toUpperCase();
if (!dataByAsin2025.has(key)) dataByAsin2025.set(key, []);
dataByAsin2025.get(key)!.push(r);
if (!dataByAsinHistorical.has(key)) dataByAsinHistorical.set(key, []);
dataByAsinHistorical.get(key)!.push(r);
});
// Map 2026 actual sales by ASIN and Month
@@ -1756,32 +1766,23 @@ export const calculateForecastViewData = (
const avgWeeklySales = velocityMap?.get(identifier) || 0;
// 2. Determine weights for this ASIN
const productRecords2025 = dataByAsin2025.get(identifier) || [];
const productHistoricalRecords = dataByAsinHistorical.get(identifier) || [];
let productWeights = globalWeights;
if (productRecords2025.length > 0) {
if (isUkOnly) {
const ukProd = productRecords2025.filter(r => r.customer === 'Amazon UK');
const info = getWeightsInfo(ukProd);
if (info) {
if (info.monthsCount < 6) {
// Smart Blending: 50% specific, 50% global
productWeights = info.weights.map((w, i) => (w * 0.5) + (globalWeights[i] * 0.5));
} else {
productWeights = info.weights;
}
}
} else {
const info = getWeightsInfo(productRecords2025);
if (info) {
if (info.monthsCount < 6) {
// Smart Blending: 50% specific, 50% global
productWeights = info.weights.map((w, i) => (w * 0.5) + (globalWeights[i] * 0.5));
} else {
productWeights = info.weights;
}
}
// If info is null (sparse data), productWeights remains globalWeights (Default)
if (productHistoricalRecords.length > 0) {
const historyToUse = isUkOnly
? productHistoricalRecords.filter(r => r.customer === 'Amazon UK')
: productHistoricalRecords;
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
const trustFactor = (info.monthsCount / 12) * 0.85;
productWeights = info.weights.map((w, i) => (w * trustFactor) + (globalWeights[i] * (1 - trustFactor)));
}
}
@@ -1806,8 +1807,8 @@ export const calculateForecastViewData = (
});
const accuracy = totalForecastUnits > 0
? Math.min(100, Math.round((1 - Math.abs(totalActualUnits - totalForecastUnits) / totalForecastUnits) * 100))
: 0;
? Math.max(0, Math.min(100, Math.round((1 - Math.abs(totalActualUnits - totalForecastUnits) / totalForecastUnits) * 100)))
: (totalActualUnits === 0 ? 100 : 0);
return {
asin: identifier,
@@ -1839,31 +1840,48 @@ export const processVendorStockExcel = async (fileOrBuffer: File | ArrayBuffer):
// Find header row (it contains "ASIN")
let headerRowIndex = -1;
for (let i = 0; i < Math.min(jsonData.length, 20); i++) {
if (jsonData[i] && jsonData[i].includes('ASIN')) {
if (jsonData[i] && (jsonData[i].includes('ASIN') || jsonData[i].includes('asin'))) {
headerRowIndex = i;
break;
}
}
if (headerRowIndex === -1) {
console.warn("Could not find header row in Vendor Stock Excel");
return vendorStockMap;
// Fallback: look for ASIN in first row if not found in header scan
if (jsonData[0] && (jsonData[0].includes('ASIN') || jsonData[0].includes('asin'))) headerRowIndex = 0;
else {
console.warn("Could not find header row in Vendor Stock Excel");
return vendorStockMap;
}
}
// Skip headers
// A (0): ASIN
// D (3): Marketplace (Country)
// P (15): Sellable on hands units
const headers: any[] = jsonData[headerRowIndex];
const asinIdx = headers.findIndex(h => String(h || '').toUpperCase() === 'ASIN');
const marketplaceIdx = headers.findIndex(h => {
const sh = String(h || '').toUpperCase();
return sh === 'MARKETPLACE' || sh === 'COUNTRY' || sh === 'COUNTRY/REGION';
});
const stockIdx = headers.findIndex(h => {
const sh = String(h || '').toUpperCase();
return sh.includes('ON HAND') || sh.includes('SELLABLE') || sh.includes('STOCK') || sh.includes('AVAILABILITY');
});
// Final sanity check for indexes, fallback to defaults if headers.findIndex returned -1
const finalAsinIdx = asinIdx !== -1 ? asinIdx : 0;
const finalMarketplaceIdx = marketplaceIdx !== -1 ? marketplaceIdx : 3;
const finalStockIdx = stockIdx !== -1 ? stockIdx : 15;
for (let i = headerRowIndex + 1; i < jsonData.length; i++) {
const row = jsonData[i];
if (!row || row.length < 16) continue;
const asin = String(row[0] || '').trim().toUpperCase();
const marketplace = String(row[3] || '').trim().toLowerCase();
const stockValue = parseUnits(String(row[15] || '0'));
if (!row || row.length <= Math.max(finalAsinIdx, finalMarketplaceIdx, finalStockIdx)) continue;
const asin = String(row[finalAsinIdx] || '').trim().toUpperCase();
if (!asin) continue;
const marketplace = String(row[finalMarketplaceIdx] || '').trim().toLowerCase();
const stockValue = parseUnits(String(row[finalStockIdx] || '0'));
if (!vendorStockMap.has(asin)) {
vendorStockMap.set(asin, { eu: 0, uk: 0 });
}
@@ -1871,10 +1889,10 @@ export const processVendorStockExcel = async (fileOrBuffer: File | ArrayBuffer):
const current = vendorStockMap.get(asin)!;
// Map to UK or EU
if (marketplace.includes('uk') || marketplace.includes('kingdom') || marketplace === 'gb') {
if (marketplace.includes('uk') || marketplace.includes('kingdom') || marketplace === 'gb' || marketplace === 'united kingdom') {
current.uk += stockValue;
} else {
// Assume everything else is Pan-EU for now if it's not UK
} else if (marketplace) {
// Assume everything else with a marketplace is Pan-EU (DE, IT, FR, ES)
current.eu += stockValue;
}
}
@@ -1988,11 +2006,17 @@ export const processBuyBoxExcel = async (fileOrBuffer: File | ArrayBuffer): Prom
const jsonData: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
const headers: any[] = jsonData[0] || [];
const asinIdx = headers.findIndex(h => String(h || '').toUpperCase() === 'ASIN');
const finalAsinIdx = asinIdx !== -1 ? asinIdx : 1; // Default to Col B if not found
// Skip header row (index 0)
for (let i = 1; i < jsonData.length; i++) {
const row = jsonData[i];
const asin = String(row[1] || '').trim().toUpperCase(); // Col B = index 1
if (!asin || asin.length < 5) continue;
if (!row || row.length <= Math.max(finalAsinIdx, config.reasonCol)) continue;
const rawAsin = String(row[finalAsinIdx] || '').trim().toUpperCase();
if (!rawAsin || rawAsin.length < 5) continue;
const rawReason = String(row[config.reasonCol] || '').trim();
@@ -2000,17 +2024,18 @@ export const processBuyBoxExcel = async (fileOrBuffer: File | ArrayBuffer): Prom
// Empty reason or "Fixed" status means no issue = they have the Buy Box
if (!rawReason || rawReason.toLowerCase() === 'fixed') continue;
const reason = rawReason;
if (!buyBoxMap.has(asin)) {
buyBoxMap.set(asin, { countries: [], reasons: {} });
let entry = buyBoxMap.get(rawAsin);
if (!entry) {
entry = { countries: [], reasons: {} };
buyBoxMap.set(rawAsin, entry);
}
const entry = buyBoxMap.get(asin)!;
if (!entry.countries.includes(config.country)) {
entry.countries.push(config.country);
}
entry.reasons[config.country] = reason;
// Collect reason for this country
entry.reasons[config.country] = rawReason;
}
}