From a678880112e94fb1731b4e61043a1ae70d7ab84f Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Fri, 30 Jan 2026 09:10:02 +0100 Subject: [PATCH] fix: improve forecast seasonality logic and catalog data integrity --- services/dataProcessor.ts | 147 ++++++++++++++++++++++---------------- 1 file changed, 86 insertions(+), 61 deletions(-) diff --git a/services/dataProcessor.ts b/services/dataProcessor.ts index fecf7a9..3a16687 100644 --- a/services/dataProcessor.ts +++ b/services/dataProcessor.ts @@ -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(); - data2025.forEach(r => { + // Map historical data by ASIN for quick access + const dataByAsinHistorical = new Map(); + 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; } }