mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 12:35:24 +02:00
feat: restrict data to Amazon customers only (DE/FR/ES/IT/UK/SC)
This commit is contained in:
+371
-363
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user