mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 16:05:23 +02:00
1. Changed weekTotals to use 'rows' instead of 'filteredRows' so totals always show complete sums regardless of search/growth filters 2. Added second pass in mergeSalesAndAdsData to include ads records for ASINs that have ad spend but no corresponding sales data
1359 lines
54 KiB
TypeScript
1359 lines
54 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
|
|
export const PAN_EU_COUNTRIES = ['Amazon DE', 'Amazon IT', 'Amazon FR', 'Amazon ES'];
|
|
const ALLOWED_CUSTOMERS = [...PAN_EU_COUNTRIES, '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
|
|
};
|
|
});
|
|
|
|
// 3. Include ads-only records (ASINs with ads but no sales)
|
|
const usedAdsKeys = new Set<string>();
|
|
salesData.forEach(sale => {
|
|
const weekNum = sale.week || 0;
|
|
const key = `${sale.asin.trim().toUpperCase()}|${sale.customer.trim().toUpperCase()}|${sale.year}|${weekNum}`;
|
|
usedAdsKeys.add(key);
|
|
});
|
|
|
|
adsData.forEach(ad => {
|
|
const key = `${ad.asin.trim().toUpperCase()}|${ad.country.trim().toUpperCase()}|${ad.year}|${ad.week}`;
|
|
if (!usedAdsKeys.has(key)) {
|
|
// Create a CombinedKPIs record for ads-only data
|
|
mergedData.push({
|
|
id: `ads-${key}`,
|
|
marketplace: ad.country,
|
|
customer: ad.country,
|
|
month: '',
|
|
week: ad.week,
|
|
year: ad.year,
|
|
asin: ad.asin,
|
|
title: '',
|
|
line: '',
|
|
sku: '',
|
|
salesTotal: 0,
|
|
unitsTotal: 0,
|
|
salesAds: ad.attributedSales30d,
|
|
unitsAds: ad.attributedUnits30d,
|
|
cost: ad.cost,
|
|
clicks: ad.clicks,
|
|
impressions: ad.impressions,
|
|
salesOrganic: 0,
|
|
unitsOrganic: 0,
|
|
paidSalesShare: 0,
|
|
organicSalesShare: 0,
|
|
acos: ad.attributedSales30d > 0 ? (ad.cost / ad.attributedSales30d) * 100 : 0,
|
|
tacos: 0,
|
|
roas: ad.cost > 0 ? ad.attributedSales30d / ad.cost : 0,
|
|
ctr: ad.impressions > 0 ? (ad.clicks / ad.impressions) * 100 : 0,
|
|
cpc: ad.clicks > 0 ? ad.cost / ad.clicks : 0,
|
|
cvrUnits: ad.clicks > 0 ? (ad.attributedUnits30d / ad.clicks) * 100 : 0
|
|
});
|
|
}
|
|
});
|
|
|
|
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
|
|
? PAN_EU_COUNTRIES.some(c => c.toUpperCase() === ad.country.toUpperCase())
|
|
: 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
|
|
? PAN_EU_COUNTRIES.includes(item.customer)
|
|
: 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;
|
|
}
|
|
|
|
// 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"
|
|
spendByWeek: { [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: {},
|
|
spendByWeek: {}
|
|
});
|
|
}
|
|
|
|
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;
|
|
row.spendByWeek[weekKey] = (row.spendByWeek[weekKey] || 0) + (record.cost || 0);
|
|
}
|
|
});
|
|
|
|
return {
|
|
rows: Array.from(map.values()),
|
|
weeks: sortedWeeks
|
|
};
|
|
};
|