diff --git a/services/dataProcessor.ts b/services/dataProcessor.ts index 5f5a6bc..530f1f8 100644 --- a/services/dataProcessor.ts +++ b/services/dataProcessor.ts @@ -4,43 +4,43 @@ 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(); + if (!value) return 0; - // 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); - } + // Remove currency symbol and whitespace + let clean = value.replace(/[€$£\s]/g, '').trim(); - // 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; + // 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; + 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); @@ -68,7 +68,7 @@ const MONTH_MAP: Record = { 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" @@ -98,21 +98,21 @@ const normalizeMonth = (rawMonth: string): string => { 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]; + 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]+)/); + 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) { @@ -121,7 +121,7 @@ const normalizeMonth = (rawMonth: string): string => { // 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 `${MONTH_MAP[letters]}-${y}`; } } @@ -151,6 +151,14 @@ const getColumnValue = (row: any, aliases: string[]): string => { } return ''; }; +// Allowed Customers Whitelist +const ALLOWED_CUSTOMERS = ['Amazon DE', 'Amazon FR', 'Amazon ES', 'Amazon IT', '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 --- @@ -161,7 +169,7 @@ const mapRowToRecord = (row: any, index: number): SalesRecord => { 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('-'); @@ -177,7 +185,7 @@ const mapRowToRecord = (row: any, index: number): SalesRecord => { const weekNum = weekStr ? parseInt(weekStr.replace(/cw/i, '').trim(), 10) : NaN; const week = isNaN(weekNum) ? undefined : weekNum; const line = getColumnValue(row, ['LINE', 'Line', 'Product Line']) || 'Unassigned'; - + const asin = getColumnValue(row, [ 'CUSTOMER REFERENCE', 'AMAZON ASIN', 'ASIN', 'Asin', 'PRODUCT ID', 'ITEM IDENTIFIER', 'ASIN NO.', 'Product ASIN', 'IDENTIFIER' ]); @@ -190,43 +198,43 @@ const mapRowToRecord = (row: any, index: number): SalesRecord => { 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 + 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); - }) - // Relaxed filtering: Only exclude rows with absolutely no year info even after backfill - .filter((r: SalesRecord) => r.year > 0); - - resolve(data); - } catch (err) { - reject(err); - } - }, - error: (error: any) => reject(error) + 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 AND Allowed Customer + .filter((r: SalesRecord) => r.year > 0 && isAllowedCustomer(r.customer)); + + resolve(data); + } catch (err) { + reject(err); + } + }, + error: (error: any) => reject(error) + }); }); - }); }; export const processExcel = async (file: File): Promise => { @@ -240,8 +248,8 @@ export const processExcel = async (file: File): Promise => { const data: SalesRecord[] = jsonData.map((row: any, index: number) => { return mapRowToRecord(row, index); }) - // Relaxed filtering - .filter((r: SalesRecord) => r.year > 0); + // Filter: Valid Year AND Allowed Customer + .filter((r: SalesRecord) => r.year > 0 && isAllowedCustomer(r.customer)); return data; } catch (error) { @@ -264,81 +272,81 @@ const mapCountryToMarketplace = (country: string): string => { }; 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 < 13) continue; + 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; - // 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; + for (let i = 0; i < len; i++) { + const row = rows[i]; + if (!Array.isArray(row) || row.length < 13) continue; - // A (0): Customer/Marketplace - // B (1): Month (Can be "may", "01", or Excel serial "45544") - // C (2): Year - // D (3): ASIN - // E (4): Ad Spend - // F (5): Clicks - // G (6): Impressions - // ... - // L (11): Units (Attributed) - // M (12): Sales (Sell Out) - - const countryRaw = row[0]; - const monthRaw = row[1]; - const yearRaw = row[2]; - const asin = row[3]; - const costRaw = row[4]; - const clicksRaw = row[5]; - const impressionsRaw = row[6]; - - const unitsRaw = row[11]; // L - const salesRaw = row[12]; // M + // 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; - if (!asin || !countryRaw) continue; + // A (0): Customer/Marketplace + // B (1): Month (Can be "may", "01", or Excel serial "45544") + // C (2): Year + // D (3): ASIN + // E (4): Ad Spend + // F (5): Clicks + // G (6): Impressions + // ... + // L (11): Units (Attributed) + // M (12): Sales (Sell Out) - // Construct Normalized Month-Year String (e.g., "May-24") - const pureMonth = normalizeMonth(String(monthRaw)); // Returns "May" - let yearShort = ''; - if (yearRaw) { - yearShort = String(yearRaw).trim().replace(/[,.]/g, '').slice(-2); // "2024" -> "24", handle "2,024" + const countryRaw = row[0]; + const monthRaw = row[1]; + const yearRaw = row[2]; + const asin = row[3]; + const costRaw = row[4]; + const clicksRaw = row[5]; + const impressionsRaw = row[6]; + + const unitsRaw = row[11]; // L + const salesRaw = row[12]; // M + + if (!asin || !countryRaw) continue; + + // Construct Normalized Month-Year String (e.g., "May-24") + const pureMonth = normalizeMonth(String(monthRaw)); // Returns "May" + let yearShort = ''; + if (yearRaw) { + yearShort = String(yearRaw).trim().replace(/[,.]/g, '').slice(-2); // "2024" -> "24", handle "2,024" + } + + // Avoid double year if normalizeMonth already extracted it (rare for serial numbers but possible for strings) + let finalMonthStr = pureMonth; + if (!finalMonthStr.includes('-') && yearShort) { + finalMonthStr = `${pureMonth}-${yearShort}`; + } + + data.push({ + country: mapCountryToMarketplace(String(countryRaw)), + month: finalMonthStr, + asin: String(asin).trim(), + cost: parseCurrency(String(costRaw)), + clicks: parseUnits(String(clicksRaw)), + impressions: parseUnits(String(impressionsRaw)), + attributedSales30d: parseCurrency(String(salesRaw)), + attributedUnits30d: parseUnits(String(unitsRaw)), + }); + } + resolve(data); + } catch (err) { + reject(err); } - - // Avoid double year if normalizeMonth already extracted it (rare for serial numbers but possible for strings) - let finalMonthStr = pureMonth; - if (!finalMonthStr.includes('-') && yearShort) { - finalMonthStr = `${pureMonth}-${yearShort}`; - } - - data.push({ - country: mapCountryToMarketplace(String(countryRaw)), - month: finalMonthStr, - asin: String(asin).trim(), - cost: parseCurrency(String(costRaw)), - clicks: parseUnits(String(clicksRaw)), - impressions: parseUnits(String(impressionsRaw)), - attributedSales30d: parseCurrency(String(salesRaw)), - attributedUnits30d: parseUnits(String(unitsRaw)), - }); - } - resolve(data); - } catch (err) { - reject(err); - } - }, - error: (error: any) => reject(error) + }, + error: (error: any) => reject(error) + }); }); - }); }; export const processAdsExcel = async (file: File): Promise => { @@ -347,7 +355,7 @@ export const processAdsExcel = async (file: File): Promise => { const workbook = XLSX.read(arrayBuffer); const firstSheetName = workbook.SheetNames[0]; const worksheet = workbook.Sheets[firstSheetName]; - + // Use header: 'A' to strictly map columns by index letter const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: "A", defval: "" }); @@ -366,7 +374,7 @@ export const processAdsExcel = async (file: File): Promise => { // ... // L: Units // M: Sales - + const countryRaw = row['A']; const monthRaw = row['B']; const yearRaw = row['C']; @@ -379,11 +387,11 @@ export const processAdsExcel = async (file: File): Promise => { if (!asin || !countryRaw) return null; - // Construct Normalized Month-Year String + // Construct Normalized Month-Year String const pureMonth = normalizeMonth(String(monthRaw)); let yearShort = ''; if (yearRaw) { - yearShort = String(yearRaw).trim().slice(-2); + yearShort = String(yearRaw).trim().slice(-2); } const finalMonthStr = yearShort ? `${pureMonth}-${yearShort}` : pureMonth; @@ -411,12 +419,12 @@ export const processAdsExcel = async (file: File): Promise => { export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecord[]): CombinedKPIs[] => { // 1. Index Ads Data for fast lookup: Key = ASIN + Marketplace + Month const adsMap = new Map(); - + adsData.forEach(ad => { // Ensure month format matches sales data (e.g. "May-24" vs "May-24") // Case-insensitive key const key = `${ad.asin.trim().toUpperCase()}|${ad.country.trim().toUpperCase()}|${ad.month.trim()}`; - + // If duplicates exist (e.g. multiple campaigns for same ASIN), sum them up if (adsMap.has(key)) { const existing = adsMap.get(key)!; @@ -433,15 +441,15 @@ export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecor // 2. Iterate Sales Data and merge const mergedData: CombinedKPIs[] = salesData.map(sale => { const key = `${sale.asin.trim().toUpperCase()}|${sale.customer.trim().toUpperCase()}|${sale.month.trim()}`; - const adData = adsMap.get(key) || { - country: sale.customer, - month: sale.month, - asin: sale.asin, - cost: 0, - clicks: 0, - impressions: 0, - attributedSales30d: 0, - attributedUnits30d: 0 + const adData = adsMap.get(key) || { + country: sale.customer, + month: sale.month, + asin: sale.asin, + cost: 0, + clicks: 0, + impressions: 0, + attributedSales30d: 0, + attributedUnits30d: 0 }; const salesTotal = sale.sellOut; @@ -461,7 +469,7 @@ export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecor const cpc = adData.clicks > 0 ? adData.cost / adData.clicks : 0; // CVR (Units / Clicks) const cvrUnits = adData.clicks > 0 ? (unitsAds / adData.clicks) * 100 : 0; - + const paidSalesShare = salesTotal > 0 ? (salesAds / salesTotal) * 100 : 0; const organicSalesShare = salesTotal > 0 ? (salesOrganic / salesTotal) * 100 : 0; @@ -474,22 +482,22 @@ export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecor title: sale.title, line: sale.line, sku: sale.sku, - + salesTotal, unitsTotal, - + salesAds, unitsAds, cost: adData.cost, clicks: adData.clicks, impressions: adData.impressions, - + salesOrganic, unitsOrganic, - + paidSalesShare, organicSalesShare, - + acos, tacos, roas, @@ -506,99 +514,99 @@ export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecor // --- EXISTING HELPERS --- export const filterData = (data: SalesRecord[], filters: FilterState): 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" + 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 || 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); + // 2. Filter Checks + const customerMatch = filters.customer.length === 0 || filters.customer.includes(item.customer); + const yearMatch = filters.year.length === 0 || filters.year.includes(item.year.toString()); - return customerMatch && yearMatch && monthMatch && lineMatch && asinMatch && skuMatch && titleMatch; - }); + // 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); + + return customerMatch && yearMatch && monthMatch && lineMatch && asinMatch && skuMatch && titleMatch; + }); }; const calculateSeasonality = (data: SalesRecord[]): { seasonality: SeasonalityPoint[], seasonalityUnits: SeasonalityPoint[], years: string[] } => { - const seasonalityMap = new Map(); - const seasonalityUnitsMap = new Map(); - const yearsSet = new Set(); + 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 }); - }); + // 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]; + 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); - if (seasonalityMap.has(pureMonth)) { - // Sell Out - const entrySO = seasonalityMap.get(pureMonth)!; - const currentValSO = (entrySO[yearStr] as number) || 0; - entrySO[yearStr] = currentValSO + record.sellOut; + // We need to match month name purely (Jan, Feb) for the X Axis, ignoring year + const pureMonth = monthName.split('-')[0]; - // Units - const entryUnits = seasonalityUnitsMap.get(pureMonth)!; - const currentValUnits = (entryUnits[yearStr] as number) || 0; - entryUnits[yearStr] = currentValUnits + record.units; - } - }); + if (seasonalityMap.has(pureMonth)) { + // Sell Out + const entrySO = seasonalityMap.get(pureMonth)!; + const currentValSO = (entrySO[yearStr] as number) || 0; + entrySO[yearStr] = currentValSO + record.sellOut; - const seasonality = Array.from(seasonalityMap.values()); - const seasonalityUnits = Array.from(seasonalityUnitsMap.values()); - const years = Array.from(yearsSet).sort(); + // Units + const entryUnits = seasonalityUnitsMap.get(pureMonth)!; + const currentValUnits = (entryUnits[yearStr] as number) || 0; + entryUnits[yearStr] = currentValUnits + record.units; + } + }); - return { seasonality, seasonalityUnits, years }; + 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); + // 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); + }); - // 2. Aggregate data by Year - const resultMap = new Map(); + // Return ALL lines + const topLines = Array.from(lineTotals.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([line]) => line); - topLines.forEach(line => { - resultMap.set(line, { name: line }); - }); + // 2. Aggregate data by Year + const resultMap = new Map(); - data.forEach(item => { - if (resultMap.has(item.line)) { - const entry = resultMap.get(item.line)!; - const keyVal = `${item.year}_value`; - const keyUnits = `${item.year}_units`; + topLines.forEach(line => { + resultMap.set(line, { name: line }); + }); - entry[keyVal] = ((entry[keyVal] as number) || 0) + item.sellOut; - entry[keyUnits] = ((entry[keyUnits] as number) || 0) + item.units; - } - }); + data.forEach(item => { + if (resultMap.has(item.line)) { + const entry = resultMap.get(item.line)!; + const keyVal = `${item.year}_value`; + const keyUnits = `${item.year}_units`; - return Array.from(resultMap.values()); + 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[] => { @@ -607,14 +615,14 @@ const calculateGenericSplit = (data: SalesRecord[], groupField: keyof SalesRecor 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]); + + 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)) { @@ -623,99 +631,99 @@ const calculateGenericSplit = (data: SalesRecord[], groupField: keyof SalesRecor 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(); + 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 + 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 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); + const currentYear = sortedYears[0]; + const prevYear = sortedYears[1]; - return { - topMovers, - bottomMovers, - comparisonPeriods: { current: currentYear.toString(), previous: prevYear.toString() } - }; + 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() } + }; }; @@ -725,11 +733,11 @@ const createItemKey = (record: SalesRecord) => { } export const calculateItemMovers = ( - currentFilteredData: SalesRecord[], - selectedCustomerFromPage: string | null, + 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 @@ -796,7 +804,7 @@ export const calculateItemMovers = ( 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; @@ -842,13 +850,13 @@ export const calculateItemMovers = ( 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, + return { + topMovers, bottomMovers, comparisonPeriods: { current: currentYear.toString(), previous: prevYear.toString() } }; @@ -856,70 +864,70 @@ export const calculateItemMovers = ( 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 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 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 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 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 { 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); + 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); - return { - totalSellOut, - totalUnits, - totalsByYear, - byLine, - byCustomer, - seasonality, - seasonalityUnits, - availableYears: years, - topMovers, - bottomMovers, - comparisonPeriods, - topLinesSplit, - byCustomerSplit, - byLineOverviewSplit - }; + 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 + }; }; export const getUniqueValues = (data: SalesRecord[], field: keyof SalesRecord): string[] => { - const values = new Set(data.map(item => String(item[field]))); - return Array.from(values).sort(); + const values = new Set(data.map(item => String(item[field]))); + return Array.from(values).sort(); }; export const pivotSalesData = (data: SalesRecord[], 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 years = Array.from(yearsSet).sort((a, b) => b - a).map(String); const map = new Map(); @@ -927,7 +935,7 @@ export const pivotSalesData = (data: SalesRecord[], dimensions: string[] = ['tit // Group by Dynamic Dimensions const keyParts = dimensions.map(dim => String(record[dim as keyof SalesRecord] || '')); const key = keyParts.join('||'); - + if (!map.has(key)) { map.set(key, { id: key, @@ -939,25 +947,25 @@ export const pivotSalesData = (data: SalesRecord[], dimensions: string[] = ['tit asin: dimensions.includes('asin') ? record.asin : '', // Initialize 12 months with empty year maps months: Array(12).fill(null).map((_, i) => ({ - monthIndex: i, + monthIndex: i, byYear: {} })), totalsByYear: {} }); } - + const row = map.get(key)!; const monthPart = record.month.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; row.totalsByYear[yearStr].units += record.units; - + // 2. Update Monthly Data if (monthIdx !== -1) { const m = row.months[monthIdx]; @@ -969,8 +977,8 @@ export const pivotSalesData = (data: SalesRecord[], dimensions: string[] = ['tit } }); - return { - rows: Array.from(map.values()), + return { + rows: Array.from(map.values()), years }; }; @@ -979,7 +987,7 @@ export const generateCSV = (rows: PivotRow[], dimensions: string[], years: strin // Flatten PivotRows into CSV-friendly objects const flatData = rows.map(row => { const flatRow: any = {}; - + // Add Dimension Columns dimensions.forEach(dim => { // Map internal key to nicer Header if needed @@ -987,7 +995,7 @@ export const generateCSV = (rows: PivotRow[], dimensions: string[], years: strin if (dim === 'line') header = 'Product Line'; if (dim === 'title') header = 'Title'; if (dim === 'customer') header = 'Customer'; - + flatRow[header] = row[dim as keyof PivotRow]; }); @@ -1027,36 +1035,36 @@ export const generateCSV = (rows: PivotRow[], dimensions: string[], years: strin }; export const generateItemMoversCSV = ( - data: ItemGrowthMetric[], - periods: { current: string; previous: string }, - type: 'Gainers' | 'Losers' + 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.toLocaleString('de-DE', {minimumFractionDigits: 0, maximumFractionDigits: 0}), - [`Sell Out ${periods.current}`]: item.currentYearSellOut.toLocaleString('de-DE', {minimumFractionDigits: 0, maximumFractionDigits: 0}), - 'SO Diff': item.sellOutGrowthValue.toLocaleString('de-DE', {minimumFractionDigits: 0, maximumFractionDigits: 0}), - 'SO Growth %': item.sellOutGrowthPercentage.toLocaleString('de-DE', {minimumFractionDigits: 1, maximumFractionDigits: 1}) + '%', - [`Units ${periods.previous}`]: item.previousYearUnits.toLocaleString('de-DE', {minimumFractionDigits: 0, maximumFractionDigits: 0}), - [`Units ${periods.current}`]: item.currentYearUnits.toLocaleString('de-DE', {minimumFractionDigits: 0, maximumFractionDigits: 0}), - 'Units Diff': item.unitsGrowthValue.toLocaleString('de-DE', {minimumFractionDigits: 0, maximumFractionDigits: 0}), - 'Units Growth %': item.unitsGrowthPercentage.toLocaleString('de-DE', {minimumFractionDigits: 1, maximumFractionDigits: 1}) + '%', - })); + 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.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 }), + [`Sell Out ${periods.current}`]: item.currentYearSellOut.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 }), + 'SO Diff': item.sellOutGrowthValue.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 }), + 'SO Growth %': item.sellOutGrowthPercentage.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + '%', + [`Units ${periods.previous}`]: item.previousYearUnits.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 }), + [`Units ${periods.current}`]: item.currentYearUnits.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 }), + 'Units Diff': item.unitsGrowthValue.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 }), + 'Units Growth %': item.unitsGrowthPercentage.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + '%', + })); - // @ts-ignore - const csv = Papa.unparse(flatData); + // @ts-ignore + const csv = Papa.unparse(flatData); - const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.setAttribute('download', `${type}_${periods.current}_vs_${periods.previous}_${new Date().toISOString().split('T')[0]}.csv`); - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `${type}_${periods.current}_vs_${periods.previous}_${new Date().toISOString().split('T')[0]}.csv`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); }; @@ -1083,7 +1091,7 @@ export const aggregateForTimeSeries = (data: SalesRecord[]): TimeSeriesData[] => .map(([key, values]) => { const [year, weekNum] = key.split('-'); const yearShort = year.substring(2); - + return { name: `W${weekNum} '${yearShort}`, sellOut: values.sellOut, @@ -1109,13 +1117,13 @@ export const aggregateForComparisonTimeSeries = (data: SalesRecord[]): Compariso 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`; weekData[sellOutKey] = (weekData[sellOutKey] || 0) + record.sellOut; weekData[unitsKey] = (weekData[unitsKey] || 0) + record.units; - + map.set(record.week, weekData); } });