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'; // Helper to parse currency values handling both EU (1.234,56) and US/Standard (1,234.56 or 1234.56) formats const parseCurrency = (value: string): number => { if (!value) return 0; // Remove currency symbol and whitespace let clean = value.replace(/[€$£\s]/g, '').trim(); // HEURISTIC: // If it contains a comma, we assume it's likely European format (Decimal separator) // UNLESS it also contains a dot and the comma is before the dot (e.g. 1,000.50 - US format) // But given the context (DE data), comma is usually decimal. // Case A: European Format (e.g., "277.179,09" or "50,00" or "263,83") if (clean.includes(',') && !clean.includes('.')) { // Likely EU decimal without thousands or with thousands implicitly handled // e.g. "263,83" -> "263.83" clean = clean.replace(',', '.'); return parseFloat(clean); } else if (clean.includes(',') && clean.includes('.')) { // Mixed: 1.234,56 -> EU if (clean.indexOf(',') > clean.indexOf('.')) { clean = clean.replace(/\./g, '').replace(',', '.'); } else { // 1,234.56 -> US clean = clean.replace(/,/g, ''); } return parseFloat(clean); } // Case B: Standard/US Format or Clean Number (e.g. "277179.09" or "1000") clean = clean.replace(/,/g, ''); // Remove commas just in case const num = parseFloat(clean); return isNaN(num) ? 0 : num; }; const parseUnits = (value: string): number => { if (!value) return 0; // Remove dots (thousands separators in EU) and commas (thousands in US) just to be safe for integers const clean = value.replace(/[\.,]/g, ''); const num = parseInt(clean, 10); return isNaN(num) ? 0 : num; } const MONTH_ORDER = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; // Comprehensive Month Mapping (English + Spanish + Short/Full) const MONTH_MAP: Record = { // English Short 'jan': 'Jan', 'feb': 'Feb', 'mar': 'Mar', 'apr': 'Apr', 'may': 'May', 'jun': 'Jun', 'jul': 'Jul', 'aug': 'Aug', 'sep': 'Sep', 'oct': 'Oct', 'nov': 'Nov', 'dec': 'Dec', // Spanish Short 'ene': 'Jan', 'abr': 'Apr', 'ago': 'Aug', 'dic': 'Dec', 'set': 'Sep', // Spanish Full 'enero': 'Jan', 'febrero': 'Feb', 'marzo': 'Mar', 'abril': 'Apr', 'mayo': 'May', 'junio': 'Jun', 'julio': 'Jul', 'agosto': 'Aug', 'septiembre': 'Sep', 'octubre': 'Oct', 'noviembre': 'Nov', 'diciembre': 'Dec', // English Full 'january': 'Jan', 'february': 'Feb', 'march': 'Mar', 'april': 'Apr', 'june': 'Jun', 'july': 'Jul', 'august': 'Aug', 'september': 'Sep', 'october': 'Oct', 'november': 'Nov', 'december': 'Dec' }; // Robust Month Normalizer const normalizeMonth = (rawMonth: string): string => { if (!rawMonth) return ''; let m = String(rawMonth).trim().toLowerCase(); // 0. Check for Excel Serial Date (e.g. 45544 -> Sep) // 25569 is the offset days between Excel epoch (1899-12-30) and Unix epoch (1970-01-01) // We check if it's a number > 20000 (roughly year 1954+) to avoid confusion with valid days like "31" const potentialSerial = parseFloat(m); if (!isNaN(potentialSerial) && potentialSerial > 20000) { // Convert Excel serial to JS Date const date = new Date(Math.round((potentialSerial - 25569) * 86400 * 1000)); if (!isNaN(date.getTime())) { return MONTH_ORDER[date.getMonth()]; } } // 1. Direct Map Lookup (Handles "jan", "enero", "sep", etc.) if (MONTH_MAP[m]) return MONTH_MAP[m]; // 2. Handle numeric months "01", "1", "01-2023" // If it's a full date string like "2023-04-01" or "01/04/2023" if (m.includes('/') || m.includes('-')) { // Try parsing standard date const date = new Date(m); if (!isNaN(date.getTime())) { const monthIdx = date.getMonth(); const yearShort = date.getFullYear().toString().slice(2); return `${MONTH_ORDER[monthIdx]}-${yearShort}`; } } const numMatch = m.match(/^(\d{1,2})([^\d]|$)/); if (numMatch) { const num = parseInt(numMatch[1]); if (num >= 1 && num <= 12) return MONTH_ORDER[num - 1]; } // 3. Fallback: Extract first 3 letters and capitalize const alphaMatch = m.match(/([a-zA-Z\u00C0-\u00FF]+)/); if (alphaMatch) { let alpha = alphaMatch[1]; if (alpha.length > 3) alpha = alpha.substring(0, 3); // Check map again with short version if (MONTH_MAP[alpha]) return MONTH_MAP[alpha]; return alpha.charAt(0).toUpperCase() + alpha.slice(1); } // Try to grab year from original string to append (e.g. "Apr-23") if strict matching failed const yearMatch = rawMonth.match(/(\d{2,4})/); if (yearMatch) { let y = yearMatch[1]; if (y.length === 4) y = y.slice(2); // This part is likely fallback for Sales Data records const letters = m.replace(/[^a-z]/g, ''); if (letters && MONTH_MAP[letters]) { return `${MONTH_MAP[letters]}-${y}`; } } return rawMonth; // Return as-is if all else fails }; // Robust CSV Column Value Extractor const getColumnValue = (row: any, aliases: string[]): string => { const rowKeys = Object.keys(row); const normalizedRowKeys: Record = {}; rowKeys.forEach(k => { normalizedRowKeys[k.trim().toLowerCase()] = k; }); for (const alias of aliases) { const lookup = alias.trim().toLowerCase(); if (normalizedRowKeys[lookup]) { const actualKey = normalizedRowKeys[lookup]; const val = row[actualKey]; if (val !== undefined && val !== null) { const strVal = String(val).trim(); if (strVal.length > 0) { return strVal; } } } } return ''; }; // Allowed Customers Whitelist export const PAN_EU_COUNTRIES = ['Amazon DE', 'Amazon IT', 'Amazon FR', 'Amazon ES']; const ALLOWED_CUSTOMERS = [...PAN_EU_COUNTRIES, 'Amazon UK', 'Amazon SC']; const isAllowedCustomer = (customer: string): boolean => { if (!customer) return false; const normCustomer = customer.trim().toLowerCase(); return ALLOWED_CUSTOMERS.some(allowed => allowed.toLowerCase() === normCustomer); }; // --- SALES / SELL OUT MAPPING --- const mapRowToRecord = (row: any, index: number): SalesRecord => { const customer = getColumnValue(row, ['NEW CUSTOMER', 'Customer', 'Client', 'Account', 'Partner', 'COUNTRY', 'Country', 'Market']) || 'Unknown'; const yearStr = getColumnValue(row, ['YEAR', 'Year', 'D']); // Sanitize year string before parsing (remove commas/dots e.g. "2,023") let year = parseInt(yearStr.replace(/[,.]/g, '')) || 0; const monthStr = getColumnValue(row, ['MONTH', 'Month', 'Period']); const month = normalizeMonth(monthStr); // BACKFILL YEAR if missing but present in Month (e.g. "Apr-23") if (year === 0 && month.includes('-')) { const parts = month.split('-'); if (parts.length === 2) { const yPart = parts[1]; // assume 20xx for 2 digits if (yPart.length === 2) year = 2000 + parseInt(yPart); else if (yPart.length === 4) year = parseInt(yPart); } } const weekStr = getColumnValue(row, ['WEEK', 'Week', 'CW', 'Semana', 'KW', 'E']); const weekNum = weekStr ? parseInt(weekStr.replace(/cw/i, '').trim(), 10) : NaN; const week = isNaN(weekNum) ? undefined : weekNum; const line = getColumnValue(row, ['LICENSE', 'License']) || 'Unassigned'; const asin = getColumnValue(row, [ 'CUSTOMER REFERENCE', 'AMAZON ASIN', 'ASIN', 'Asin', 'PRODUCT ID', 'ITEM IDENTIFIER', 'ASIN NO.', 'Product ASIN', 'IDENTIFIER' ]); const sku = getColumnValue(row, ['RAW ARTICLE NO.', 'SKU', 'Sku', 'Item No']); const title = getColumnValue(row, ['ARTICLE NAME (Craze)', 'Title', 'TITLE', 'Product Title', 'Article Name', 'ArticleName']); const articleName = getColumnValue(row, ['ARTICLE NAME (Craze)', 'Article Name', 'ArticleName', 'Title']); const unitsRaw = getColumnValue(row, ['UNITS', 'Units', 'Quantity', 'Qty']); const sellOutRaw = getColumnValue(row, ['AMOUNT', 'Sell Out', 'SellOut', 'Revenue', 'Sales', 'Turnover']); return { id: `row-${index}`, customer, year, month, week, asin, sku, title, articleName, units: parseUnits(unitsRaw), sellOut: parseCurrency(sellOutRaw), line }; }; export const processCSV = (fileOrContent: File | string): Promise => { return new Promise((resolve, reject) => { // @ts-ignore Papa.parse(fileOrContent, { header: true, skipEmptyLines: true, complete: (results: any) => { try { const data: SalesRecord[] = results.data.map((row: any, index: number) => { return mapRowToRecord(row, index); }) // Filter: Valid Year > 2023 (exclude incomplete 2023 data) AND Allowed Customer .filter((r: SalesRecord) => r.year > 2023 && isAllowedCustomer(r.customer)); resolve(data); } catch (err) { reject(err); } }, error: (error: any) => reject(error) }); }); }; export const processExcel = async (file: File): Promise => { try { const arrayBuffer = await file.arrayBuffer(); const workbook = XLSX.read(arrayBuffer); const firstSheetName = workbook.SheetNames[0]; const worksheet = workbook.Sheets[firstSheetName]; const jsonData = XLSX.utils.sheet_to_json(worksheet, { defval: "" }); const data: SalesRecord[] = jsonData.map((row: any, index: number) => { return mapRowToRecord(row, index); }) // Filter: Valid Year AND Allowed Customer .filter((r: SalesRecord) => r.year > 0 && isAllowedCustomer(r.customer)); return data; } catch (error) { console.error("Error processing Excel file:", error); throw error; } } // --- ADS DATA MAPPING --- const mapCountryToMarketplace = (country: string): string => { const c = String(country).toLowerCase().trim(); if (c.includes('germany') || c.includes('deutschland') || c.includes('de')) return 'Amazon DE'; if (c.includes('spain') || c.includes('espana') || c.includes('españa') || c.includes('es')) return 'Amazon ES'; if (c.includes('france') || c.includes('fr')) return 'Amazon FR'; if (c.includes('italy') || c.includes('italia') || c.includes('it')) return 'Amazon IT'; if (c.includes('kingdom') || c.includes('uk') || c === 'gb') return 'Amazon UK'; if (c.includes('netherlands') || c.includes('nederland') || c.includes('holland') || c.includes('nl')) return 'Amazon NL'; return country.toUpperCase(); // Fallback }; export const processAdsCSV = (file: File): Promise => { return new Promise((resolve, reject) => { // @ts-ignore Papa.parse(file, { header: false, // Index-based mapping skipEmptyLines: true, complete: (results: any) => { try { const data: AdsRecord[] = []; const rows = results.data; const len = rows.length; for (let i = 0; i < len; i++) { const row = rows[i]; if (!Array.isArray(row) || row.length < 12) continue; // Check header row (Column A: Customer or Country) const c0 = String(row[0]).trim().toLowerCase(); if (c0.includes('customer') || c0.includes('country') || c0.includes('marketplace')) continue; // Column mapping for CSV (same as Excel): // A (0): Country // B (1): Week // C (2): ASIN // D (3): Cost // E (4): Clicks // F (5): Impressions // G (6): CPC // H (7): CTR % // I (8): ACOS % // J (9): Conversions (30d) // K (10): Units (30d) // L (11): Sales (30d) const countryRaw = row[0]; const weekRaw = row[1]; const asin = row[2]; const costRaw = row[3]; const clicksRaw = row[4]; const impressionsRaw = row[5]; const cpcRaw = row[6]; const ctrRaw = row[7]; const acosRaw = row[8]; const conversionsRaw = row[9]; const unitsRaw = row[10]; const salesRaw = row[11]; if (!asin || !countryRaw || weekRaw === undefined) continue; const weekNum = parseInt(String(weekRaw)); if (isNaN(weekNum) || weekNum < 1 || weekNum > 53) continue; // For CSV without sheet names, assume current year const currentYear = new Date().getFullYear(); data.push({ country: mapCountryToMarketplace(String(countryRaw)), year: currentYear, week: weekNum, asin: String(asin).trim(), cost: parseCurrency(String(costRaw)), clicks: parseUnits(String(clicksRaw)), impressions: parseUnits(String(impressionsRaw)), cpc: parseCurrency(String(cpcRaw)), ctr: parseCurrency(String(ctrRaw)), acos: parseCurrency(String(acosRaw)), conversions: parseUnits(String(conversionsRaw)), attributedUnits30d: parseUnits(String(unitsRaw)), attributedSales30d: parseCurrency(String(salesRaw)), }); } resolve(data); } catch (err) { reject(err); } }, error: (error: any) => reject(error) }); }); }; export const processAdsExcel = async (fileOrBuffer: File | ArrayBuffer): Promise => { try { const arrayBuffer = fileOrBuffer instanceof File ? await fileOrBuffer.arrayBuffer() : fileOrBuffer; const workbook = XLSX.read(arrayBuffer, { type: 'array' }); const allData: AdsRecord[] = []; // Process ALL sheets (e.g., "2025", "2026") for (const sheetName of workbook.SheetNames) { const year = parseInt(sheetName); if (isNaN(year) || year < 2020 || year > 2100) { console.warn(`Skipping sheet "${sheetName}" - not a valid year`); continue; } const worksheet = workbook.Sheets[sheetName]; // Use header: 1 to get array of arrays (row-based) const jsonData: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: "" }); console.log(`Processing sheet ${sheetName}: ${jsonData.length} rows`); // Skip header row (index 0), process data rows for (let i = 1; i < jsonData.length; i++) { const row = jsonData[i]; if (!row || row.length < 12) continue; // Column mapping for Ads Weekly.xlsx: // A (0): Country // B (1): Week // C (2): ASIN // D (3): Cost // E (4): Clicks // F (5): Impressions // G (6): CPC // H (7): CTR % // I (8): ACOS % // J (9): Conversions (30d) // K (10): Units (30d) // L (11): Sales (30d) const countryRaw = row[0]; const weekRaw = row[1]; const asin = row[2]; const costRaw = row[3]; const clicksRaw = row[4]; const impressionsRaw = row[5]; const cpcRaw = row[6]; const ctrRaw = row[7]; const acosRaw = row[8]; const conversionsRaw = row[9]; const unitsRaw = row[10]; const salesRaw = row[11]; // Skip if missing essential data if (!asin || !countryRaw || weekRaw === undefined || weekRaw === '') continue; const weekNum = parseInt(String(weekRaw)); if (isNaN(weekNum) || weekNum < 1 || weekNum > 53) continue; allData.push({ country: mapCountryToMarketplace(String(countryRaw)), year, week: weekNum, asin: String(asin).trim(), cost: parseCurrency(String(costRaw)), clicks: parseUnits(String(clicksRaw)), impressions: parseUnits(String(impressionsRaw)), cpc: parseCurrency(String(cpcRaw)), ctr: parseCurrency(String(ctrRaw)), acos: parseCurrency(String(acosRaw)), conversions: parseUnits(String(conversionsRaw)), attributedUnits30d: parseUnits(String(unitsRaw)), attributedSales30d: parseCurrency(String(salesRaw)), }); } } console.log(`Total Ads records loaded: ${allData.length}`); return allData; } catch (error) { console.error("Error processing Ads Excel:", error); throw error; } }; // --- TRAFFIC DATA PARSING --- export const processTrafficExcel = async (fileOrBuffer: File | ArrayBuffer): Promise => { try { const arrayBuffer = fileOrBuffer instanceof File ? await fileOrBuffer.arrayBuffer() : fileOrBuffer; const workbook = XLSX.read(arrayBuffer, { type: 'array' }); const allData: TrafficRecord[] = []; // Process first sheet only (Traffic Weekly.xlsx typically has one sheet) const sheetName = workbook.SheetNames[0]; const worksheet = workbook.Sheets[sheetName]; const jsonData: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: "" }); console.log(`Processing Traffic sheet "${sheetName}": ${jsonData.length} rows`); // Column mapping for Traffic Weekly.xlsx: // A (0): Year // B (1): Week // C (2): ASIN // F (5): Country // G (6): Glance Views (GV) // Skip header row (index 0), process data rows for (let i = 1; i < jsonData.length; i++) { const row = jsonData[i]; if (!row || row.length < 7) continue; const yearRaw = row[0]; const weekRaw = row[1]; const asin = row[2]; const countryRaw = row[5]; const gvRaw = row[6]; // Skip if missing essential data if (!asin || yearRaw === undefined || weekRaw === undefined || !countryRaw) continue; const year = parseInt(String(yearRaw)); const weekNum = parseInt(String(weekRaw)); if (isNaN(year) || year < 2020 || year > 2100) continue; if (isNaN(weekNum) || weekNum < 1 || weekNum > 53) continue; allData.push({ country: mapCountryToMarketplace(String(countryRaw)), year, week: weekNum, asin: String(asin).trim().toUpperCase(), glanceViews: parseUnits(String(gvRaw)), }); } console.log(`Total Traffic records loaded: ${allData.length}`); return allData; } catch (error) { console.error("Error processing Traffic Excel:", error); throw error; } }; // --- DATA MERGING --- export const mergeSalesAndAdsData = ( salesData: SalesRecord[], adsData: AdsRecord[], asinMetadataMap?: Map, trafficData?: TrafficRecord[], velocityMap?: Map ): CombinedKPIs[] => { // Key for both sales and ads: ASIN|Customer|Year|Week const createKey = (asin: string, customer: string, year: number, week: number) => `${asin.trim().toUpperCase()}|${customer.trim().toUpperCase()}|${year}|${week}`; // Build traffic lookup map - Use a more efficient key const trafficMap = new Map(); if (trafficData) { for (let i = 0; i < trafficData.length; i++) { const t = trafficData[i]; const key = `${t.asin.trim().toUpperCase()}|${t.country.trim().toUpperCase()}|${t.year}|${t.week}`; trafficMap.set(key, (trafficMap.get(key) || 0) + (t.glanceViews || 0)); } } // 1. Initialize metadata lookup map with provided global map if available, otherwise build from current sales const asinMetadata = asinMetadataMap || new Map(); // 2. Aggregate Sales by ASIN|Customer|Year|Week (combine all SKUs) const salesMap = new Map(); for (let i = 0; i < salesData.length; i++) { const sale = salesData[i]; const weekNum = sale.week || 0; if (weekNum === 0) continue; const asinUpper = sale.asin.trim().toUpperCase(); const key = `${asinUpper}|${sale.customer.trim().toUpperCase()}|${sale.year}|${weekNum}`; // If no global map provided, build it on the fly if (!asinMetadataMap) { const existingMeta = asinMetadata.get(asinUpper); if (!existingMeta || (sale.title && sale.title.length > (existingMeta.title?.length || 0))) { asinMetadata.set(asinUpper, { sku: sale.sku, title: sale.title, line: sale.line }); } } const existing = salesMap.get(key); if (existing) { existing.sellOut += sale.sellOut; existing.units += sale.units; if (sale.title && sale.title.length > (existing.title?.length || 0)) { existing.title = sale.title; } if (sale.sku && !existing.sku) { existing.sku = sale.sku; } } else { salesMap.set(key, { sellOut: sale.sellOut, units: sale.units, sku: sale.sku, title: sale.title, line: sale.line, asin: sale.asin, customer: sale.customer, year: sale.year, week: weekNum, month: sale.month }); } } // 3. Aggregate Ads by ASIN|Customer|Year|Week const adsMap = new Map(); for (let i = 0; i < adsData.length; i++) { const ad = adsData[i]; const key = `${ad.asin.trim().toUpperCase()}|${ad.country.trim().toUpperCase()}|${ad.year}|${ad.week}`; const existing = adsMap.get(key); if (existing) { existing.cost += ad.cost; existing.clicks += ad.clicks; existing.impressions += ad.impressions; existing.attributedSales30d += ad.attributedSales30d; existing.attributedUnits30d += ad.attributedUnits30d; existing.conversions += ad.conversions; } else { adsMap.set(key, { ...ad }); } } const mergedData: CombinedKPIs[] = []; const processedKeys = new Set(); // 4. Create ONE record per ASIN/Customer/Year/Week from sales salesMap.forEach((sale, key) => { processedKeys.add(key); const ad = adsMap.get(key); const adCost = ad?.cost || 0; const adClicks = ad?.clicks || 0; const adImpressions = ad?.impressions || 0; const adSales = ad?.attributedSales30d || 0; const adUnits = ad?.attributedUnits30d || 0; const salesTotal = sale.sellOut; const unitsTotal = sale.units; const salesOrganic = Math.max(0, salesTotal - adSales); const unitsOrganic = Math.max(0, unitsTotal - adUnits); const acos = adSales > 0 ? (adCost / adSales) * 100 : 0; const tacos = salesTotal > 0 ? (adCost / salesTotal) * 100 : 0; const roas = adCost > 0 ? adSales / adCost : 0; const ctr = adImpressions > 0 ? (adClicks / adImpressions) * 100 : 0; const cpc = adClicks > 0 ? adCost / adClicks : 0; const cvrUnits = adClicks > 0 ? (adUnits / adClicks) * 100 : 0; const avgWeeklySales = velocityMap?.get(sale.asin.trim().toUpperCase()) || 0; mergedData.push({ id: `merged-${key}`, marketplace: sale.customer, customer: sale.customer, month: sale.month, week: sale.week, year: sale.year, asin: sale.asin.trim().toUpperCase(), title: sale.title, line: sale.line, sku: sale.sku, salesTotal, unitsTotal, salesAds: adSales, unitsAds: adUnits, cost: adCost, clicks: adClicks, impressions: adImpressions, conversions: ad?.conversions || 0, salesOrganic, unitsOrganic, paidSalesShare: salesTotal > 0 ? (adSales / salesTotal) * 100 : 0, organicSalesShare: salesTotal > 0 ? (salesOrganic / salesTotal) * 100 : 0, acos, tacos, roas, ctr, cpc, cvrUnits, glanceViews: trafficMap.get(key) || 0, avgWeeklySales }); }); // 5. Add ads-only records - use provided metadata for line/title adsMap.forEach((ad, key) => { if (!processedKeys.has(key)) { const asin = ad.asin.trim().toUpperCase(); const meta = asinMetadata.get(asin); const avgWeeklySales = velocityMap?.get(asin) || 0; mergedData.push({ id: `ads-only-${key}`, marketplace: ad.country, customer: ad.country, month: 'N/A', week: ad.week, year: ad.year, asin: asin, title: meta?.title || ad.asin, line: meta?.line || 'Unassigned', sku: meta?.sku || '', salesTotal: 0, unitsTotal: 0, salesAds: ad.attributedSales30d, unitsAds: ad.attributedUnits30d, cost: ad.cost, clicks: ad.clicks, impressions: ad.impressions, conversions: ad.conversions, salesOrganic: 0, unitsOrganic: 0, paidSalesShare: 0, organicSalesShare: 0, acos: ad.attributedSales30d > 0 ? (ad.cost / ad.attributedSales30d) * 100 : 0, tacos: 0, roas: ad.cost > 0 ? ad.attributedSales30d / ad.cost : 0, ctr: ad.impressions > 0 ? (ad.clicks / ad.impressions) * 100 : 0, cpc: ad.clicks > 0 ? ad.cost / ad.clicks : 0, cvrUnits: ad.clicks > 0 ? (ad.attributedUnits30d / ad.clicks) * 100 : 0, glanceViews: trafficMap.get(key) || 0, avgWeeklySales }); } }); return mergedData; }; // --- EXISTING HELPERS --- // Helper to check stock filter const checkStockFilter = (sku: string, filters: string[], stockMap?: Map): boolean => { if (!filters || !Array.isArray(filters) || filters.length === 0) return true; if (!stockMap) return true; // Normalize SKU (remove DE/EN) to match stock map const baseSku = sku?.replace(/(DE|EN)$/i, ''); const stockValue = stockMap.get(baseSku) || 0; return filters.some(f => { // Smart Filters if (f === 'Out of Stock (0)') return stockValue === 0; if (f === 'In Stock (>0)') return stockValue > 0; if (f === 'Low Stock (<10)') return stockValue < 10; // Numeric Range (e.g. ">10", "1-50") const input = f.trim().toLowerCase(); if (input.includes('-')) { const [start, end] = input.split('-').map(s => parseFloat(s.trim())); if (!isNaN(start) && !isNaN(end)) return stockValue >= start && stockValue <= end; } else if (input.startsWith('<=')) { const val = parseFloat(input.substring(2).trim()); if (!isNaN(val)) return stockValue <= val; } else if (input.startsWith('>=')) { const val = parseFloat(input.substring(2).trim()); if (!isNaN(val)) return stockValue >= val; } else if (input.startsWith('<')) { const val = parseFloat(input.substring(1).trim()); if (!isNaN(val)) return stockValue < val; } else if (input.startsWith('>')) { const val = parseFloat(input.substring(1).trim()); if (!isNaN(val)) return stockValue > val; } // Exact Match return stockValue.toString() === f; }); }; const checkVendorStockFilter = (asin: string, filters: string[], vendorStockMap?: Map, mode: 'eu' | 'uk' = 'eu'): boolean => { if (!filters || !Array.isArray(filters) || filters.length === 0) return true; if (!vendorStockMap) return true; const data = vendorStockMap.get(asin); const stockValue = data ? (mode === 'uk' ? data.uk : data.eu) : 0; return filters.some(f => { if (f === 'Out of Stock (0)') return stockValue === 0; if (f === 'In Stock (>20)') return stockValue > 20; if (f === 'In Stock (>0)') return stockValue > 0; if (f === 'Low Stock (<10)') return stockValue < 10; const input = f.trim().toLowerCase(); if (input.includes('-')) { const [start, end] = input.split('-').map(s => parseFloat(s.trim())); if (!isNaN(start) && !isNaN(end)) return stockValue >= start && stockValue <= end; } else if (input.startsWith('<=')) { const val = parseFloat(input.substring(2).trim()); if (!isNaN(val)) return stockValue <= val; } else if (input.startsWith('>=')) { const val = parseFloat(input.substring(2).trim()); if (!isNaN(val)) return stockValue >= val; } else if (input.startsWith('<')) { const val = parseFloat(input.substring(1).trim()); if (!isNaN(val)) return stockValue < val; } else if (input.startsWith('>')) { const val = parseFloat(input.substring(1).trim()); if (!isNaN(val)) return stockValue > val; } else if (!isNaN(parseFloat(input))) { return stockValue === parseFloat(input); } return false; }); }; // Filter Ads Data by Country, Year, Week, ASIN, SKU, and Product Line export const filterAdsData = ( adsData: AdsRecord[], filters: FilterState, asinMetadata?: Map, stockMap?: Map, vendorStockMap?: Map, top50Mode: 'eu' | 'uk' = 'eu' ): AdsRecord[] => { return adsData.filter(ad => { const asin = ad.asin.trim().toUpperCase(); const meta = asinMetadata?.get(asin); // Country/Customer match (ads use 'country', sales use 'customer') const countryMatch = filters.customer.length === 0 ? PAN_EU_COUNTRIES.some(c => c.toUpperCase() === ad.country.toUpperCase()) : filters.customer.some(c => c.toUpperCase() === ad.country.toUpperCase()); // Year match const yearMatch = filters.year.length === 0 || filters.year.includes(ad.year.toString()); // Week match (filters use "W1", "W2" format) const weekStr = `W${ad.week}`; const weekMatch = filters.week.length === 0 || filters.week.includes(weekStr); // ASIN match const asinMatch = filters.asin.length === 0 || filters.asin.some(a => a.toUpperCase() === asin); // SKU match (Requires metadata) let skuMatch = true; if (filters.sku.length > 0) { skuMatch = meta ? filters.sku.some(s => s.toUpperCase() === meta.sku.toUpperCase()) : false; } // Line match (Requires metadata) let lineMatch = true; if (filters.line.length > 0) { lineMatch = meta ? filters.line.includes(meta.line) : false; } // Stock match const stockMatch = checkStockFilter(meta?.sku || '', filters.stock, stockMap); const vendorStockMatch = checkVendorStockFilter(asin, filters.vendorStock, vendorStockMap, top50Mode); return countryMatch && yearMatch && weekMatch && asinMatch && skuMatch && lineMatch && stockMatch && vendorStockMatch; }); }; export const filterData = ( data: SalesRecord[], filters: FilterState, stockMap?: Map, vendorStockMap?: Map, top50Mode: 'eu' | 'uk' = 'eu' ): SalesRecord[] => { return data.filter(item => { // 1. Month Logic: Handle "Apr-23" matching "Apr" filter const recordMonth = item.month; // e.g. "Apr-23" const pureMonth = recordMonth.split('-')[0]; // "Apr" // 2. Filter Checks const customerMatch = filters.customer.length === 0 ? PAN_EU_COUNTRIES.includes(item.customer) : filters.customer.includes(item.customer); const yearMatch = filters.year.length === 0 || filters.year.includes(item.year.toString()); // Check match against pure month ("Apr") OR full month ("Apr-23") just in case filters evolve const monthMatch = filters.month.length === 0 || filters.month.includes(pureMonth) || filters.month.includes(recordMonth); const lineMatch = filters.line.length === 0 || filters.line.includes(item.line); const asinMatch = filters.asin.length === 0 || filters.asin.includes(item.asin); const skuMatch = filters.sku.length === 0 || filters.sku.includes(item.sku); const titleMatch = filters.title.length === 0 || filters.title.includes(item.title); // Week Logic: Match "W1", "W2" etc. // item.week is a number (e.g. 1), filter uses strings "W1" const weekStr = item.week ? `W${item.week}` : ''; const weekMatch = filters.week.length === 0 || (weekStr !== '' && filters.week.includes(weekStr)); // Stock match const stockMatch = checkStockFilter(item.sku, filters.stock, stockMap); const vendorStockMatch = checkVendorStockFilter(item.asin, filters.vendorStock, vendorStockMap, top50Mode); return customerMatch && yearMatch && monthMatch && lineMatch && asinMatch && skuMatch && titleMatch && weekMatch && stockMatch && vendorStockMatch; }); }; const calculateSeasonality = (data: SalesRecord[]): { seasonality: SeasonalityPoint[], seasonalityUnits: SeasonalityPoint[], years: string[] } => { const seasonalityMap = new Map(); const seasonalityUnitsMap = new Map(); const yearsSet = new Set(); // Initialize all months MONTH_ORDER.forEach(m => { seasonalityMap.set(m, { name: m }); seasonalityUnitsMap.set(m, { name: m }); }); data.forEach(record => { const monthName = record.month; // Extract year from record.month if it's in Format "Mon-YY", else use record.year // record.year is numeric, record.month is "Apr-23". const yearStr = record.year.toString(); yearsSet.add(yearStr); // We need to match month name purely (Jan, Feb) for the X Axis, ignoring year const pureMonth = monthName.split('-')[0]; if (seasonalityMap.has(pureMonth)) { // Sell Out const entrySO = seasonalityMap.get(pureMonth)!; const currentValSO = (entrySO[yearStr] as number) || 0; entrySO[yearStr] = currentValSO + record.sellOut; // Units const entryUnits = seasonalityUnitsMap.get(pureMonth)!; const currentValUnits = (entryUnits[yearStr] as number) || 0; entryUnits[yearStr] = currentValUnits + record.units; } }); const seasonality = Array.from(seasonalityMap.values()); const seasonalityUnits = Array.from(seasonalityUnitsMap.values()); const years = Array.from(yearsSet).sort(); return { seasonality, seasonalityUnits, years }; }; const calculateTopLinesSplit = (data: SalesRecord[]): YearlySplitData[] => { // 1. Identify Lines by Sell Out (Sort desc) const lineTotals = new Map(); data.forEach(item => { lineTotals.set(item.line, (lineTotals.get(item.line) || 0) + item.sellOut); }); // Return ALL lines const topLines = Array.from(lineTotals.entries()) .sort((a, b) => b[1] - a[1]) .map(([line]) => line); // 2. Aggregate data by Year const resultMap = new Map(); topLines.forEach(line => { resultMap.set(line, { name: line }); }); data.forEach(item => { if (resultMap.has(item.line)) { const entry = resultMap.get(item.line)!; const keyVal = `${item.year}_value`; const keyUnits = `${item.year}_units`; entry[keyVal] = ((entry[keyVal] as number) || 0) + item.sellOut; entry[keyUnits] = ((entry[keyUnits] as number) || 0) + item.units; } }); return Array.from(resultMap.values()); }; const calculateGenericSplit = (data: SalesRecord[], groupField: keyof SalesRecord, valueField: 'sellOut' | 'units', limit?: number): YearlySplitData[] => { const totals = new Map(); data.forEach(item => { const key = String(item[groupField]); totals.set(key, (totals.get(key) || 0) + item[valueField]); }); let sortedKeys = Array.from(totals.entries()).sort((a, b) => b[1] - a[1]).map(e => e[0]); if (limit) sortedKeys = sortedKeys.slice(0, limit); const keySet = new Set(sortedKeys); const resultMap = new Map(); sortedKeys.forEach(k => resultMap.set(k, { name: k })); data.forEach(item => { const key = String(item[groupField]); if (keySet.has(key)) { const entry = resultMap.get(key)!; const yearKey = item.year.toString(); entry[yearKey] = ((entry[yearKey] as number) || 0) + item[valueField]; } }); return Array.from(resultMap.values()); }; // Renamed from calculateMovers export const calculateLineMovers = (data: SalesRecord[]): { topMovers: LineGrowthMetric[], bottomMovers: LineGrowthMetric[], comparisonPeriods: { current: string, previous: string } } => { const lineYearMap = new Map>(); const allYears = new Set(); data.forEach(item => { if (!lineYearMap.has(item.line)) { lineYearMap.set(item.line, new Map()); } const yearMap = lineYearMap.get(item.line)!; const current = yearMap.get(item.year) || { sellOut: 0, units: 0 }; yearMap.set(item.year, { sellOut: current.sellOut + item.sellOut, units: current.units + item.units }); allYears.add(item.year); }); const sortedYears = Array.from(allYears).sort((a, b) => b - a); if (sortedYears.length < 2) { return { topMovers: [], bottomMovers: [], comparisonPeriods: { current: 'N/A', previous: 'N/A' } }; } const currentYear = sortedYears[0]; const prevYear = sortedYears[1]; const metrics: LineGrowthMetric[] = []; lineYearMap.forEach((yearMap, line) => { const currData = yearMap.get(currentYear) || { sellOut: 0, units: 0 }; const prevData = yearMap.get(prevYear) || { sellOut: 0, units: 0 }; // Sell Out Growth let sellOutGrowthValue = 0; let sellOutGrowthPercentage = 0; if (prevData.sellOut > 0) { sellOutGrowthValue = currData.sellOut - prevData.sellOut; sellOutGrowthPercentage = (sellOutGrowthValue / prevData.sellOut) * 100; } else if (currData.sellOut > 0) { sellOutGrowthValue = currData.sellOut; sellOutGrowthPercentage = 100; } else if (currData.sellOut === 0 && prevData.sellOut > 0) { sellOutGrowthValue = -prevData.sellOut; sellOutGrowthPercentage = -100; } // Unit Growth let unitsGrowthValue = 0; let unitsGrowthPercentage = 0; if (prevData.units > 0) { unitsGrowthValue = currData.units - prevData.units; unitsGrowthPercentage = (unitsGrowthValue / prevData.units) * 100; } else if (currData.units > 0) { unitsGrowthValue = currData.units; unitsGrowthPercentage = 100; } else if (currData.units === 0 && prevData.units > 0) { unitsGrowthValue = -prevData.units; unitsGrowthPercentage = -100; } if (currData.sellOut > 0 || prevData.sellOut > 0) { metrics.push({ line, currentYearSellOut: currData.sellOut, previousYearSellOut: prevData.sellOut, sellOutGrowthValue, sellOutGrowthPercentage, currentYearUnits: currData.units, previousYearUnits: prevData.units, unitsGrowthValue, unitsGrowthPercentage }); } }); const topMovers = metrics .filter(m => m.sellOutGrowthValue > 0) .sort((a, b) => b.sellOutGrowthValue - a.sellOutGrowthValue); const bottomMovers = metrics .filter(m => m.sellOutGrowthValue < 0) .sort((a, b) => a.sellOutGrowthValue - b.sellOutGrowthValue); return { topMovers, bottomMovers, comparisonPeriods: { current: currentYear.toString(), previous: prevYear.toString() } }; }; const createItemKey = (record: SalesRecord) => { // A robust key combining all identifiers return `${record.sku || 'NO_SKU'}||${record.asin || 'NO_ASIN'}||${record.title || 'NO_TITLE'}`; } export const calculateItemMovers = ( currentFilteredData: SalesRecord[], selectedCustomerFromPage: string | null, currentComparisonYearFromPage: number | null ): { topMovers: ItemGrowthMetric[], bottomMovers: ItemGrowthMetric[], comparisonPeriods: { current: string, previous: string } } => { let dataToProcess = currentFilteredData; // Apply customer filter if selected on the Top Movers page if (selectedCustomerFromPage) { dataToProcess = dataToProcess.filter(item => item.customer === selectedCustomerFromPage); } if (dataToProcess.length === 0) { return { topMovers: [], bottomMovers: [], comparisonPeriods: { current: 'N/A', previous: 'N/A' } }; } // Map to store item data aggregated by year const itemYearMap = new Map>(); const allYearsInFilteredData = new Set(); dataToProcess.forEach(item => { const itemKey = createItemKey(item); if (!itemYearMap.has(itemKey)) { itemYearMap.set(itemKey, new Map()); } const yearMap = itemYearMap.get(itemKey)!; const current = yearMap.get(item.year) || { sellOut: 0, units: 0, sku: item.sku, asin: item.asin, title: item.title, line: item.line }; yearMap.set(item.year, { sellOut: current.sellOut + item.sellOut, units: current.units + item.units, sku: item.sku, asin: item.asin, title: item.title, line: item.line }); allYearsInFilteredData.add(item.year); }); const sortedYearsInFilteredData = Array.from(allYearsInFilteredData).sort((a, b) => b - a); // Descending (most recent first) let currentYear: number; let prevYear: number; if (currentComparisonYearFromPage) { // If a specific comparison year is provided by the user on the Top Movers page currentYear = currentComparisonYearFromPage; const currentYearIndex = sortedYearsInFilteredData.indexOf(currentYear); if (currentYearIndex === -1 || currentYearIndex === sortedYearsInFilteredData.length - 1) { // Specified year not found in filtered data or it's the oldest year (no previous year for comparison) return { topMovers: [], bottomMovers: [], comparisonPeriods: { current: currentYear.toString(), previous: 'N/A' } }; } prevYear = sortedYearsInFilteredData[currentYearIndex + 1]; // The year directly before the currentComparisonYear } else { // Default to the two most recent years from the *filtered data* if no specific year is chosen if (sortedYearsInFilteredData.length < 2) { return { topMovers: [], bottomMovers: [], comparisonPeriods: { current: 'N/A', previous: 'N/A' } }; } currentYear = sortedYearsInFilteredData[0]; // Most recent prevYear = sortedYearsInFilteredData[1]; // Second most recent } const metrics: ItemGrowthMetric[] = []; itemYearMap.forEach((yearMap) => { const currData = yearMap.get(currentYear) || { sellOut: 0, units: 0, sku: '', asin: '', title: '', line: '' }; const prevData = yearMap.get(prevYear) || { sellOut: 0, units: 0, sku: '', asin: '', title: '', line: '' }; // Only include items that had some activity in at least one of the comparison years if ((currData.sellOut === 0 && currData.units === 0) && (prevData.sellOut === 0 && prevData.units === 0)) { return; } // Use metadata from current year, if not available use previous (for sku/asin/title/line) const itemMeta = currData.sku ? currData : prevData; // Sell Out Growth let sellOutGrowthValue = currData.sellOut - prevData.sellOut; let sellOutGrowthPercentage = 0; if (prevData.sellOut !== 0) { sellOutGrowthPercentage = (sellOutGrowthValue / prevData.sellOut) * 100; } else if (currData.sellOut > 0) { sellOutGrowthPercentage = 100; // Growth from zero } else if (currData.sellOut === 0 && prevData.sellOut > 0) { sellOutGrowthPercentage = -100; // Decline to zero } // Unit Growth let unitsGrowthValue = currData.units - prevData.units; let unitsGrowthPercentage = 0; if (prevData.units !== 0) { unitsGrowthPercentage = (unitsGrowthValue / prevData.units) * 100; } else if (currData.units > 0) { unitsGrowthPercentage = 100; // Growth from zero } else if (currData.units === 0 && prevData.units > 0) { unitsGrowthPercentage = -100; // Decline to zero } metrics.push({ sku: itemMeta.sku, asin: itemMeta.asin, title: itemMeta.title, line: itemMeta.line, currentYearSellOut: currData.sellOut, previousYearSellOut: prevData.sellOut, sellOutGrowthValue, sellOutGrowthPercentage, currentYearUnits: currData.units, previousYearUnits: prevData.units, unitsGrowthValue, unitsGrowthPercentage }); }); const topMovers = metrics .sort((a, b) => b.unitsGrowthValue - a.unitsGrowthValue) // Sort by unitsGrowthValue .slice(0, 20); // Top 20 Gainers const bottomMovers = metrics .sort((a, b) => a.unitsGrowthValue - b.unitsGrowthValue) // Sort by unitsGrowthValue .slice(0, 20); // Top 20 Losers return { topMovers, bottomMovers, comparisonPeriods: { current: currentYear.toString(), previous: prevYear.toString() } }; }; export const aggregateData = (data: SalesRecord[]): AggregatedData => { const totalSellOut = data.reduce((acc, curr) => acc + curr.sellOut, 0); const totalUnits = data.reduce((acc, curr) => acc + curr.units, 0); const totalsByYear: Record = {}; data.forEach(item => { const y = item.year.toString(); if (!totalsByYear[y]) totalsByYear[y] = { sellOut: 0, units: 0 }; totalsByYear[y].sellOut += item.sellOut; totalsByYear[y].units += item.units; }); const lineMap = new Map(); data.forEach(item => { const current = lineMap.get(item.line) || { value: 0, units: 0 }; lineMap.set(item.line, { value: current.value + item.sellOut, units: current.units + item.units }); }); const byLine = Array.from(lineMap.entries()) .map(([name, data]) => ({ name, value: data.value, units: data.units })) .sort((a, b) => b.value - a.value); const customerMap = new Map(); data.forEach(item => { customerMap.set(item.customer, (customerMap.get(item.customer) || 0) + item.sellOut); }); const byCustomer = Array.from(customerMap.entries()) .map(([name, value]) => ({ name, value })) .sort((a, b) => b.value - a.value); const { seasonality, seasonalityUnits, years } = calculateSeasonality(data); const { topMovers, bottomMovers, comparisonPeriods } = calculateLineMovers(data); // Use calculateLineMovers const topLinesSplit = calculateTopLinesSplit(data); const byCustomerSplit = calculateGenericSplit(data, 'customer', 'sellOut'); const byLineOverviewSplit = calculateGenericSplit(data, 'line', 'units', 10); return { totalSellOut, totalUnits, totalsByYear, byLine, byCustomer, seasonality, seasonalityUnits, availableYears: years, topMovers, bottomMovers, comparisonPeriods, topLinesSplit, byCustomerSplit, byLineOverviewSplit }; }; /** * Groups Pan-EU countries (Amazon DE, IT, FR, ES) into a single "Pan-EU" customer * when no customer filter is applied. This provides a consolidated view of European * markets while keeping UK and SC separate. * * @param data - Array of sales records * @param hasCustomerFilter - Whether a customer filter is currently applied * @returns Processed data with Pan-EU grouping applied if appropriate */ export const applyPanEUGrouping = ( data: SalesRecord[], hasCustomerFilter: boolean ): SalesRecord[] => { // If customer filter is applied, don't group - show selected countries as-is if (hasCustomerFilter) { return data; } // Replace Pan-EU country names with "Pan-EU" for grouping return data.map(record => { if (PAN_EU_COUNTRIES.includes(record.customer)) { return { ...record, customer: 'Pan-EU' }; } return record; }); }; export const getUniqueValues = (data: SalesRecord[], field: keyof SalesRecord): string[] => { const values = new Set(data.map(item => String(item[field]))); return Array.from(values).sort(); }; export const pivotSalesData = (data: any[], dimensions: string[] = ['title', 'customer', 'line', 'sku']): { rows: PivotRow[], years: string[] } => { // 1. Determine all years present in the data for columns const yearsSet = new Set(data.map(d => d.year)); const years = Array.from(yearsSet).sort((a, b) => b - a).map(String); const map = new Map(); data.forEach(record => { // Group by Dynamic Dimensions // Use a fallback for 'customer' dimension as some records use 'marketplace' const keyParts = dimensions.map(dim => { if (dim === 'customer') return String(record.customer || record.marketplace || ''); return String(record[dim] || ''); }); const key = keyParts.join('||'); if (!map.has(key)) { map.set(key, { id: key, customer: dimensions.includes('customer') ? (record.customer || record.marketplace || '') : '', line: dimensions.includes('line') ? record.line : '', title: dimensions.includes('title') ? record.title : '', articleName: dimensions.includes('articleName') ? record.articleName : '', sku: dimensions.includes('sku') ? record.sku : '', asin: dimensions.includes('asin') ? record.asin : '', // Initialize 12 months with empty year maps months: Array(12).fill(null).map((_, i) => ({ monthIndex: i, byYear: {} })), totalsByYear: {}, adsByYear: {} }); } const row = map.get(key)!; const monthRaw = record.month || ''; const monthPart = monthRaw.split('-')[0]; // Handle "Apr-23" -> "Apr" const monthIdx = MONTH_ORDER.indexOf(monthPart); const yearStr = record.year.toString(); // 1. Update Row Totals for Year if (!row.totalsByYear[yearStr]) { row.totalsByYear[yearStr] = { sellOut: 0, units: 0 }; } row.totalsByYear[yearStr].sellOut += (record.sellOut || record.salesTotal || 0); row.totalsByYear[yearStr].units += (record.units || record.unitsTotal || 0); // 2. Update Ads Data (if present in the record) if (record.cost !== undefined || record.salesAds !== undefined) { if (!row.adsByYear) row.adsByYear = {}; if (!row.adsByYear[yearStr]) { row.adsByYear[yearStr] = { adSpend: 0, attributedSales: 0, acos: 0, tacos: 0 }; } row.adsByYear[yearStr].adSpend += (record.cost || 0); row.adsByYear[yearStr].attributedSales += (record.salesAds || 0); // Recalculate ACOS/TACOS at the aggregated level const ads = row.adsByYear[yearStr]; const sales = row.totalsByYear[yearStr].sellOut; ads.acos = ads.attributedSales > 0 ? (ads.adSpend / ads.attributedSales) * 100 : 0; ads.tacos = sales > 0 ? (ads.adSpend / sales) * 100 : 0; } // 3. Update Monthly Data if (monthIdx !== -1) { const m = row.months[monthIdx]; if (!m.byYear[yearStr]) { m.byYear[yearStr] = { sellOut: 0, units: 0 }; } m.byYear[yearStr].sellOut += (record.sellOut || record.salesTotal || 0); m.byYear[yearStr].units += (record.units || record.unitsTotal || 0); } }); return { rows: Array.from(map.values()), years }; }; export const generateXLSX = (rows: PivotRow[], dimensions: string[], years: string[]) => { // Flatten PivotRows into Excel-friendly objects const flatData = rows.map(row => { const flatRow: any = {}; // Add Dimension Columns dimensions.forEach(dim => { let header = dim; if (dim === 'line') header = 'Product Line'; if (dim === 'title') header = 'Title'; if (dim === 'customer') header = 'Customer'; if (dim === 'sku' || dim === 'SKU') header = 'SKU'; if (dim === 'asin' || dim === 'ASIN') header = 'ASIN'; flatRow[header] = row[dim as keyof PivotRow]; }); // Add Yearly Totals years.forEach(year => { const data = row.totalsByYear[year]; flatRow[`Total Sell Out ${year}`] = data?.sellOut || 0; flatRow[`Total Units ${year}`] = data?.units || 0; }); // Add Monthly Data row.months.forEach(m => { const monthName = MONTH_ORDER[m.monthIndex]; years.forEach(year => { const data = m.byYear[year]; flatRow[`${monthName} ${year} Sell Out`] = data?.sellOut || 0; flatRow[`${monthName} ${year} Units`] = data?.units || 0; }); }); return flatRow; }); const ws = XLSX.utils.json_to_sheet(flatData); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Business Data'); XLSX.writeFile(wb, `Business_Data_Export_${new Date().toISOString().slice(0, 10)}.xlsx`); }; export const generateItemMoversXLSX = ( data: ItemGrowthMetric[], periods: { current: string; previous: string }, type: 'Gainers' | 'Losers' ) => { const flatData = data.map(item => ({ SKU: item.sku || '-', ASIN: item.asin || '-', 'Product Title': item.title || '-', 'Product Line': item.line || '-', [`Sell Out ${periods.previous}`]: item.previousYearSellOut, [`Sell Out ${periods.current}`]: item.currentYearSellOut, 'SO Diff': item.sellOutGrowthValue, 'SO Growth %': Number(item.sellOutGrowthPercentage.toFixed(2)), [`Units ${periods.previous}`]: item.previousYearUnits, [`Units ${periods.current}`]: item.currentYearUnits, 'Units Diff': item.unitsGrowthValue, 'Units Growth %': Number(item.unitsGrowthPercentage.toFixed(2)), })); const ws = XLSX.utils.json_to_sheet(flatData); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, type); XLSX.writeFile(wb, `${type}_${periods.current}_vs_${periods.previous}_${new Date().toISOString().split('T')[0]}.xlsx`); }; export const aggregateForTimeSeries = (data: SalesRecord[]): TimeSeriesData[] => { const map = new Map(); const recordsWithWeek = data.filter(r => r.week != null && r.year != null && r.week >= 1 && r.week <= 53); if (recordsWithWeek.length === 0) return []; // No weekly data to process recordsWithWeek.forEach(record => { // Create a sortable key YYYY-WW const weekStr = record.week!.toString().padStart(2, '0'); const key = `${record.year}-${weekStr}`; const current = map.get(key) || { sellOut: 0, units: 0 }; // Support both SalesRecord (sellOut/units) and CombinedKPIs (salesTotal/unitsTotal) current.sellOut += (record as any).sellOut ?? (record as any).salesTotal ?? 0; current.units += (record as any).units ?? (record as any).unitsTotal ?? 0; map.set(key, current); }); // Convert map to array and sort chronologically return Array.from(map.entries()) .sort((a, b) => a[0].localeCompare(b[0])) .map(([key, values]) => { const [year, weekNum] = key.split('-'); const yearShort = year.substring(2); return { name: `W${weekNum} '${yearShort}`, sellOut: values.sellOut, units: values.units }; }); }; export const aggregateForComparisonTimeSeries = (data: SalesRecord[]): ComparisonTimeSeriesPoint[] => { const map = new Map(); // Key is week number const years = Array.from(new Set(data.map(d => d.year))); // Initialize map for all 53 possible weeks to ensure a consistent X-axis for (let i = 1; i <= 53; i++) { const initialWeekData: { [key: string]: number } = {}; years.forEach(year => { initialWeekData[`${year}_sellOut`] = 0; initialWeekData[`${year}_units`] = 0; }); map.set(i, initialWeekData); } data.forEach(record => { if (record.week != null && record.year != null && record.week >= 1 && record.week <= 53) { const weekData = map.get(record.week)!; const sellOutKey = `${record.year}_sellOut`; const unitsKey = `${record.year}_units`; // Support both SalesRecord (sellOut/units) and CombinedKPIs (salesTotal/unitsTotal) const sellOut = (record as any).sellOut ?? (record as any).salesTotal ?? 0; const units = (record as any).units ?? (record as any).unitsTotal ?? 0; weekData[sellOutKey] = (weekData[sellOutKey] || 0) + sellOut; weekData[unitsKey] = (weekData[unitsKey] || 0) + units; map.set(record.week, weekData); } }); // Convert map to array, filter out weeks with no data across all years, and sort return Array.from(map.entries()) .map(([week, values]) => ({ week, name: `W${week}`, ...values, })) .filter(d => { // Check if there is any non-zero value for this week return Object.values(d).some(val => typeof val === 'number' && val > 0); }) .sort((a, b) => a.week - b.week); }; export interface WeeklyPivotRow { id: string; sku: string; title: string; asin: string; line: string; customer: string; unitsByWeek: { [weekKey: string]: number }; // Key: "YYYY-WW" spendByWeek: { [weekKey: string]: number }; // Key: "YYYY-WW" gvByWeek: { [weekKey: string]: number }; // Key: "YYYY-WW" } export const pivotWeeklySalesData = (data: CombinedKPIs[]): { rows: WeeklyPivotRow[], weeks: string[] } => { const weekKeysSet = new Set(); const map = new Map(); // Cache week keys to avoid repeated string formatting // Key: year|week, Value: YYYY-WW const weekCache = new Map(); const getWeekKey = (year: number, week: number) => { const cacheKey = `${year}|${week}`; let k = weekCache.get(cacheKey); if (!k) { k = `${year}-${String(week).padStart(2, '0')}`; weekCache.set(cacheKey, k); } return k; }; const len = data.length; for (let i = 0; i < len; i++) { const record = data[i]; if (!record.week) continue; const weekKey = getWeekKey(record.year, record.week); weekKeysSet.add(weekKey); const key = record.asin || record.sku || `${record.title}-${record.line}`; if (!key) continue; let row = map.get(key); if (!row) { row = { id: key, sku: record.sku || '', title: record.title || '', asin: record.asin || '', line: record.line || '', customer: record.customer || record.marketplace || '', unitsByWeek: {}, spendByWeek: {}, gvByWeek: {} }; map.set(key, row); } row.unitsByWeek[weekKey] = (row.unitsByWeek[weekKey] || 0) + (record.unitsTotal || 0); row.spendByWeek[weekKey] = (row.spendByWeek[weekKey] || 0) + (record.cost || 0); row.gvByWeek[weekKey] = (row.gvByWeek[weekKey] || 0) + (record.glanceViews || 0); } const sortedWeeks = Array.from(weekKeysSet).sort((a, b) => b.localeCompare(a)); return { rows: Array.from(map.values()), weeks: sortedWeeks }; }; export const processForecastExcel = async (fileOrBuffer: File | ArrayBuffer): Promise => { 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')), sku: row['SKU'] || row['sku'] || undefined, title: row['Title'] || row['title'] || row['Article Name'] || undefined, line: row['Product Line'] || row['line'] || row['ProductLine'] || undefined })).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, filters?: FilterState, velocityMap?: Map ): ProductForecastData[] => { const data2025 = rawData.filter(r => r.year === 2025); const data2026 = rawData.filter(r => r.year === 2026); // Calculate Seasonality weights for 2025 const getWeights = (records: SalesRecord[]): number[] | null => { 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 ASIN has any 2025 sales, we trust its specific seasonality. // Return null ONLY if there's no data at all for this ASIN in 2025. if (total === 0) return null; return weights.map(w => w / total); }; // 1. Determine Global/Default Weights const panEuData2025 = data2025.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[]) => { const weights = new Array(12).fill(0); let total = 0; const seenMonths = new Set(); 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 (r.units > 0) seenMonths.add(m); } }); // SAFETY NET: Even for Global Weights, if the reference file (2025 Sales) // has fewer than 4 months of data (e.g. user only uploaded Jan 2025), // we should NOT assume 100% seasonality in those months. Fallback to flat. if (total === 0 || seenMonths.size < 4) { return new Array(12).fill(1 / 12); } return weights.map(w => w / total); }; const panEuWeights = getGlobalWeightsInner(panEuData2025); // Check if we are in UK-only mode const isUkOnly = filters?.customer?.includes('Amazon UK') && filters.customer.length === 1; const getHybridWeights = (paEuRecords: SalesRecord[], ukRecords: SalesRecord[]) => { // Use Inner helper to ensure we always get weights for global subsets const peWeights = getGlobalWeightsInner(paEuRecords); const ukWeights = getGlobalWeightsInner(ukRecords); // Blend: Jan-Aug from Pan-EU, Sep-Dec from UK const hybrid = new Array(12).fill(0); const hasUkHistory = ukRecords.length > 0; for (let i = 0; i < 12; i++) { if (i < 8) { // Jan-Aug hybrid[i] = peWeights[i]; } else { // Sep-Dec hybrid[i] = hasUkHistory ? ukWeights[i] : peWeights[i]; } } // Normalize const sum = hybrid.reduce((a, b) => a + b, 0); return sum > 0 ? hybrid.map(w => w / sum) : peWeights; }; const globalWeights = isUkOnly ? getHybridWeights(panEuData2025, data2025.filter(r => r.customer === 'Amazon UK')) : panEuWeights; // Map 2025 data by ASIN for quick access const dataByAsin2025 = new Map(); 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>(); 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); const avgWeeklySales = velocityMap?.get(identifier) || 0; // 2. Determine weights for this ASIN const productRecords2025 = dataByAsin2025.get(identifier) || []; let productWeights = globalWeights; if (productRecords2025.length > 0) { if (isUkOnly) { // For UK Only, we try to be strict, but fallback to global UK weights if needed const ukProd = productRecords2025.filter(r => r.customer === 'Amazon UK'); // We use hybrid approach only if we have PanEU data for this product too const peProd = productRecords2025.filter(r => PAN_EU_COUNTRIES.includes(r.customer)); // If we have solid data for this product, use Hybrid or UK weights const specificWeights = getHybridWeights(peProd, ukProd); // Wait, getHybridWeights calls getGlobalWeightsInner which never returns null. // We need to check if specific product data is sparse. // Let's simplify: Check if we have enough UK history const ukWeights = getWeights(ukProd); if (ukWeights) { productWeights = ukWeights; } } else { const w = getWeights(productRecords2025); if (w) productWeights = w; // If w is null (sparse data), productWeights remains globalWeights (Default) } } // 3. Build monthly points and aggregate const monthlyData: Record = {}; let totalActualUnits = 0; let totalForecastUnits = 0; MONTH_ORDER.forEach((m, idx) => { const forecastUnits = Math.round(fc.annualForecast * productWeights[idx]); const actualUnits = actuals2026.get(identifier)?.get(m) || 0; monthlyData[m] = { month: m, forecastUnits, actualUnits, units: actualUnits // Added for chart compatibility } as any; totalActualUnits += actualUnits; totalForecastUnits += forecastUnits; }); const accuracy = totalForecastUnits > 0 ? Math.min(100, Math.round((1 - Math.abs(totalActualUnits - totalForecastUnits) / totalForecastUnits) * 100)) : 0; return { asin: identifier, sku: meta?.sku || fc.sku || identifier, title: meta?.title || fc.title || identifier, line: meta?.line || fc.line || "Unassigned", annualForecast: fc.annualForecast, actualUnits: totalActualUnits, forecastUnits: totalForecastUnits, accuracy: Math.max(0, accuracy), avgWeeklySales, monthlyData }; }); }; export const processVendorStockExcel = async (fileOrBuffer: File | ArrayBuffer): Promise> => { 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, { header: 1 }); const vendorStockMap = new Map(); // 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')) { headerRowIndex = i; break; } } if (headerRowIndex === -1) { 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 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 (!asin) continue; if (!vendorStockMap.has(asin)) { vendorStockMap.set(asin, { eu: 0, uk: 0 }); } const current = vendorStockMap.get(asin)!; // Map to UK or EU if (marketplace.includes('uk') || marketplace.includes('kingdom') || marketplace === 'gb') { current.uk += stockValue; } else { // Assume everything else is Pan-EU for now if it's not UK current.eu += stockValue; } } return vendorStockMap; } catch (error) { console.error("Error processing Vendor Stock Excel:", error); throw error; } }; export const calculateVelocityMap = (data: SalesRecord[]): Map => { // 4-Week Average Sales Calculation const validYears = data.map(r => r.year).filter(y => y > 0); if (validYears.length === 0) return new Map(); const latestYear = Math.max(...validYears); const yearData = data.filter(r => r.year === latestYear); const latestWeek = yearData.length > 0 ? Math.max(...yearData.map(r => r.week).filter(w => w !== undefined) as number[]) : 0; const last4WeeksKeys = new Set(); for (let i = 0; i < 4; i++) { let w = latestWeek - i; let y = latestYear; if (w <= 0) { w = 52 + w; y = latestYear - 1; } last4WeeksKeys.add(`${y}|${w}`); } const asin4WeekSales = new Map(); data.forEach(r => { if (r.week === undefined) return; if (last4WeeksKeys.has(`${r.year}|${r.week}`)) { const key = r.asin.trim().toUpperCase(); asin4WeekSales.set(key, (asin4WeekSales.get(key) || 0) + r.units); } }); const velocityMap = new Map(); asin4WeekSales.forEach((total, asin) => { velocityMap.set(asin, total / 4); }); return velocityMap; }; export const processStockExcel = async (fileOrBuffer: File | ArrayBuffer): Promise> => { 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, { header: 1 }); const stockMap = new Map(); // Skip headers (index 0) for (let i = 1; i < jsonData.length; i++) { const row = jsonData[i]; const rawSku = String(row[0] || '').trim(); if (!rawSku) continue; // Normalize SKU: Remove trailing EN or DE const normalizedSku = rawSku.replace(/(DE|EN)$/i, ''); // User requested Column I which is index 8 (After Assembly Orders GMBH) const stockValue = Number(row[8] || 0); if (!isNaN(stockValue)) { const current = stockMap.get(normalizedSku) || 0; stockMap.set(normalizedSku, current + stockValue); } } return stockMap; } catch (error) { console.error("Error processing Stock Excel:", error); throw error; } };