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
const parseCurrency = (value: string): number => {
if (!value) return 0;
if (!value) return 0;
// Remove currency symbol and whitespace
let clean = value.replace(/[€$£\s]/g, '').trim();
// 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.
// 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 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);
// 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;
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);
@@ -98,8 +98,8 @@ 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
@@ -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 ---
@@ -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<SalesRecord[]> => {
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);
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)
resolve(data);
} catch (err) {
reject(err);
}
},
error: (error: any) => reject(error)
});
});
});
};
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) => {
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<AdsRecord[]> => {
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;
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;
for (let i = 0; i < len; i++) {
const row = rows[i];
if (!Array.isArray(row) || row.length < 13) 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;
// 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;
// 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)
// 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 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
const unitsRaw = row[11]; // L
const salesRaw = row[12]; // M
if (!asin || !countryRaw) continue;
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"
// 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<AdsRecord[]> => {
@@ -379,11 +387,11 @@ export const processAdsExcel = async (file: File): Promise<AdsRecord[]> => {
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;
@@ -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());
// 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);
// 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);
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;
});
return customerMatch && yearMatch && monthMatch && lineMatch && asinMatch && skuMatch && titleMatch;
});
};
const calculateSeasonality = (data: SalesRecord[]): { seasonality: SeasonalityPoint[], seasonalityUnits: SeasonalityPoint[], years: string[] } => {
const seasonalityMap = new Map<string, SeasonalityPoint>();
const seasonalityUnitsMap = new Map<string, SeasonalityPoint>();
const yearsSet = new Set<string>();
const seasonalityMap = new Map<string, SeasonalityPoint>();
const seasonalityUnitsMap = new Map<string, SeasonalityPoint>();
const yearsSet = new Set<string>();
// 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);
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];
// 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;
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;
}
});
// 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();
const seasonality = Array.from(seasonalityMap.values());
const seasonalityUnits = Array.from(seasonalityUnitsMap.values());
const years = Array.from(yearsSet).sort();
return { seasonality, seasonalityUnits, years };
return { seasonality, seasonalityUnits, years };
};
const calculateTopLinesSplit = (data: SalesRecord[]): YearlySplitData[] => {
// 1. Identify Lines by Sell Out (Sort desc)
const lineTotals = new Map<string, number>();
data.forEach(item => {
lineTotals.set(item.line, (lineTotals.get(item.line) || 0) + item.sellOut);
});
// 1. Identify Lines by Sell Out (Sort desc)
const lineTotals = new Map<string, number>();
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);
// 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<string, YearlySplitData>();
// 2. Aggregate data by Year
const resultMap = new Map<string, YearlySplitData>();
topLines.forEach(line => {
resultMap.set(line, { name: line });
});
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`;
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;
}
});
entry[keyVal] = ((entry[keyVal] as number) || 0) + item.sellOut;
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[] => {
@@ -608,7 +616,7 @@ const calculateGenericSplit = (data: SalesRecord[], groupField: keyof SalesRecor
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);
@@ -629,93 +637,93 @@ const calculateGenericSplit = (data: SalesRecord[], groupField: keyof SalesRecor
// Renamed from calculateMovers
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 allYears = new Set<number>();
const lineYearMap = new Map<string, Map<number, { sellOut: number; units: number }>>();
const allYears = new Set<number>();
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 currentYear = sortedYears[0];
const prevYear = sortedYears[1];
const bottomMovers = metrics
.filter(m => m.sellOutGrowthValue < 0)
.sort((a, b) => a.sellOutGrowthValue - b.sellOutGrowthValue);
const metrics: LineGrowthMetric[] = [];
return {
topMovers,
bottomMovers,
comparisonPeriods: { current: currentYear.toString(), previous: prevYear.toString() }
};
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() }
};
};
@@ -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<string, { sellOut: number; units: number }> = {};
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<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 totalsByYear: Record<string, { sellOut: number; units: number }> = {};
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<string, number>();
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<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 { 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<string, number>();
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<string, 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);
};