mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 15:15:24 +02:00
817 lines
31 KiB
TypeScript
817 lines
31 KiB
TypeScript
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);
|
||
|
|
};
|