feat: restrict data to Amazon customers only (DE/FR/ES/IT/UK/SC)

This commit is contained in:
Christian Vidal Wolf
2026-01-16 10:44:43 +01:00
parent 470db20458
commit a1ad3aa944
+371 -363
View File
@@ -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 // 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 => { const parseCurrency = (value: string): number => {
if (!value) return 0; if (!value) return 0;
// Remove currency symbol and whitespace // Remove currency symbol and whitespace
let clean = value.replace(/[€$£\s]/g, '').trim(); let clean = value.replace(/[€$£\s]/g, '').trim();
// HEURISTIC: // HEURISTIC:
// If it contains a comma, we assume it's likely European format (Decimal separator) // 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) // 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. // 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") // Case A: European Format (e.g., "277.179,09" or "50,00" or "263,83")
if (clean.includes(',') && !clean.includes('.')) { if (clean.includes(',') && !clean.includes('.')) {
// Likely EU decimal without thousands or with thousands implicitly handled // Likely EU decimal without thousands or with thousands implicitly handled
// e.g. "263,83" -> "263.83" // e.g. "263,83" -> "263.83"
clean = clean.replace(',', '.'); clean = clean.replace(',', '.');
return parseFloat(clean); return parseFloat(clean);
} }
else if (clean.includes(',') && clean.includes('.')) { else if (clean.includes(',') && clean.includes('.')) {
// Mixed: 1.234,56 -> EU // Mixed: 1.234,56 -> EU
if (clean.indexOf(',') > clean.indexOf('.')) { if (clean.indexOf(',') > clean.indexOf('.')) {
clean = clean.replace(/\./g, '').replace(',', '.'); clean = clean.replace(/\./g, '').replace(',', '.');
} else { } else {
// 1,234.56 -> US // 1,234.56 -> US
clean = clean.replace(/,/g, ''); clean = clean.replace(/,/g, '');
} }
return parseFloat(clean); return parseFloat(clean);
} }
// Case B: Standard/US Format or Clean Number (e.g. "277179.09" or "1000") // Case B: Standard/US Format or Clean Number (e.g. "277179.09" or "1000")
clean = clean.replace(/,/g, ''); // Remove commas just in case clean = clean.replace(/,/g, ''); // Remove commas just in case
const num = parseFloat(clean); const num = parseFloat(clean);
return isNaN(num) ? 0 : num; return isNaN(num) ? 0 : num;
}; };
const parseUnits = (value: string): number => { 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 // Remove dots (thousands separators in EU) and commas (thousands in US) just to be safe for integers
const clean = value.replace(/[\.,]/g, ''); const clean = value.replace(/[\.,]/g, '');
const num = parseInt(clean, 10); const num = parseInt(clean, 10);
@@ -98,8 +98,8 @@ const normalizeMonth = (rawMonth: string): string => {
const numMatch = m.match(/^(\d{1,2})([^\d]|$)/); const numMatch = m.match(/^(\d{1,2})([^\d]|$)/);
if (numMatch) { if (numMatch) {
const num = parseInt(numMatch[1]); const num = parseInt(numMatch[1]);
if (num >= 1 && num <= 12) return MONTH_ORDER[num - 1]; if (num >= 1 && num <= 12) return MONTH_ORDER[num - 1];
} }
// 3. Fallback: Extract first 3 letters and capitalize // 3. Fallback: Extract first 3 letters and capitalize
@@ -121,7 +121,7 @@ const normalizeMonth = (rawMonth: string): string => {
// This part is likely fallback for Sales Data records // This part is likely fallback for Sales Data records
const letters = m.replace(/[^a-z]/g, ''); const letters = m.replace(/[^a-z]/g, '');
if (letters && MONTH_MAP[letters]) { 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 ''; 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 --- // --- SALES / SELL OUT MAPPING ---
@@ -190,43 +198,43 @@ const mapRowToRecord = (row: any, index: number): SalesRecord => {
const sellOutRaw = getColumnValue(row, ['AMOUNT', 'Sell Out', 'SellOut', 'Revenue', 'Sales', 'Turnover']); const sellOutRaw = getColumnValue(row, ['AMOUNT', 'Sell Out', 'SellOut', 'Revenue', 'Sales', 'Turnover']);
return { return {
id: `row-${index}`, id: `row-${index}`,
customer, customer,
year, year,
month, month,
week, week,
asin, asin,
sku, sku,
title, title,
articleName, articleName,
units: parseUnits(unitsRaw), units: parseUnits(unitsRaw),
sellOut: parseCurrency(sellOutRaw), sellOut: parseCurrency(sellOutRaw),
line line
}; };
}; };
export const processCSV = (fileOrContent: File | string): Promise<SalesRecord[]> => { export const processCSV = (fileOrContent: File | string): Promise<SalesRecord[]> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// @ts-ignore // @ts-ignore
Papa.parse(fileOrContent, { Papa.parse(fileOrContent, {
header: true, header: true,
skipEmptyLines: true, skipEmptyLines: true,
complete: (results: any) => { complete: (results: any) => {
try { try {
const data: SalesRecord[] = results.data.map((row: any, index: number) => { const data: SalesRecord[] = results.data.map((row: any, index: number) => {
return mapRowToRecord(row, index); return mapRowToRecord(row, index);
}) })
// Relaxed filtering: Only exclude rows with absolutely no year info even after backfill // Filter: Valid Year AND Allowed Customer
.filter((r: SalesRecord) => r.year > 0); .filter((r: SalesRecord) => r.year > 0 && isAllowedCustomer(r.customer));
resolve(data); resolve(data);
} catch (err) { } catch (err) {
reject(err); reject(err);
} }
}, },
error: (error: any) => reject(error) error: (error: any) => reject(error)
});
}); });
});
}; };
export const processExcel = async (file: File): Promise<SalesRecord[]> => { export const processExcel = async (file: File): Promise<SalesRecord[]> => {
@@ -240,8 +248,8 @@ export const processExcel = async (file: File): Promise<SalesRecord[]> => {
const data: SalesRecord[] = jsonData.map((row: any, index: number) => { const data: SalesRecord[] = jsonData.map((row: any, index: number) => {
return mapRowToRecord(row, index); return mapRowToRecord(row, index);
}) })
// Relaxed filtering // Filter: Valid Year AND Allowed Customer
.filter((r: SalesRecord) => r.year > 0); .filter((r: SalesRecord) => r.year > 0 && isAllowedCustomer(r.customer));
return data; return data;
} catch (error) { } catch (error) {
@@ -264,81 +272,81 @@ const mapCountryToMarketplace = (country: string): string => {
}; };
export const processAdsCSV = (file: File): Promise<AdsRecord[]> => { export const processAdsCSV = (file: File): Promise<AdsRecord[]> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// @ts-ignore // @ts-ignore
Papa.parse(file, { Papa.parse(file, {
header: false, // Index-based mapping header: false, // Index-based mapping
skipEmptyLines: true, skipEmptyLines: true,
complete: (results: any) => { complete: (results: any) => {
try { try {
const data: AdsRecord[] = []; const data: AdsRecord[] = [];
const rows = results.data; const rows = results.data;
const len = rows.length; const len = rows.length;
for (let i = 0; i < len; i++) { for (let i = 0; i < len; i++) {
const row = rows[i]; const row = rows[i];
if (!Array.isArray(row) || row.length < 13) continue; if (!Array.isArray(row) || row.length < 13) continue;
// Check header row (Column A: Customer or Country) // Check header row (Column A: Customer or Country)
const c0 = String(row[0]).trim().toLowerCase(); const c0 = String(row[0]).trim().toLowerCase();
if (c0.includes('customer') || c0.includes('country') || c0.includes('marketplace')) continue; if (c0.includes('customer') || c0.includes('country') || c0.includes('marketplace')) continue;
// A (0): Customer/Marketplace // A (0): Customer/Marketplace
// B (1): Month (Can be "may", "01", or Excel serial "45544") // B (1): Month (Can be "may", "01", or Excel serial "45544")
// C (2): Year // C (2): Year
// D (3): ASIN // D (3): ASIN
// E (4): Ad Spend // E (4): Ad Spend
// F (5): Clicks // F (5): Clicks
// G (6): Impressions // G (6): Impressions
// ... // ...
// L (11): Units (Attributed) // L (11): Units (Attributed)
// M (12): Sales (Sell Out) // M (12): Sales (Sell Out)
const countryRaw = row[0]; const countryRaw = row[0];
const monthRaw = row[1]; const monthRaw = row[1];
const yearRaw = row[2]; const yearRaw = row[2];
const asin = row[3]; const asin = row[3];
const costRaw = row[4]; const costRaw = row[4];
const clicksRaw = row[5]; const clicksRaw = row[5];
const impressionsRaw = row[6]; const impressionsRaw = row[6];
const unitsRaw = row[11]; // L const unitsRaw = row[11]; // L
const salesRaw = row[12]; // M const salesRaw = row[12]; // M
if (!asin || !countryRaw) continue; if (!asin || !countryRaw) continue;
// Construct Normalized Month-Year String (e.g., "May-24") // Construct Normalized Month-Year String (e.g., "May-24")
const pureMonth = normalizeMonth(String(monthRaw)); // Returns "May" const pureMonth = normalizeMonth(String(monthRaw)); // Returns "May"
let yearShort = ''; let yearShort = '';
if (yearRaw) { if (yearRaw) {
yearShort = String(yearRaw).trim().replace(/[,.]/g, '').slice(-2); // "2024" -> "24", handle "2,024" 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) error: (error: any) => reject(error)
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)
}); });
});
}; };
export const processAdsExcel = async (file: File): Promise<AdsRecord[]> => { export const processAdsExcel = async (file: File): Promise<AdsRecord[]> => {
@@ -379,11 +387,11 @@ export const processAdsExcel = async (file: File): Promise<AdsRecord[]> => {
if (!asin || !countryRaw) return null; if (!asin || !countryRaw) return null;
// Construct Normalized Month-Year String // Construct Normalized Month-Year String
const pureMonth = normalizeMonth(String(monthRaw)); const pureMonth = normalizeMonth(String(monthRaw));
let yearShort = ''; let yearShort = '';
if (yearRaw) { if (yearRaw) {
yearShort = String(yearRaw).trim().slice(-2); yearShort = String(yearRaw).trim().slice(-2);
} }
const finalMonthStr = yearShort ? `${pureMonth}-${yearShort}` : pureMonth; const finalMonthStr = yearShort ? `${pureMonth}-${yearShort}` : pureMonth;
@@ -506,99 +514,99 @@ export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecor
// --- EXISTING HELPERS --- // --- EXISTING HELPERS ---
export const filterData = (data: SalesRecord[], filters: FilterState): SalesRecord[] => { export const filterData = (data: SalesRecord[], filters: FilterState): SalesRecord[] => {
return data.filter(item => { return data.filter(item => {
// 1. Month Logic: Handle "Apr-23" matching "Apr" filter // 1. Month Logic: Handle "Apr-23" matching "Apr" filter
const recordMonth = item.month; // e.g. "Apr-23" const recordMonth = item.month; // e.g. "Apr-23"
const pureMonth = recordMonth.split('-')[0]; // "Apr" const pureMonth = recordMonth.split('-')[0]; // "Apr"
// 2. Filter Checks // 2. Filter Checks
const customerMatch = filters.customer.length === 0 || filters.customer.includes(item.customer); const customerMatch = filters.customer.length === 0 || filters.customer.includes(item.customer);
const yearMatch = filters.year.length === 0 || filters.year.includes(item.year.toString()); 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 // 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 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 lineMatch = filters.line.length === 0 || filters.line.includes(item.line);
const asinMatch = filters.asin.length === 0 || filters.asin.includes(item.asin); const asinMatch = filters.asin.length === 0 || filters.asin.includes(item.asin);
const skuMatch = filters.sku.length === 0 || filters.sku.includes(item.sku); const skuMatch = filters.sku.length === 0 || filters.sku.includes(item.sku);
const titleMatch = filters.title.length === 0 || filters.title.includes(item.title); const titleMatch = filters.title.length === 0 || filters.title.includes(item.title);
return customerMatch && yearMatch && monthMatch && lineMatch && asinMatch && skuMatch && titleMatch; return customerMatch && yearMatch && monthMatch && lineMatch && asinMatch && skuMatch && titleMatch;
}); });
}; };
const calculateSeasonality = (data: SalesRecord[]): { seasonality: SeasonalityPoint[], seasonalityUnits: SeasonalityPoint[], years: string[] } => { const calculateSeasonality = (data: SalesRecord[]): { seasonality: SeasonalityPoint[], seasonalityUnits: SeasonalityPoint[], years: string[] } => {
const seasonalityMap = new Map<string, SeasonalityPoint>(); const seasonalityMap = new Map<string, SeasonalityPoint>();
const seasonalityUnitsMap = new Map<string, SeasonalityPoint>(); const seasonalityUnitsMap = new Map<string, SeasonalityPoint>();
const yearsSet = new Set<string>(); const yearsSet = new Set<string>();
// Initialize all months // Initialize all months
MONTH_ORDER.forEach(m => { MONTH_ORDER.forEach(m => {
seasonalityMap.set(m, { name: m }); seasonalityMap.set(m, { name: m });
seasonalityUnitsMap.set(m, { name: m }); seasonalityUnitsMap.set(m, { name: m });
}); });
data.forEach(record => { data.forEach(record => {
const monthName = record.month; const monthName = record.month;
// Extract year from record.month if it's in Format "Mon-YY", else use record.year // 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". // record.year is numeric, record.month is "Apr-23".
const yearStr = record.year.toString(); const yearStr = record.year.toString();
yearsSet.add(yearStr); yearsSet.add(yearStr);
// We need to match month name purely (Jan, Feb) for the X Axis, ignoring year // We need to match month name purely (Jan, Feb) for the X Axis, ignoring year
const pureMonth = monthName.split('-')[0]; const pureMonth = monthName.split('-')[0];
if (seasonalityMap.has(pureMonth)) { if (seasonalityMap.has(pureMonth)) {
// Sell Out // Sell Out
const entrySO = seasonalityMap.get(pureMonth)!; const entrySO = seasonalityMap.get(pureMonth)!;
const currentValSO = (entrySO[yearStr] as number) || 0; const currentValSO = (entrySO[yearStr] as number) || 0;
entrySO[yearStr] = currentValSO + record.sellOut; entrySO[yearStr] = currentValSO + record.sellOut;
// Units // Units
const entryUnits = seasonalityUnitsMap.get(pureMonth)!; const entryUnits = seasonalityUnitsMap.get(pureMonth)!;
const currentValUnits = (entryUnits[yearStr] as number) || 0; const currentValUnits = (entryUnits[yearStr] as number) || 0;
entryUnits[yearStr] = currentValUnits + record.units; entryUnits[yearStr] = currentValUnits + record.units;
} }
}); });
const seasonality = Array.from(seasonalityMap.values()); const seasonality = Array.from(seasonalityMap.values());
const seasonalityUnits = Array.from(seasonalityUnitsMap.values()); const seasonalityUnits = Array.from(seasonalityUnitsMap.values());
const years = Array.from(yearsSet).sort(); const years = Array.from(yearsSet).sort();
return { seasonality, seasonalityUnits, years }; return { seasonality, seasonalityUnits, years };
}; };
const calculateTopLinesSplit = (data: SalesRecord[]): YearlySplitData[] => { const calculateTopLinesSplit = (data: SalesRecord[]): YearlySplitData[] => {
// 1. Identify Lines by Sell Out (Sort desc) // 1. Identify Lines by Sell Out (Sort desc)
const lineTotals = new Map<string, number>(); const lineTotals = new Map<string, number>();
data.forEach(item => { data.forEach(item => {
lineTotals.set(item.line, (lineTotals.get(item.line) || 0) + item.sellOut); lineTotals.set(item.line, (lineTotals.get(item.line) || 0) + item.sellOut);
}); });
// Return ALL lines // Return ALL lines
const topLines = Array.from(lineTotals.entries()) const topLines = Array.from(lineTotals.entries())
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.map(([line]) => line); .map(([line]) => line);
// 2. Aggregate data by Year // 2. Aggregate data by Year
const resultMap = new Map<string, YearlySplitData>(); const resultMap = new Map<string, YearlySplitData>();
topLines.forEach(line => { topLines.forEach(line => {
resultMap.set(line, { name: line }); resultMap.set(line, { name: line });
}); });
data.forEach(item => { data.forEach(item => {
if (resultMap.has(item.line)) { if (resultMap.has(item.line)) {
const entry = resultMap.get(item.line)!; const entry = resultMap.get(item.line)!;
const keyVal = `${item.year}_value`; const keyVal = `${item.year}_value`;
const keyUnits = `${item.year}_units`; const keyUnits = `${item.year}_units`;
entry[keyVal] = ((entry[keyVal] as number) || 0) + item.sellOut; entry[keyVal] = ((entry[keyVal] as number) || 0) + item.sellOut;
entry[keyUnits] = ((entry[keyUnits] as number) || 0) + item.units; entry[keyUnits] = ((entry[keyUnits] as number) || 0) + item.units;
} }
}); });
return Array.from(resultMap.values()); return Array.from(resultMap.values());
}; };
const calculateGenericSplit = (data: SalesRecord[], groupField: keyof SalesRecord, valueField: 'sellOut' | 'units', limit?: number): YearlySplitData[] => { const calculateGenericSplit = (data: SalesRecord[], groupField: keyof SalesRecord, valueField: 'sellOut' | 'units', limit?: number): YearlySplitData[] => {
@@ -608,7 +616,7 @@ const calculateGenericSplit = (data: SalesRecord[], groupField: keyof SalesRecor
totals.set(key, (totals.get(key) || 0) + item[valueField]); 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); if (limit) sortedKeys = sortedKeys.slice(0, limit);
const keySet = new Set(sortedKeys); const keySet = new Set(sortedKeys);
@@ -629,93 +637,93 @@ const calculateGenericSplit = (data: SalesRecord[], groupField: keyof SalesRecor
// Renamed from calculateMovers // Renamed from calculateMovers
export const calculateLineMovers = (data: SalesRecord[]): { topMovers: LineGrowthMetric[], bottomMovers: LineGrowthMetric[], comparisonPeriods: { current: string, previous: string } } => { export const calculateLineMovers = (data: SalesRecord[]): { topMovers: LineGrowthMetric[], bottomMovers: LineGrowthMetric[], comparisonPeriods: { current: string, previous: string } } => {
const lineYearMap = new Map<string, Map<number, { sellOut: number; units: number }>>(); const lineYearMap = new Map<string, Map<number, { sellOut: number; units: number }>>();
const allYears = new Set<number>(); const allYears = new Set<number>();
data.forEach(item => { data.forEach(item => {
if (!lineYearMap.has(item.line)) { if (!lineYearMap.has(item.line)) {
lineYearMap.set(item.line, new Map()); lineYearMap.set(item.line, new Map());
} }
const yearMap = lineYearMap.get(item.line)!; const yearMap = lineYearMap.get(item.line)!;
const current = yearMap.get(item.year) || { sellOut: 0, units: 0 }; const current = yearMap.get(item.year) || { sellOut: 0, units: 0 };
yearMap.set(item.year, { yearMap.set(item.year, {
sellOut: current.sellOut + item.sellOut, sellOut: current.sellOut + item.sellOut,
units: current.units + item.units 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
}); });
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 const currentYear = sortedYears[0];
.filter(m => m.sellOutGrowthValue > 0) const prevYear = sortedYears[1];
.sort((a, b) => b.sellOutGrowthValue - a.sellOutGrowthValue);
const bottomMovers = metrics const metrics: LineGrowthMetric[] = [];
.filter(m => m.sellOutGrowthValue < 0)
.sort((a, b) => a.sellOutGrowthValue - b.sellOutGrowthValue);
return { lineYearMap.forEach((yearMap, line) => {
topMovers, const currData = yearMap.get(currentYear) || { sellOut: 0, units: 0 };
bottomMovers, const prevData = yearMap.get(prevYear) || { sellOut: 0, units: 0 };
comparisonPeriods: { current: currentYear.toString(), previous: prevYear.toString() }
}; // 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() }
};
}; };
@@ -856,70 +864,70 @@ export const calculateItemMovers = (
export const aggregateData = (data: SalesRecord[]): AggregatedData => { export const aggregateData = (data: SalesRecord[]): AggregatedData => {
const totalSellOut = data.reduce((acc, curr) => acc + curr.sellOut, 0); const totalSellOut = data.reduce((acc, curr) => acc + curr.sellOut, 0);
const totalUnits = data.reduce((acc, curr) => acc + curr.units, 0); const totalUnits = data.reduce((acc, curr) => acc + curr.units, 0);
const totalsByYear: Record<string, { sellOut: number; units: number }> = {}; const totalsByYear: Record<string, { sellOut: number; units: number }> = {};
data.forEach(item => { data.forEach(item => {
const y = item.year.toString(); const y = item.year.toString();
if (!totalsByYear[y]) totalsByYear[y] = { sellOut: 0, units: 0 }; if (!totalsByYear[y]) totalsByYear[y] = { sellOut: 0, units: 0 };
totalsByYear[y].sellOut += item.sellOut; totalsByYear[y].sellOut += item.sellOut;
totalsByYear[y].units += item.units; totalsByYear[y].units += item.units;
});
const lineMap = new Map<string, { value: number; units: number }>();
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<string, number>(); const lineMap = new Map<string, { value: number; units: number }>();
data.forEach(item => { data.forEach(item => {
customerMap.set(item.customer, (customerMap.get(item.customer) || 0) + item.sellOut); const current = lineMap.get(item.line) || { value: 0, units: 0 };
}); lineMap.set(item.line, {
const byCustomer = Array.from(customerMap.entries()) value: current.value + item.sellOut,
.map(([name, value]) => ({ name, value })) units: current.units + item.units
.sort((a, b) => b.value - a.value); });
});
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 customerMap = new Map<string, number>();
const { topMovers, bottomMovers, comparisonPeriods } = calculateLineMovers(data); // Use calculateLineMovers data.forEach(item => {
const topLinesSplit = calculateTopLinesSplit(data); customerMap.set(item.customer, (customerMap.get(item.customer) || 0) + item.sellOut);
const byCustomerSplit = calculateGenericSplit(data, 'customer', 'sellOut'); });
const byLineOverviewSplit = calculateGenericSplit(data, 'line', 'units', 10); const byCustomer = Array.from(customerMap.entries())
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value);
return { const { seasonality, seasonalityUnits, years } = calculateSeasonality(data);
totalSellOut, const { topMovers, bottomMovers, comparisonPeriods } = calculateLineMovers(data); // Use calculateLineMovers
totalUnits, const topLinesSplit = calculateTopLinesSplit(data);
totalsByYear, const byCustomerSplit = calculateGenericSplit(data, 'customer', 'sellOut');
byLine, const byLineOverviewSplit = calculateGenericSplit(data, 'line', 'units', 10);
byCustomer,
seasonality, return {
seasonalityUnits, totalSellOut,
availableYears: years, totalUnits,
topMovers, totalsByYear,
bottomMovers, byLine,
comparisonPeriods, byCustomer,
topLinesSplit, seasonality,
byCustomerSplit, seasonalityUnits,
byLineOverviewSplit availableYears: years,
}; topMovers,
bottomMovers,
comparisonPeriods,
topLinesSplit,
byCustomerSplit,
byLineOverviewSplit
};
}; };
export const getUniqueValues = (data: SalesRecord[], field: keyof SalesRecord): string[] => { export const getUniqueValues = (data: SalesRecord[], field: keyof SalesRecord): string[] => {
const values = new Set(data.map(item => String(item[field]))); const values = new Set(data.map(item => String(item[field])));
return Array.from(values).sort(); return Array.from(values).sort();
}; };
export const pivotSalesData = (data: SalesRecord[], dimensions: string[] = ['title', 'customer', 'line', 'sku']): { rows: PivotRow[], years: string[] } => { 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 // 1. Determine all years present in the data for columns
const yearsSet = new Set(data.map(d => d.year)); 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<string, PivotRow>(); const map = new Map<string, PivotRow>();
@@ -1027,36 +1035,36 @@ export const generateCSV = (rows: PivotRow[], dimensions: string[], years: strin
}; };
export const generateItemMoversCSV = ( export const generateItemMoversCSV = (
data: ItemGrowthMetric[], data: ItemGrowthMetric[],
periods: { current: string; previous: string }, periods: { current: string; previous: string },
type: 'Gainers' | 'Losers' type: 'Gainers' | 'Losers'
) => { ) => {
const flatData = data.map(item => ({ const flatData = data.map(item => ({
SKU: item.sku || '-', SKU: item.sku || '-',
ASIN: item.asin || '-', ASIN: item.asin || '-',
'Product Title': item.title || '-', 'Product Title': item.title || '-',
'Product Line': item.line || '-', 'Product Line': item.line || '-',
[`Sell Out ${periods.previous}`]: item.previousYearSellOut.toLocaleString('de-DE', {minimumFractionDigits: 0, maximumFractionDigits: 0}), [`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}), [`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 Diff': item.sellOutGrowthValue.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 }),
'SO Growth %': item.sellOutGrowthPercentage.toLocaleString('de-DE', {minimumFractionDigits: 1, maximumFractionDigits: 1}) + '%', '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.previous}`]: item.previousYearUnits.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 }),
[`Units ${periods.current}`]: item.currentYearUnits.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 Diff': item.unitsGrowthValue.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 }),
'Units Growth %': item.unitsGrowthPercentage.toLocaleString('de-DE', {minimumFractionDigits: 1, maximumFractionDigits: 1}) + '%', 'Units Growth %': item.unitsGrowthPercentage.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + '%',
})); }));
// @ts-ignore // @ts-ignore
const csv = Papa.unparse(flatData); const csv = Papa.unparse(flatData);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
link.setAttribute('download', `${type}_${periods.current}_vs_${periods.previous}_${new Date().toISOString().split('T')[0]}.csv`); link.setAttribute('download', `${type}_${periods.current}_vs_${periods.previous}_${new Date().toISOString().split('T')[0]}.csv`);
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
}; };