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); const keyParts = new Array(dimensions.length);
for (let i = 0; i < dimensions.length; i++) { for (let i = 0; i < dimensions.length; i++) {
const dim = dimensions[i]; const dim = dimensions[i];
if (dim === 'customer') keyParts[i] = String(record.customer || record.marketplace || ''); let val = '';
else keyParts[i] = String(record[dim] || ''); 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('||'); const key = keyParts.join('||');
@@ -1578,7 +1586,9 @@ export const pivotWeeklySalesData = (data: CombinedKPIs[]): {
const weekKey = getWeekKey(record.year, record.week); const weekKey = getWeekKey(record.year, record.week);
weekKeysSet.add(weekKey); 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; if (!key) continue;
let row = map.get(key); let row = map.get(key);
@@ -1643,7 +1653,7 @@ export const calculateForecastViewData = (
): ProductForecastData[] => { ): ProductForecastData[] => {
// Use referenceData if provided (for global weights), otherwise fallback to rawData // Use referenceData if provided (for global weights), otherwise fallback to rawData
const seasonalitySource = referenceData || 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); const data2026 = seasonalitySource.filter(r => r.year === 2026);
// Calculate Seasonality weights for 2025 // Calculate Seasonality weights for 2025
@@ -1673,7 +1683,7 @@ export const calculateForecastViewData = (
}; };
// 1. Determine Global/Default Weights // 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) // 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 // We recreate a simple version of getWeights that doesn't return null for the global set
const getGlobalWeightsInner = (records: SalesRecord[]) => { const getGlobalWeightsInner = (records: SalesRecord[]) => {
@@ -1701,7 +1711,7 @@ export const calculateForecastViewData = (
return weights.map(w => w / total); return weights.map(w => w / total);
}; };
const panEuWeights = getGlobalWeightsInner(panEuData2025); const panEuWeights = getGlobalWeightsInner(panEuHistoricalData);
// Check if we are in UK-only mode // Check if we are in UK-only mode
const isUkOnly = filters?.customer?.includes('Amazon UK') && filters.customer.length === 1; const isUkOnly = filters?.customer?.includes('Amazon UK') && filters.customer.length === 1;
@@ -1729,15 +1739,15 @@ export const calculateForecastViewData = (
}; };
const globalWeights = isUkOnly const globalWeights = isUkOnly
? getHybridWeights(panEuData2025, data2025.filter(r => r.customer === 'Amazon UK')) ? getHybridWeights(panEuHistoricalData, historicalData.filter(r => r.customer === 'Amazon UK'))
: panEuWeights; : panEuWeights;
// Map 2025 data by ASIN for quick access // Map historical data by ASIN for quick access
const dataByAsin2025 = new Map<string, SalesRecord[]>(); const dataByAsinHistorical = new Map<string, SalesRecord[]>();
data2025.forEach(r => { historicalData.forEach(r => {
const key = r.asin.trim().toUpperCase(); const key = r.asin.trim().toUpperCase();
if (!dataByAsin2025.has(key)) dataByAsin2025.set(key, []); if (!dataByAsinHistorical.has(key)) dataByAsinHistorical.set(key, []);
dataByAsin2025.get(key)!.push(r); dataByAsinHistorical.get(key)!.push(r);
}); });
// Map 2026 actual sales by ASIN and Month // Map 2026 actual sales by ASIN and Month
@@ -1756,32 +1766,23 @@ export const calculateForecastViewData = (
const avgWeeklySales = velocityMap?.get(identifier) || 0; const avgWeeklySales = velocityMap?.get(identifier) || 0;
// 2. Determine weights for this ASIN // 2. Determine weights for this ASIN
const productRecords2025 = dataByAsin2025.get(identifier) || []; const productHistoricalRecords = dataByAsinHistorical.get(identifier) || [];
let productWeights = globalWeights; let productWeights = globalWeights;
if (productRecords2025.length > 0) { if (productHistoricalRecords.length > 0) {
if (isUkOnly) { const historyToUse = isUkOnly
const ukProd = productRecords2025.filter(r => r.customer === 'Amazon UK'); ? productHistoricalRecords.filter(r => r.customer === 'Amazon UK')
const info = getWeightsInfo(ukProd); : productHistoricalRecords;
if (info) {
if (info.monthsCount < 6) { const info = getWeightsInfo(historyToUse);
// Smart Blending: 50% specific, 50% global if (info) {
productWeights = info.weights.map((w, i) => (w * 0.5) + (globalWeights[i] * 0.5)); // Adaptive Blending (Bayesian Shrinkage):
} else { // We blend local seasonality with global seasonality based on how many months of data we have.
productWeights = info.weights; // 12 months = 85% local, 15% global (safety net)
} // 6 months = 42.5% local, 57.5% global
} // 0 months = 0% local, 100% global
} else { const trustFactor = (info.monthsCount / 12) * 0.85;
const info = getWeightsInfo(productRecords2025); productWeights = info.weights.map((w, i) => (w * trustFactor) + (globalWeights[i] * (1 - trustFactor)));
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)
} }
} }
@@ -1806,8 +1807,8 @@ export const calculateForecastViewData = (
}); });
const accuracy = totalForecastUnits > 0 const accuracy = totalForecastUnits > 0
? Math.min(100, Math.round((1 - Math.abs(totalActualUnits - totalForecastUnits) / totalForecastUnits) * 100)) ? Math.max(0, Math.min(100, Math.round((1 - Math.abs(totalActualUnits - totalForecastUnits) / totalForecastUnits) * 100)))
: 0; : (totalActualUnits === 0 ? 100 : 0);
return { return {
asin: identifier, asin: identifier,
@@ -1839,31 +1840,48 @@ export const processVendorStockExcel = async (fileOrBuffer: File | ArrayBuffer):
// Find header row (it contains "ASIN") // Find header row (it contains "ASIN")
let headerRowIndex = -1; let headerRowIndex = -1;
for (let i = 0; i < Math.min(jsonData.length, 20); i++) { 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; headerRowIndex = i;
break; break;
} }
} }
if (headerRowIndex === -1) { if (headerRowIndex === -1) {
console.warn("Could not find header row in Vendor Stock Excel"); // Fallback: look for ASIN in first row if not found in header scan
return vendorStockMap; 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 const headers: any[] = jsonData[headerRowIndex];
// A (0): ASIN const asinIdx = headers.findIndex(h => String(h || '').toUpperCase() === 'ASIN');
// D (3): Marketplace (Country) const marketplaceIdx = headers.findIndex(h => {
// P (15): Sellable on hands units 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++) { for (let i = headerRowIndex + 1; i < jsonData.length; i++) {
const row = jsonData[i]; const row = jsonData[i];
if (!row || row.length < 16) continue; if (!row || row.length <= Math.max(finalAsinIdx, finalMarketplaceIdx, finalStockIdx)) continue;
const asin = String(row[0] || '').trim().toUpperCase();
const marketplace = String(row[3] || '').trim().toLowerCase();
const stockValue = parseUnits(String(row[15] || '0'));
const asin = String(row[finalAsinIdx] || '').trim().toUpperCase();
if (!asin) continue; if (!asin) continue;
const marketplace = String(row[finalMarketplaceIdx] || '').trim().toLowerCase();
const stockValue = parseUnits(String(row[finalStockIdx] || '0'));
if (!vendorStockMap.has(asin)) { if (!vendorStockMap.has(asin)) {
vendorStockMap.set(asin, { eu: 0, uk: 0 }); vendorStockMap.set(asin, { eu: 0, uk: 0 });
} }
@@ -1871,10 +1889,10 @@ export const processVendorStockExcel = async (fileOrBuffer: File | ArrayBuffer):
const current = vendorStockMap.get(asin)!; const current = vendorStockMap.get(asin)!;
// Map to UK or EU // 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; current.uk += stockValue;
} else { } else if (marketplace) {
// Assume everything else is Pan-EU for now if it's not UK // Assume everything else with a marketplace is Pan-EU (DE, IT, FR, ES)
current.eu += stockValue; 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 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) // Skip header row (index 0)
for (let i = 1; i < jsonData.length; i++) { for (let i = 1; i < jsonData.length; i++) {
const row = jsonData[i]; const row = jsonData[i];
const asin = String(row[1] || '').trim().toUpperCase(); // Col B = index 1 if (!row || row.length <= Math.max(finalAsinIdx, config.reasonCol)) continue;
if (!asin || asin.length < 5) continue;
const rawAsin = String(row[finalAsinIdx] || '').trim().toUpperCase();
if (!rawAsin || rawAsin.length < 5) continue;
const rawReason = String(row[config.reasonCol] || '').trim(); 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 // Empty reason or "Fixed" status means no issue = they have the Buy Box
if (!rawReason || rawReason.toLowerCase() === 'fixed') continue; if (!rawReason || rawReason.toLowerCase() === 'fixed') continue;
const reason = rawReason; let entry = buyBoxMap.get(rawAsin);
if (!entry) {
if (!buyBoxMap.has(asin)) { entry = { countries: [], reasons: {} };
buyBoxMap.set(asin, { countries: [], reasons: {} }); buyBoxMap.set(rawAsin, entry);
} }
const entry = buyBoxMap.get(asin)!;
if (!entry.countries.includes(config.country)) { if (!entry.countries.includes(config.country)) {
entry.countries.push(config.country); entry.countries.push(config.country);
} }
entry.reasons[config.country] = reason;
// Collect reason for this country
entry.reasons[config.country] = rawReason;
} }
} }