mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 19:55:24 +02:00
1311 lines
52 KiB
TypeScript
1311 lines
52 KiB
TypeScript
import { SalesRecord, AdsRecord, CombinedKPIs, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint } from '../types';
|
|
import * as XLSX from 'xlsx';
|
|
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;
|
|
|
|
// 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" 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);
|
|
|
|
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'];
|
|
|
|
// Comprehensive Month Mapping (English + Spanish + Short/Full)
|
|
const MONTH_MAP: Record<string, string> = {
|
|
// English Short
|
|
'jan': 'Jan', 'feb': 'Feb', 'mar': 'Mar', 'apr': 'Apr', 'may': 'May', 'jun': 'Jun',
|
|
'jul': 'Jul', 'aug': 'Aug', 'sep': 'Sep', 'oct': 'Oct', 'nov': 'Nov', 'dec': 'Dec',
|
|
// Spanish Short
|
|
'ene': 'Jan', 'abr': 'Apr', 'ago': 'Aug', 'dic': 'Dec', 'set': 'Sep',
|
|
// Spanish Full
|
|
'enero': 'Jan', 'febrero': 'Feb', 'marzo': 'Mar', 'abril': 'Apr', 'mayo': 'May', 'junio': 'Jun',
|
|
'julio': 'Jul', 'agosto': 'Aug', 'septiembre': 'Sep', 'octubre': 'Oct', 'noviembre': 'Nov', 'diciembre': 'Dec',
|
|
// English Full
|
|
'january': 'Jan', 'february': 'Feb', 'march': 'Mar', 'april': 'Apr', 'june': 'Jun',
|
|
'july': 'Jul', 'august': 'Aug', 'september': 'Sep', 'october': 'Oct', 'november': 'Nov', 'december': 'Dec'
|
|
};
|
|
|
|
// Robust Month Normalizer
|
|
const normalizeMonth = (rawMonth: string): string => {
|
|
if (!rawMonth) return '';
|
|
let m = String(rawMonth).trim().toLowerCase();
|
|
|
|
// 0. Check for Excel Serial Date (e.g. 45544 -> Sep)
|
|
// 25569 is the offset days between Excel epoch (1899-12-30) and Unix epoch (1970-01-01)
|
|
// We check if it's a number > 20000 (roughly year 1954+) to avoid confusion with valid days like "31"
|
|
const potentialSerial = parseFloat(m);
|
|
if (!isNaN(potentialSerial) && potentialSerial > 20000) {
|
|
// Convert Excel serial to JS Date
|
|
const date = new Date(Math.round((potentialSerial - 25569) * 86400 * 1000));
|
|
if (!isNaN(date.getTime())) {
|
|
return MONTH_ORDER[date.getMonth()];
|
|
}
|
|
}
|
|
|
|
// 1. Direct Map Lookup (Handles "jan", "enero", "sep", etc.)
|
|
if (MONTH_MAP[m]) return MONTH_MAP[m];
|
|
|
|
// 2. Handle numeric months "01", "1", "01-2023"
|
|
// If it's a full date string like "2023-04-01" or "01/04/2023"
|
|
if (m.includes('/') || m.includes('-')) {
|
|
// Try parsing standard date
|
|
const date = new Date(m);
|
|
if (!isNaN(date.getTime())) {
|
|
const monthIdx = date.getMonth();
|
|
const yearShort = date.getFullYear().toString().slice(2);
|
|
return `${MONTH_ORDER[monthIdx]}-${yearShort}`;
|
|
}
|
|
}
|
|
|
|
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];
|
|
}
|
|
|
|
// 3. Fallback: Extract first 3 letters and capitalize
|
|
const alphaMatch = m.match(/([a-zA-Z\u00C0-\u00FF]+)/);
|
|
if (alphaMatch) {
|
|
let alpha = alphaMatch[1];
|
|
if (alpha.length > 3) alpha = alpha.substring(0, 3);
|
|
// Check map again with short version
|
|
if (MONTH_MAP[alpha]) return MONTH_MAP[alpha];
|
|
|
|
return alpha.charAt(0).toUpperCase() + alpha.slice(1);
|
|
}
|
|
|
|
// Try to grab year from original string to append (e.g. "Apr-23") if strict matching failed
|
|
const yearMatch = rawMonth.match(/(\d{2,4})/);
|
|
if (yearMatch) {
|
|
let y = yearMatch[1];
|
|
if (y.length === 4) y = y.slice(2);
|
|
// 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 rawMonth; // Return as-is if all else fails
|
|
};
|
|
|
|
// Robust CSV Column Value Extractor
|
|
const getColumnValue = (row: any, aliases: string[]): string => {
|
|
const rowKeys = Object.keys(row);
|
|
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();
|
|
if (strVal.length > 0) {
|
|
return strVal;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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 ---
|
|
|
|
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']);
|
|
// Sanitize year string before parsing (remove commas/dots e.g. "2,023")
|
|
let year = parseInt(yearStr.replace(/[,.]/g, '')) || 0;
|
|
const monthStr = getColumnValue(row, ['MONTH', 'Month', 'Period']);
|
|
const month = normalizeMonth(monthStr);
|
|
|
|
// BACKFILL YEAR if missing but present in Month (e.g. "Apr-23")
|
|
if (year === 0 && month.includes('-')) {
|
|
const parts = month.split('-');
|
|
if (parts.length === 2) {
|
|
const yPart = parts[1];
|
|
// assume 20xx for 2 digits
|
|
if (yPart.length === 2) year = 2000 + parseInt(yPart);
|
|
else if (yPart.length === 4) year = parseInt(yPart);
|
|
}
|
|
}
|
|
|
|
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, ['LICENSE', 'License']) || 'Unassigned';
|
|
|
|
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']);
|
|
const title = getColumnValue(row, ['ARTICLE NAME (Craze)', 'Title', 'TITLE', 'Product Title', 'Article Name', 'ArticleName']);
|
|
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
|
|
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 > 2023 (exclude incomplete 2023 data) AND Allowed Customer
|
|
.filter((r: SalesRecord) => r.year > 2023 && isAllowedCustomer(r.customer));
|
|
|
|
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];
|
|
const jsonData = XLSX.utils.sheet_to_json(worksheet, { defval: "" });
|
|
|
|
const data: SalesRecord[] = jsonData.map((row: any, index: number) => {
|
|
return mapRowToRecord(row, index);
|
|
})
|
|
// Filter: Valid Year AND Allowed Customer
|
|
.filter((r: SalesRecord) => r.year > 0 && isAllowedCustomer(r.customer));
|
|
|
|
return data;
|
|
} catch (error) {
|
|
console.error("Error processing Excel file:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// --- ADS DATA MAPPING ---
|
|
|
|
const mapCountryToMarketplace = (country: string): string => {
|
|
const c = String(country).toLowerCase().trim();
|
|
if (c.includes('germany') || c.includes('deutschland') || c.includes('de')) return 'Amazon DE';
|
|
if (c.includes('spain') || c.includes('espana') || c.includes('españa') || c.includes('es')) return 'Amazon ES';
|
|
if (c.includes('france') || c.includes('fr')) return 'Amazon FR';
|
|
if (c.includes('italy') || c.includes('italia') || c.includes('it')) return 'Amazon IT';
|
|
if (c.includes('kingdom') || c.includes('uk') || c === 'gb') return 'Amazon UK';
|
|
if (c.includes('netherlands') || c.includes('nederland') || c.includes('holland') || c.includes('nl')) return 'Amazon NL';
|
|
return country.toUpperCase(); // Fallback
|
|
};
|
|
|
|
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;
|
|
|
|
for (let i = 0; i < len; i++) {
|
|
const row = rows[i];
|
|
if (!Array.isArray(row) || row.length < 12) 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;
|
|
|
|
// Column mapping for CSV (same as Excel):
|
|
// A (0): Country
|
|
// B (1): Week
|
|
// C (2): ASIN
|
|
// D (3): Cost
|
|
// E (4): Clicks
|
|
// F (5): Impressions
|
|
// G (6): CPC
|
|
// H (7): CTR %
|
|
// I (8): ACOS %
|
|
// J (9): Conversions (30d)
|
|
// K (10): Units (30d)
|
|
// L (11): Sales (30d)
|
|
|
|
const countryRaw = row[0];
|
|
const weekRaw = row[1];
|
|
const asin = row[2];
|
|
const costRaw = row[3];
|
|
const clicksRaw = row[4];
|
|
const impressionsRaw = row[5];
|
|
const cpcRaw = row[6];
|
|
const ctrRaw = row[7];
|
|
const acosRaw = row[8];
|
|
const conversionsRaw = row[9];
|
|
const unitsRaw = row[10];
|
|
const salesRaw = row[11];
|
|
|
|
if (!asin || !countryRaw || weekRaw === undefined) continue;
|
|
|
|
const weekNum = parseInt(String(weekRaw));
|
|
if (isNaN(weekNum) || weekNum < 1 || weekNum > 53) continue;
|
|
|
|
// For CSV without sheet names, assume current year
|
|
const currentYear = new Date().getFullYear();
|
|
|
|
data.push({
|
|
country: mapCountryToMarketplace(String(countryRaw)),
|
|
year: currentYear,
|
|
week: weekNum,
|
|
asin: String(asin).trim(),
|
|
cost: parseCurrency(String(costRaw)),
|
|
clicks: parseUnits(String(clicksRaw)),
|
|
impressions: parseUnits(String(impressionsRaw)),
|
|
cpc: parseCurrency(String(cpcRaw)),
|
|
ctr: parseCurrency(String(ctrRaw)),
|
|
acos: parseCurrency(String(acosRaw)),
|
|
conversions: parseUnits(String(conversionsRaw)),
|
|
attributedUnits30d: parseUnits(String(unitsRaw)),
|
|
attributedSales30d: parseCurrency(String(salesRaw)),
|
|
});
|
|
}
|
|
resolve(data);
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
},
|
|
error: (error: any) => reject(error)
|
|
});
|
|
});
|
|
};
|
|
|
|
export const processAdsExcel = async (fileOrBuffer: File | ArrayBuffer): Promise<AdsRecord[]> => {
|
|
try {
|
|
const arrayBuffer = fileOrBuffer instanceof File
|
|
? await fileOrBuffer.arrayBuffer()
|
|
: fileOrBuffer;
|
|
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
|
const allData: AdsRecord[] = [];
|
|
|
|
// Process ALL sheets (e.g., "2025", "2026")
|
|
for (const sheetName of workbook.SheetNames) {
|
|
const year = parseInt(sheetName);
|
|
if (isNaN(year) || year < 2020 || year > 2100) {
|
|
console.warn(`Skipping sheet "${sheetName}" - not a valid year`);
|
|
continue;
|
|
}
|
|
|
|
const worksheet = workbook.Sheets[sheetName];
|
|
// Use header: 1 to get array of arrays (row-based)
|
|
const jsonData: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: "" });
|
|
|
|
console.log(`Processing sheet ${sheetName}: ${jsonData.length} rows`);
|
|
|
|
// Skip header row (index 0), process data rows
|
|
for (let i = 1; i < jsonData.length; i++) {
|
|
const row = jsonData[i];
|
|
if (!row || row.length < 12) continue;
|
|
|
|
// Column mapping for Ads Weekly.xlsx:
|
|
// A (0): Country
|
|
// B (1): Week
|
|
// C (2): ASIN
|
|
// D (3): Cost
|
|
// E (4): Clicks
|
|
// F (5): Impressions
|
|
// G (6): CPC
|
|
// H (7): CTR %
|
|
// I (8): ACOS %
|
|
// J (9): Conversions (30d)
|
|
// K (10): Units (30d)
|
|
// L (11): Sales (30d)
|
|
|
|
const countryRaw = row[0];
|
|
const weekRaw = row[1];
|
|
const asin = row[2];
|
|
const costRaw = row[3];
|
|
const clicksRaw = row[4];
|
|
const impressionsRaw = row[5];
|
|
const cpcRaw = row[6];
|
|
const ctrRaw = row[7];
|
|
const acosRaw = row[8];
|
|
const conversionsRaw = row[9];
|
|
const unitsRaw = row[10];
|
|
const salesRaw = row[11];
|
|
|
|
// Skip if missing essential data
|
|
if (!asin || !countryRaw || weekRaw === undefined || weekRaw === '') continue;
|
|
|
|
const weekNum = parseInt(String(weekRaw));
|
|
if (isNaN(weekNum) || weekNum < 1 || weekNum > 53) continue;
|
|
|
|
allData.push({
|
|
country: mapCountryToMarketplace(String(countryRaw)),
|
|
year,
|
|
week: weekNum,
|
|
asin: String(asin).trim(),
|
|
cost: parseCurrency(String(costRaw)),
|
|
clicks: parseUnits(String(clicksRaw)),
|
|
impressions: parseUnits(String(impressionsRaw)),
|
|
cpc: parseCurrency(String(cpcRaw)),
|
|
ctr: parseCurrency(String(ctrRaw)),
|
|
acos: parseCurrency(String(acosRaw)),
|
|
conversions: parseUnits(String(conversionsRaw)),
|
|
attributedUnits30d: parseUnits(String(unitsRaw)),
|
|
attributedSales30d: parseCurrency(String(salesRaw)),
|
|
});
|
|
}
|
|
}
|
|
|
|
console.log(`Total Ads records loaded: ${allData.length}`);
|
|
return allData;
|
|
} catch (error) {
|
|
console.error("Error processing Ads Excel:", error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// --- DATA MERGING ---
|
|
|
|
export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecord[]): CombinedKPIs[] => {
|
|
// 1. Index Ads Data for fast lookup: Key = ASIN + Marketplace + Year + Week
|
|
const adsMap = new Map<string, AdsRecord>();
|
|
|
|
adsData.forEach(ad => {
|
|
// Case-insensitive key using ASIN + Country + Year + Week
|
|
const key = `${ad.asin.trim().toUpperCase()}|${ad.country.trim().toUpperCase()}|${ad.year}|${ad.week}`;
|
|
|
|
// If duplicates exist (e.g. multiple campaigns for same ASIN), sum them up
|
|
if (adsMap.has(key)) {
|
|
const existing = adsMap.get(key)!;
|
|
existing.cost += ad.cost;
|
|
existing.clicks += ad.clicks;
|
|
existing.impressions += ad.impressions;
|
|
existing.attributedSales30d += ad.attributedSales30d;
|
|
existing.attributedUnits30d += ad.attributedUnits30d;
|
|
existing.conversions += ad.conversions;
|
|
} else {
|
|
adsMap.set(key, { ...ad });
|
|
}
|
|
});
|
|
|
|
// 2. Iterate Sales Data and merge
|
|
const mergedData: CombinedKPIs[] = salesData.map(sale => {
|
|
// Use week from sales record if available
|
|
const weekNum = sale.week || 0;
|
|
const key = `${sale.asin.trim().toUpperCase()}|${sale.customer.trim().toUpperCase()}|${sale.year}|${weekNum}`;
|
|
const adData = adsMap.get(key) || {
|
|
country: sale.customer,
|
|
year: sale.year,
|
|
week: weekNum,
|
|
asin: sale.asin,
|
|
cost: 0,
|
|
clicks: 0,
|
|
impressions: 0,
|
|
cpc: 0,
|
|
ctr: 0,
|
|
acos: 0,
|
|
conversions: 0,
|
|
attributedSales30d: 0,
|
|
attributedUnits30d: 0
|
|
};
|
|
|
|
const salesTotal = sale.sellOut;
|
|
const salesAds = adData.attributedSales30d;
|
|
// Logic: Organic = Total - Ads. Max(0) to avoid negative if attribution window logic differs vs finance dates
|
|
const salesOrganic = Math.max(0, salesTotal - salesAds);
|
|
|
|
const unitsTotal = sale.units;
|
|
const unitsAds = adData.attributedUnits30d;
|
|
const unitsOrganic = Math.max(0, unitsTotal - unitsAds);
|
|
|
|
// KPIs
|
|
const acos = salesAds > 0 ? (adData.cost / salesAds) * 100 : 0;
|
|
const tacos = salesTotal > 0 ? (adData.cost / salesTotal) * 100 : 0;
|
|
const roas = adData.cost > 0 ? salesAds / adData.cost : 0;
|
|
const ctr = adData.impressions > 0 ? (adData.clicks / adData.impressions) * 100 : 0;
|
|
const cpc = adData.clicks > 0 ? adData.cost / adData.clicks : 0;
|
|
// CVR (Units / Clicks)
|
|
const cvrUnits = adData.clicks > 0 ? (unitsAds / adData.clicks) * 100 : 0;
|
|
|
|
const paidSalesShare = salesTotal > 0 ? (salesAds / salesTotal) * 100 : 0;
|
|
const organicSalesShare = salesTotal > 0 ? (salesOrganic / salesTotal) * 100 : 0;
|
|
|
|
return {
|
|
id: sale.id,
|
|
marketplace: sale.customer,
|
|
customer: sale.customer,
|
|
month: sale.month,
|
|
week: sale.week || 0, // Preserve week info
|
|
year: sale.year,
|
|
asin: sale.asin,
|
|
title: sale.title,
|
|
line: sale.line,
|
|
sku: sale.sku,
|
|
|
|
salesTotal,
|
|
unitsTotal,
|
|
|
|
salesAds,
|
|
unitsAds,
|
|
cost: adData.cost,
|
|
clicks: adData.clicks,
|
|
impressions: adData.impressions,
|
|
|
|
salesOrganic,
|
|
unitsOrganic,
|
|
|
|
paidSalesShare,
|
|
organicSalesShare,
|
|
|
|
acos,
|
|
tacos,
|
|
roas,
|
|
ctr,
|
|
cpc,
|
|
cvrUnits
|
|
};
|
|
});
|
|
|
|
return mergedData;
|
|
};
|
|
|
|
|
|
// --- EXISTING HELPERS ---
|
|
|
|
// Filter Ads Data by Country, Year, Week, and ASIN
|
|
export const filterAdsData = (adsData: AdsRecord[], filters: FilterState): AdsRecord[] => {
|
|
return adsData.filter(ad => {
|
|
// Country/Customer match (ads use 'country', sales use 'customer')
|
|
const countryMatch = filters.customer.length === 0 ||
|
|
filters.customer.some(c => c.toUpperCase() === ad.country.toUpperCase());
|
|
|
|
// Year match
|
|
const yearMatch = filters.year.length === 0 ||
|
|
filters.year.includes(ad.year.toString());
|
|
|
|
// Week match (filters use "W1", "W2" format)
|
|
const weekStr = `W${ad.week}`;
|
|
const weekMatch = filters.week.length === 0 || filters.week.includes(weekStr);
|
|
|
|
// ASIN match
|
|
const asinMatch = filters.asin.length === 0 ||
|
|
filters.asin.some(a => a.toUpperCase() === ad.asin.toUpperCase());
|
|
|
|
return countryMatch && yearMatch && weekMatch && asinMatch;
|
|
});
|
|
};
|
|
|
|
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"
|
|
|
|
// 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);
|
|
|
|
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);
|
|
|
|
// Week Logic: Match "W1", "W2" etc.
|
|
// item.week is a number (e.g. 1), filter uses strings "W1"
|
|
const weekStr = item.week ? `W${item.week}` : '';
|
|
const weekMatch = filters.week.length === 0 || (weekStr !== '' && filters.week.includes(weekStr));
|
|
|
|
return customerMatch && yearMatch && monthMatch && lineMatch && asinMatch && skuMatch && titleMatch && weekMatch;
|
|
});
|
|
};
|
|
|
|
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;
|
|
// 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];
|
|
|
|
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;
|
|
}
|
|
});
|
|
|
|
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
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Groups Pan-EU countries (Amazon DE, IT, FR, ES) into a single "Pan-EU" customer
|
|
* when no customer filter is applied. This provides a consolidated view of European
|
|
* markets while keeping UK and SC separate.
|
|
*
|
|
* @param data - Array of sales records
|
|
* @param hasCustomerFilter - Whether a customer filter is currently applied
|
|
* @returns Processed data with Pan-EU grouping applied if appropriate
|
|
*/
|
|
export const applyPanEUGrouping = (
|
|
data: SalesRecord[],
|
|
hasCustomerFilter: boolean
|
|
): SalesRecord[] => {
|
|
// If customer filter is applied, don't group - show selected countries as-is
|
|
if (hasCustomerFilter) {
|
|
return data;
|
|
}
|
|
|
|
// Define Pan-EU countries
|
|
const PAN_EU_COUNTRIES = ['Amazon DE', 'Amazon IT', 'Amazon FR', 'Amazon ES'];
|
|
|
|
// Replace Pan-EU country names with "Pan-EU" for grouping
|
|
return data.map(record => {
|
|
if (PAN_EU_COUNTRIES.includes(record.customer)) {
|
|
return { ...record, customer: 'Pan-EU' };
|
|
}
|
|
return record;
|
|
});
|
|
};
|
|
|
|
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: any[], 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
|
|
// Use a fallback for 'customer' dimension as some records use 'marketplace'
|
|
const keyParts = dimensions.map(dim => {
|
|
if (dim === 'customer') return String(record.customer || record.marketplace || '');
|
|
return String(record[dim] || '');
|
|
});
|
|
const key = keyParts.join('||');
|
|
|
|
if (!map.has(key)) {
|
|
map.set(key, {
|
|
id: key,
|
|
customer: dimensions.includes('customer') ? (record.customer || record.marketplace || '') : '',
|
|
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: {},
|
|
adsByYear: {}
|
|
});
|
|
}
|
|
|
|
const row = map.get(key)!;
|
|
const monthRaw = record.month || '';
|
|
const monthPart = monthRaw.split('-')[0]; // Handle "Apr-23" -> "Apr"
|
|
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 || record.salesTotal || 0);
|
|
row.totalsByYear[yearStr].units += (record.units || record.unitsTotal || 0);
|
|
|
|
// 2. Update Ads Data (if present in the record)
|
|
if (record.cost !== undefined || record.salesAds !== undefined) {
|
|
if (!row.adsByYear) row.adsByYear = {};
|
|
if (!row.adsByYear[yearStr]) {
|
|
row.adsByYear[yearStr] = { adSpend: 0, attributedSales: 0, acos: 0, tacos: 0 };
|
|
}
|
|
row.adsByYear[yearStr].adSpend += (record.cost || 0);
|
|
row.adsByYear[yearStr].attributedSales += (record.salesAds || 0);
|
|
|
|
// Recalculate ACOS/TACOS at the aggregated level
|
|
const ads = row.adsByYear[yearStr];
|
|
const sales = row.totalsByYear[yearStr].sellOut;
|
|
ads.acos = ads.attributedSales > 0 ? (ads.adSpend / ads.attributedSales) * 100 : 0;
|
|
ads.tacos = sales > 0 ? (ads.adSpend / sales) * 100 : 0;
|
|
}
|
|
|
|
// 3. 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 || record.salesTotal || 0);
|
|
m.byYear[yearStr].units += (record.units || record.unitsTotal || 0);
|
|
}
|
|
});
|
|
|
|
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);
|
|
};
|
|
export interface WeeklyPivotRow {
|
|
id: string;
|
|
sku: string;
|
|
title: string;
|
|
asin: string;
|
|
line: string;
|
|
customer: string;
|
|
unitsByWeek: { [weekKey: string]: number }; // Key: "YYYY-WW"
|
|
}
|
|
|
|
export const pivotWeeklySalesData = (data: CombinedKPIs[]): {
|
|
rows: WeeklyPivotRow[],
|
|
weeks: string[]
|
|
} => {
|
|
// 1. Identify all unique weeks and sort descending (YYYY-WW)
|
|
const weekKeys = new Set<string>();
|
|
data.forEach(d => {
|
|
if (d.week) {
|
|
const weekKey = `${d.year}-${String(d.week).padStart(2, '0')}`;
|
|
weekKeys.add(weekKey);
|
|
}
|
|
});
|
|
const sortedWeeks = Array.from(weekKeys).sort((a, b) => b.localeCompare(a));
|
|
|
|
const map = new Map<string, WeeklyPivotRow>();
|
|
|
|
data.forEach(record => {
|
|
const key = record.sku || record.asin || `${record.title}-${record.line}`;
|
|
if (!key) return;
|
|
|
|
if (!map.has(key)) {
|
|
map.set(key, {
|
|
id: key,
|
|
sku: record.sku || '',
|
|
title: record.title || '',
|
|
asin: record.asin || '',
|
|
line: record.line || '',
|
|
customer: record.customer || record.marketplace || '',
|
|
unitsByWeek: {}
|
|
});
|
|
}
|
|
|
|
const row = map.get(key)!;
|
|
if (record.week) {
|
|
const weekKey = `${record.year}-${String(record.week).padStart(2, '0')}`;
|
|
row.unitsByWeek[weekKey] = (row.unitsByWeek[weekKey] || 0) + record.unitsTotal;
|
|
}
|
|
});
|
|
|
|
return {
|
|
rows: Array.from(map.values()),
|
|
weeks: sortedWeeks
|
|
};
|
|
};
|