Files
CrazeAnalytix/services/dataProcessor.ts
T

817 lines
31 KiB
TypeScript
Raw Normal View History

import { SalesRecord, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint } from '../types';
import * as XLSX from 'xlsx';
// Helper to parse currency values handling both EU (1.234,56) and US/Standard (1,234.56 or 1234.56) formats
const parseCurrency = (value: string): number => {
if (!value) return 0;
// Remove currency symbol and whitespace
let clean = value.replace(/[€\s]/g, '').trim();
// HEURISTIC:
// If it contains a comma, we assume it's likely European format (Decimal separator)
// UNLESS it also contains a dot and the comma is before the dot (e.g. 1,000.50 - US format)
// But given the context (DE data), comma is usually decimal.
// Case A: European Format (e.g., "277.179,09" or "50,00")
if (clean.includes(',')) {
// If it has dots (thousands), remove them
clean = clean.replace(/\./g, '');
// Replace decimal comma with dot
clean = clean.replace(',', '.');
return parseFloat(clean);
}
// Case B: Standard/US Format or Clean Number (e.g. "277179.09" or "1000")
// Just remove any potential thousands separator commas (if any exist and we didn't catch them above)
// and parse.
clean = clean.replace(/,/g, '');
const num = parseFloat(clean);
return isNaN(num) ? 0 : num;
};
const parseUnits = (value: string): number => {
if(!value) return 0;
// Remove dots (thousands separators in EU) and commas (thousands in US) just to be safe for integers
const clean = value.replace(/[\.,]/g, '');
const num = parseInt(clean, 10);
return isNaN(num) ? 0 : num;
}
const MONTH_ORDER = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
// Robust Month Normalizer
const normalizeMonth = (rawMonth: string): string => {
if (!rawMonth) return '';
let m = rawMonth.trim();
// Handle numeric months "01", "1", "01-2023" (start with digits)
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];
}
// Handle text months "Apr-23", "Apr 23", "April"
// Extract first sequence of letters
const alphaMatch = m.match(/([a-zA-Z]+)/);
if (alphaMatch) {
m = alphaMatch[1];
}
// Take first 3 characters
if (m.length > 3) {
m = m.substring(0, 3);
}
// Capitalize first letter, lowercase rest
m = m.charAt(0).toUpperCase() + m.slice(1).toLowerCase();
return m;
};
// Robust CSV Column Value Extractor
// Handles case-insensitivity, trimming, multiple potential header aliases, AND ignores empty values to find fallbacks.
const getColumnValue = (row: any, aliases: string[]): string => {
const rowKeys = Object.keys(row);
// Create a map of normalized keys in the row to the actual keys
const normalizedRowKeys: Record<string, string> = {};
rowKeys.forEach(k => {
normalizedRowKeys[k.trim().toLowerCase()] = k;
});
for (const alias of aliases) {
const lookup = alias.trim().toLowerCase();
if (normalizedRowKeys[lookup]) {
const actualKey = normalizedRowKeys[lookup];
const val = row[actualKey];
if (val !== undefined && val !== null) {
const strVal = String(val).trim();
// CRITICAL FIX: Only return if the value is NOT empty.
// This allows falling back to the next alias if the first matching column exists but is empty.
if (strVal.length > 0) {
return strVal;
}
}
}
}
return '';
};
// Extracted Mapping Function
const mapRowToRecord = (row: any, index: number): SalesRecord => {
const customer = getColumnValue(row, ['NEW CUSTOMER', 'Customer', 'Client', 'Account', 'Partner', 'COUNTRY', 'Country', 'Market']) || 'Unknown';
const yearStr = getColumnValue(row, ['YEAR', 'Year', 'D']);
const year = parseInt(yearStr) || 0;
const monthStr = getColumnValue(row, ['MONTH', 'Month', 'Period']);
const month = normalizeMonth(monthStr);
const weekStr = getColumnValue(row, ['WEEK', 'Week', 'CW', 'Semana', 'KW', 'E']);
const weekNum = weekStr ? parseInt(weekStr.replace(/cw/i, '').trim(), 10) : NaN;
const week = isNaN(weekNum) ? undefined : weekNum;
const line = getColumnValue(row, ['LINE', 'Line', 'Product Line']) || 'Other';
// Updated ASIN priority list based on user feedback
const asin = getColumnValue(row, [
'CUSTOMER REFERENCE',
'AMAZON ASIN',
'ASIN',
'Asin',
'PRODUCT ID',
'ITEM IDENTIFIER',
'ASIN NO.',
'Product ASIN',
'IDENTIFIER'
]);
const sku = getColumnValue(row, ['RAW ARTICLE NO.', 'SKU', 'Sku', 'Item No']);
// Prioritize 'Title' column, fallback to 'Article Name' columns
const title = getColumnValue(row, ['ARTICLE NAME (Craze)', 'Title', 'TITLE', 'Product Title', 'Article Name', 'ArticleName']);
// Legacy/Backup field
const articleName = getColumnValue(row, ['ARTICLE NAME (Craze)', 'Article Name', 'ArticleName', 'Title']);
const unitsRaw = getColumnValue(row, ['UNITS', 'Units', 'Quantity', 'Qty']);
const sellOutRaw = getColumnValue(row, ['AMOUNT', 'Sell Out', 'SellOut', 'Revenue', 'Sales', 'Turnover']);
return {
id: `row-${index}`,
customer,
year,
month,
week,
asin,
sku,
title,
articleName,
units: parseUnits(unitsRaw),
sellOut: parseCurrency(sellOutRaw),
line
};
};
export const processCSV = (fileOrContent: File | string): Promise<SalesRecord[]> => {
return new Promise((resolve, reject) => {
// @ts-ignore - PapaParse is loaded globally via CDN
Papa.parse(fileOrContent, {
header: true,
// delimiter: ";", // Allow auto-detect
skipEmptyLines: true,
complete: (results: any) => {
try {
const data: SalesRecord[] = results.data.map((row: any, index: number) => {
return mapRowToRecord(row, index);
}).filter((r: SalesRecord) => r.year !== 2022 && r.line && r.line !== 'Other'); // Validation: Exclude 2022 and require line
resolve(data);
} catch (err) {
reject(err);
}
},
error: (error: any) => {
reject(error);
}
});
});
};
export const processExcel = async (file: File): Promise<SalesRecord[]> => {
try {
const arrayBuffer = await file.arrayBuffer();
const workbook = XLSX.read(arrayBuffer);
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
// Convert to JSON
// raw: false attempts to format the cell (e.g. dates), but for robustness we often prefer raw values or defval
// Using { defval: "" } ensures empty cells are present as empty strings if needed, but key logic handles missing keys.
const jsonData = XLSX.utils.sheet_to_json(worksheet, { defval: "" });
const data: SalesRecord[] = jsonData.map((row: any, index: number) => {
return mapRowToRecord(row, index);
}).filter((r: SalesRecord) => r.year !== 2022 && r.line && r.line !== 'Other');
return data;
} catch (error) {
console.error("Error processing Excel file:", error);
throw error;
}
}
export const filterData = (data: SalesRecord[], filters: FilterState): SalesRecord[] => {
return data.filter(item => {
// Item month is already normalized
const recordMonth = item.month;
const customerMatch = filters.customer.length === 0 || filters.customer.includes(item.customer);
const yearMatch = filters.year.length === 0 || filters.year.includes(item.year.toString());
const monthMatch = filters.month.length === 0 || filters.month.includes(recordMonth);
const lineMatch = filters.line.length === 0 || filters.line.includes(item.line);
const asinMatch = filters.asin.length === 0 || filters.asin.includes(item.asin);
const skuMatch = filters.sku.length === 0 || filters.sku.includes(item.sku);
const titleMatch = filters.title.length === 0 || filters.title.includes(item.title);
return customerMatch && yearMatch && monthMatch && lineMatch && asinMatch && skuMatch && titleMatch;
});
};
const calculateSeasonality = (data: SalesRecord[]): { seasonality: SeasonalityPoint[], seasonalityUnits: SeasonalityPoint[], years: string[] } => {
const seasonalityMap = new Map<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 });
});
data.forEach(record => {
const monthName = record.month;
const yearStr = record.year.toString();
yearsSet.add(yearStr);
if (seasonalityMap.has(monthName)) {
// Sell Out
const entrySO = seasonalityMap.get(monthName)!;
const currentValSO = (entrySO[yearStr] as number) || 0;
entrySO[yearStr] = currentValSO + record.sellOut;
// Units
const entryUnits = seasonalityUnitsMap.get(monthName)!;
const currentValUnits = (entryUnits[yearStr] as number) || 0;
entryUnits[yearStr] = currentValUnits + record.units;
}
});
const seasonality = Array.from(seasonalityMap.values());
const seasonalityUnits = Array.from(seasonalityUnitsMap.values());
const years = Array.from(yearsSet).sort();
return { seasonality, seasonalityUnits, years };
};
const calculateTopLinesSplit = (data: SalesRecord[]): YearlySplitData[] => {
// 1. Identify Lines by Sell Out (Sort desc)
const lineTotals = new Map<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);
// 2. Aggregate data by Year
const resultMap = new Map<string, YearlySplitData>();
topLines.forEach(line => {
resultMap.set(line, { name: line });
});
data.forEach(item => {
if (resultMap.has(item.line)) {
const entry = resultMap.get(item.line)!;
const keyVal = `${item.year}_value`;
const keyUnits = `${item.year}_units`;
entry[keyVal] = ((entry[keyVal] as number) || 0) + item.sellOut;
entry[keyUnits] = ((entry[keyUnits] as number) || 0) + item.units;
}
});
return Array.from(resultMap.values());
};
const calculateGenericSplit = (data: SalesRecord[], groupField: keyof SalesRecord, valueField: 'sellOut' | 'units', limit?: number): YearlySplitData[] => {
const totals = new Map<string, number>();
data.forEach(item => {
const key = String(item[groupField]);
totals.set(key, (totals.get(key) || 0) + item[valueField]);
});
let sortedKeys = Array.from(totals.entries()).sort((a,b) => b[1] - a[1]).map(e => e[0]);
if (limit) sortedKeys = sortedKeys.slice(0, limit);
const keySet = new Set(sortedKeys);
const resultMap = new Map<string, YearlySplitData>();
sortedKeys.forEach(k => resultMap.set(k, { name: k }));
data.forEach(item => {
const key = String(item[groupField]);
if (keySet.has(key)) {
const entry = resultMap.get(key)!;
const yearKey = item.year.toString();
entry[yearKey] = ((entry[yearKey] as number) || 0) + item[valueField];
}
});
return Array.from(resultMap.values());
};
// Renamed from calculateMovers
export const calculateLineMovers = (data: SalesRecord[]): { topMovers: LineGrowthMetric[], bottomMovers: LineGrowthMetric[], comparisonPeriods: { current: string, previous: string } } => {
const lineYearMap = new Map<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
});
}
});
const topMovers = metrics
.filter(m => m.sellOutGrowthValue > 0)
.sort((a, b) => b.sellOutGrowthValue - a.sellOutGrowthValue);
const bottomMovers = metrics
.filter(m => m.sellOutGrowthValue < 0)
.sort((a, b) => a.sellOutGrowthValue - b.sellOutGrowthValue);
return {
topMovers,
bottomMovers,
comparisonPeriods: { current: currentYear.toString(), previous: prevYear.toString() }
};
};
const createItemKey = (record: SalesRecord) => {
// A robust key combining all identifiers
return `${record.sku || 'NO_SKU'}||${record.asin || 'NO_ASIN'}||${record.title || 'NO_TITLE'}`;
}
export const calculateItemMovers = (
currentFilteredData: SalesRecord[],
selectedCustomerFromPage: string | null,
currentComparisonYearFromPage: number | null
): { topMovers: ItemGrowthMetric[], bottomMovers: ItemGrowthMetric[], comparisonPeriods: { current: string, previous: string } } => {
let dataToProcess = currentFilteredData;
// Apply customer filter if selected on the Top Movers page
if (selectedCustomerFromPage) {
dataToProcess = dataToProcess.filter(item => item.customer === selectedCustomerFromPage);
}
if (dataToProcess.length === 0) {
return { topMovers: [], bottomMovers: [], comparisonPeriods: { current: 'N/A', previous: 'N/A' } };
}
// Map to store item data aggregated by year
const itemYearMap = new Map<string, Map<number, { sellOut: number; units: number, sku: string, asin: string, title: string, line: string }>>();
const allYearsInFilteredData = new Set<number>();
dataToProcess.forEach(item => {
const itemKey = createItemKey(item);
if (!itemYearMap.has(itemKey)) {
itemYearMap.set(itemKey, new Map());
}
const yearMap = itemYearMap.get(itemKey)!;
const current = yearMap.get(item.year) || { sellOut: 0, units: 0, sku: item.sku, asin: item.asin, title: item.title, line: item.line };
yearMap.set(item.year, {
sellOut: current.sellOut + item.sellOut,
units: current.units + item.units,
sku: item.sku,
asin: item.asin,
title: item.title,
line: item.line
});
allYearsInFilteredData.add(item.year);
});
const sortedYearsInFilteredData = Array.from(allYearsInFilteredData).sort((a, b) => b - a); // Descending (most recent first)
let currentYear: number;
let prevYear: number;
if (currentComparisonYearFromPage) {
// If a specific comparison year is provided by the user on the Top Movers page
currentYear = currentComparisonYearFromPage;
const currentYearIndex = sortedYearsInFilteredData.indexOf(currentYear);
if (currentYearIndex === -1 || currentYearIndex === sortedYearsInFilteredData.length - 1) {
// Specified year not found in filtered data or it's the oldest year (no previous year for comparison)
return { topMovers: [], bottomMovers: [], comparisonPeriods: { current: currentYear.toString(), previous: 'N/A' } };
}
prevYear = sortedYearsInFilteredData[currentYearIndex + 1]; // The year directly before the currentComparisonYear
} else {
// Default to the two most recent years from the *filtered data* if no specific year is chosen
if (sortedYearsInFilteredData.length < 2) {
return { topMovers: [], bottomMovers: [], comparisonPeriods: { current: 'N/A', previous: 'N/A' } };
}
currentYear = sortedYearsInFilteredData[0]; // Most recent
prevYear = sortedYearsInFilteredData[1]; // Second most recent
}
const metrics: ItemGrowthMetric[] = [];
itemYearMap.forEach((yearMap) => {
const currData = yearMap.get(currentYear) || { sellOut: 0, units: 0, sku: '', asin: '', title: '', line: '' };
const prevData = yearMap.get(prevYear) || { sellOut: 0, units: 0, sku: '', asin: '', title: '', line: '' };
// Only include items that had some activity in at least one of the comparison years
if ((currData.sellOut === 0 && currData.units === 0) && (prevData.sellOut === 0 && prevData.units === 0)) {
return;
}
// Use metadata from current year, if not available use previous (for sku/asin/title/line)
const itemMeta = currData.sku ? currData : prevData;
// Sell Out Growth
let sellOutGrowthValue = currData.sellOut - prevData.sellOut;
let sellOutGrowthPercentage = 0;
if (prevData.sellOut !== 0) {
sellOutGrowthPercentage = (sellOutGrowthValue / prevData.sellOut) * 100;
} else if (currData.sellOut > 0) {
sellOutGrowthPercentage = 100; // Growth from zero
} else if (currData.sellOut === 0 && prevData.sellOut > 0) {
sellOutGrowthPercentage = -100; // Decline to zero
}
// Unit Growth
let unitsGrowthValue = currData.units - prevData.units;
let unitsGrowthPercentage = 0;
if (prevData.units !== 0) {
unitsGrowthPercentage = (unitsGrowthValue / prevData.units) * 100;
} else if (currData.units > 0) {
unitsGrowthPercentage = 100; // Growth from zero
} else if (currData.units === 0 && prevData.units > 0) {
unitsGrowthPercentage = -100; // Decline to zero
}
metrics.push({
sku: itemMeta.sku,
asin: itemMeta.asin,
title: itemMeta.title,
line: itemMeta.line,
currentYearSellOut: currData.sellOut,
previousYearSellOut: prevData.sellOut,
sellOutGrowthValue,
sellOutGrowthPercentage,
currentYearUnits: currData.units,
previousYearUnits: prevData.units,
unitsGrowthValue,
unitsGrowthPercentage
});
});
const topMovers = metrics
.sort((a, b) => b.unitsGrowthValue - a.unitsGrowthValue) // Sort by unitsGrowthValue
.slice(0, 20); // Top 20 Gainers
const bottomMovers = metrics
.sort((a, b) => a.unitsGrowthValue - b.unitsGrowthValue) // Sort by unitsGrowthValue
.slice(0, 20); // Top 20 Losers
return {
topMovers,
bottomMovers,
comparisonPeriods: { current: currentYear.toString(), previous: prevYear.toString() }
};
};
export const aggregateData = (data: SalesRecord[]): AggregatedData => {
const totalSellOut = data.reduce((acc, curr) => acc + curr.sellOut, 0);
const totalUnits = data.reduce((acc, curr) => acc + curr.units, 0);
const totalsByYear: Record<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 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 { 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();
};
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 map = new Map<string, PivotRow>();
data.forEach(record => {
// Group by Dynamic Dimensions
const keyParts = dimensions.map(dim => String(record[dim as keyof SalesRecord] || ''));
const key = keyParts.join('||');
if (!map.has(key)) {
map.set(key, {
id: key,
customer: dimensions.includes('customer') ? record.customer : '',
line: dimensions.includes('line') ? record.line : '',
title: dimensions.includes('title') ? record.title : '',
articleName: dimensions.includes('articleName') ? record.articleName : '',
sku: dimensions.includes('sku') ? record.sku : '',
asin: dimensions.includes('asin') ? record.asin : '',
// Initialize 12 months with empty year maps
months: Array(12).fill(null).map((_, i) => ({
monthIndex: i,
byYear: {}
})),
totalsByYear: {}
});
}
const row = map.get(key)!;
const monthPart = record.month;
const monthIdx = MONTH_ORDER.indexOf(monthPart);
const yearStr = record.year.toString();
// 1. Update Row Totals for Year
if (!row.totalsByYear[yearStr]) {
row.totalsByYear[yearStr] = { sellOut: 0, units: 0 };
}
row.totalsByYear[yearStr].sellOut += record.sellOut;
row.totalsByYear[yearStr].units += record.units;
// 2. Update Monthly Data
if (monthIdx !== -1) {
const m = row.months[monthIdx];
if (!m.byYear[yearStr]) {
m.byYear[yearStr] = { sellOut: 0, units: 0 };
}
m.byYear[yearStr].sellOut += record.sellOut;
m.byYear[yearStr].units += record.units;
}
});
return {
rows: Array.from(map.values()),
years
};
};
export const generateCSV = (rows: PivotRow[], dimensions: string[], years: string[]) => {
// Flatten PivotRows into CSV-friendly objects
const flatData = rows.map(row => {
const flatRow: any = {};
// Add Dimension Columns
dimensions.forEach(dim => {
// Map internal key to nicer Header if needed
let header = dim;
if (dim === 'line') header = 'Product Line';
if (dim === 'title') header = 'Title';
if (dim === 'customer') header = 'Customer';
flatRow[header] = row[dim as keyof PivotRow];
});
// Add Yearly Totals
years.forEach(year => {
const data = row.totalsByYear[year];
flatRow[`Total Sell Out ${year}`] = data?.sellOut || 0;
flatRow[`Total Units ${year}`] = data?.units || 0;
});
// Add Monthly Data
row.months.forEach(m => {
const monthName = MONTH_ORDER[m.monthIndex];
years.forEach(year => {
const data = m.byYear[year];
flatRow[`${monthName} ${year} Sell Out`] = data?.sellOut || 0;
flatRow[`${monthName} ${year} Units`] = data?.units || 0;
});
});
return flatRow;
});
// Generate CSV string
// @ts-ignore
const csv = Papa.unparse(flatData);
// Trigger Download
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', `sales_export_${new Date().toISOString().split('T')[0]}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
export const generateItemMoversCSV = (
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}) + '%',
}));
// @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);
};
export const aggregateForTimeSeries = (data: SalesRecord[]): TimeSeriesData[] => {
const map = new Map<string, { sellOut: number; units: number }>();
const recordsWithWeek = data.filter(r => r.week != null && r.year != null && r.week >= 1 && r.week <= 53);
if (recordsWithWeek.length === 0) return []; // No weekly data to process
recordsWithWeek.forEach(record => {
// Create a sortable key YYYY-WW
const weekStr = record.week!.toString().padStart(2, '0');
const key = `${record.year}-${weekStr}`;
const current = map.get(key) || { sellOut: 0, units: 0 };
current.sellOut += record.sellOut;
current.units += record.units;
map.set(key, current);
});
// Convert map to array and sort chronologically
return Array.from(map.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([key, values]) => {
const [year, weekNum] = key.split('-');
const yearShort = year.substring(2);
return {
name: `W${weekNum} '${yearShort}`,
sellOut: values.sellOut,
units: values.units
};
});
};
export const aggregateForComparisonTimeSeries = (data: SalesRecord[]): ComparisonTimeSeriesPoint[] => {
const map = new Map<number, { [key: string]: number }>(); // Key is week number
const years = Array.from(new Set(data.map(d => d.year)));
// Initialize map for all 53 possible weeks to ensure a consistent X-axis
for (let i = 1; i <= 53; i++) {
const initialWeekData: { [key: string]: number } = {};
years.forEach(year => {
initialWeekData[`${year}_sellOut`] = 0;
initialWeekData[`${year}_units`] = 0;
});
map.set(i, initialWeekData);
}
data.forEach(record => {
if (record.week != null && record.year != null && record.week >= 1 && record.week <= 53) {
const weekData = map.get(record.week)!;
const sellOutKey = `${record.year}_sellOut`;
const unitsKey = `${record.year}_units`;
weekData[sellOutKey] = (weekData[sellOutKey] || 0) + record.sellOut;
weekData[unitsKey] = (weekData[unitsKey] || 0) + record.units;
map.set(record.week, weekData);
}
});
// Convert map to array, filter out weeks with no data across all years, and sort
return Array.from(map.entries())
.map(([week, values]) => ({
week,
name: `W${week}`,
...values,
}))
.filter(d => {
// Check if there is any non-zero value for this week
return Object.values(d).some(val => typeof val === 'number' && val > 0);
})
.sort((a, b) => a.week - b.week);
};