Files
CrazeAnalytix/services/dataProcessor.ts
T

1795 lines
70 KiB
TypeScript
Raw Normal View History

import { SalesRecord, AdsRecord, TrafficRecord, CombinedKPIs, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint, ForecastRecord, MonthlyForecastPoint, ProductForecastData } from '../types';
import * as XLSX from 'xlsx';
2025-12-11 14:03:33 +01:00
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'
2025-12-11 14:03:33 +01:00
};
// 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"
2025-12-11 14:03:33 +01:00
// If it's a full date string like "2023-04-01" or "01/04/2023"
if (m.includes('/') || m.includes('-')) {
// Try parsing standard date
2025-12-11 14:03:33 +01:00
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
2025-12-11 14:03:33 +01:00
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}`;
2025-12-11 14:03:33 +01:00
}
}
2025-12-11 14:03:33 +01:00
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);
};
2025-12-11 14:03:33 +01:00
// --- 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']);
2025-12-11 14:03:33 +01:00
// 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);
2025-12-11 14:03:33 +01:00
// 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, [
2025-12-11 14:03:33 +01:00
'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);
2025-12-11 14:03:33 +01:00
})
// 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;
}
}
2025-12-11 14:03:33 +01:00
// --- 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';
2025-12-11 14:03:33 +01:00
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;
2025-12-11 14:03:33 +01:00
for (let i = 0; i < len; i++) {
const row = rows[i];
if (!Array.isArray(row) || row.length < 12) continue;
2025-12-11 14:03:33 +01:00
// 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;
2025-12-11 14:03:33 +01:00
// 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)
2025-12-11 14:03:33 +01:00
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)
});
2025-12-11 14:03:33 +01:00
});
};
export const processAdsExcel = async (fileOrBuffer: File | ArrayBuffer): Promise<AdsRecord[]> => {
2025-12-11 14:03:33 +01:00
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: "" });
2025-12-11 14:03:33 +01:00
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;
2025-12-11 14:03:33 +01:00
} catch (error) {
console.error("Error processing Ads Excel:", error);
throw error;
}
};
// --- TRAFFIC DATA PARSING ---
export const processTrafficExcel = async (fileOrBuffer: File | ArrayBuffer): Promise<TrafficRecord[]> => {
try {
const arrayBuffer = fileOrBuffer instanceof File
? await fileOrBuffer.arrayBuffer()
: fileOrBuffer;
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
const allData: TrafficRecord[] = [];
// Process first sheet only (Traffic Weekly.xlsx typically has one sheet)
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: "" });
console.log(`Processing Traffic sheet "${sheetName}": ${jsonData.length} rows`);
// Column mapping for Traffic Weekly.xlsx:
// A (0): Year
// B (1): Week
// C (2): ASIN
// F (5): Country
// G (6): Glance Views (GV)
// Skip header row (index 0), process data rows
for (let i = 1; i < jsonData.length; i++) {
const row = jsonData[i];
if (!row || row.length < 7) continue;
const yearRaw = row[0];
const weekRaw = row[1];
const asin = row[2];
const countryRaw = row[5];
const gvRaw = row[6];
// Skip if missing essential data
if (!asin || yearRaw === undefined || weekRaw === undefined || !countryRaw) continue;
const year = parseInt(String(yearRaw));
const weekNum = parseInt(String(weekRaw));
if (isNaN(year) || year < 2020 || year > 2100) continue;
if (isNaN(weekNum) || weekNum < 1 || weekNum > 53) continue;
allData.push({
country: mapCountryToMarketplace(String(countryRaw)),
year,
week: weekNum,
asin: String(asin).trim().toUpperCase(),
glanceViews: parseUnits(String(gvRaw)),
});
}
console.log(`Total Traffic records loaded: ${allData.length}`);
return allData;
} catch (error) {
console.error("Error processing Traffic Excel:", error);
throw error;
}
};
2025-12-11 14:03:33 +01:00
// --- DATA MERGING ---
export const mergeSalesAndAdsData = (
salesData: SalesRecord[],
adsData: AdsRecord[],
asinMetadataMap?: Map<string, { sku: string; title: string; line: string }>,
trafficData?: TrafficRecord[]
): CombinedKPIs[] => {
// Key for both sales and ads: ASIN|Customer|Year|Week
const createKey = (asin: string, customer: string, year: number, week: number) =>
`${asin.trim().toUpperCase()}|${customer.trim().toUpperCase()}|${year}|${week}`;
// Build traffic lookup map - Use a more efficient key
const trafficMap = new Map<string, number>();
if (trafficData) {
for (let i = 0; i < trafficData.length; i++) {
const t = trafficData[i];
const key = `${t.asin.trim().toUpperCase()}|${t.country.trim().toUpperCase()}|${t.year}|${t.week}`;
trafficMap.set(key, (trafficMap.get(key) || 0) + (t.glanceViews || 0));
}
}
// 1. Initialize metadata lookup map with provided global map if available, otherwise build from current sales
const asinMetadata = asinMetadataMap || new Map<string, { sku: string; title: string; line: string }>();
// 2. Aggregate Sales by ASIN|Customer|Year|Week (combine all SKUs)
const salesMap = new Map<string, {
sellOut: number;
units: number;
sku: string;
title: string;
line: string;
asin: string;
customer: string;
year: number;
week: number;
month: string;
}>();
for (let i = 0; i < salesData.length; i++) {
const sale = salesData[i];
const weekNum = sale.week || 0;
if (weekNum === 0) continue;
const asinUpper = sale.asin.trim().toUpperCase();
const key = `${asinUpper}|${sale.customer.trim().toUpperCase()}|${sale.year}|${weekNum}`;
// If no global map provided, build it on the fly
if (!asinMetadataMap) {
const existingMeta = asinMetadata.get(asinUpper);
if (!existingMeta || (sale.title && sale.title.length > (existingMeta.title?.length || 0))) {
asinMetadata.set(asinUpper, { sku: sale.sku, title: sale.title, line: sale.line });
}
}
const existing = salesMap.get(key);
if (existing) {
existing.sellOut += sale.sellOut;
existing.units += sale.units;
if (sale.title && sale.title.length > (existing.title?.length || 0)) {
existing.title = sale.title;
}
if (sale.sku && !existing.sku) {
existing.sku = sale.sku;
}
} else {
salesMap.set(key, {
sellOut: sale.sellOut,
units: sale.units,
sku: sale.sku,
title: sale.title,
line: sale.line,
asin: sale.asin,
customer: sale.customer,
year: sale.year,
week: weekNum,
month: sale.month
});
}
}
// 3. Aggregate Ads by ASIN|Customer|Year|Week
2025-12-11 14:03:33 +01:00
const adsMap = new Map<string, AdsRecord>();
for (let i = 0; i < adsData.length; i++) {
const ad = adsData[i];
const key = `${ad.asin.trim().toUpperCase()}|${ad.country.trim().toUpperCase()}|${ad.year}|${ad.week}`;
const existing = adsMap.get(key);
if (existing) {
2025-12-11 14:03:33 +01:00
existing.cost += ad.cost;
existing.clicks += ad.clicks;
existing.impressions += ad.impressions;
existing.attributedSales30d += ad.attributedSales30d;
existing.attributedUnits30d += ad.attributedUnits30d;
existing.conversions += ad.conversions;
2025-12-11 14:03:33 +01:00
} else {
adsMap.set(key, { ...ad });
}
}
2025-12-11 14:03:33 +01:00
const mergedData: CombinedKPIs[] = [];
const processedKeys = new Set<string>();
// 4. Create ONE record per ASIN/Customer/Year/Week from sales
salesMap.forEach((sale, key) => {
processedKeys.add(key);
const ad = adsMap.get(key);
const adCost = ad?.cost || 0;
const adClicks = ad?.clicks || 0;
const adImpressions = ad?.impressions || 0;
const adSales = ad?.attributedSales30d || 0;
const adUnits = ad?.attributedUnits30d || 0;
2025-12-11 14:03:33 +01:00
const salesTotal = sale.sellOut;
const unitsTotal = sale.units;
const salesOrganic = Math.max(0, salesTotal - adSales);
const unitsOrganic = Math.max(0, unitsTotal - adUnits);
2025-12-11 14:03:33 +01:00
const acos = adSales > 0 ? (adCost / adSales) * 100 : 0;
const tacos = salesTotal > 0 ? (adCost / salesTotal) * 100 : 0;
const roas = adCost > 0 ? adSales / adCost : 0;
const ctr = adImpressions > 0 ? (adClicks / adImpressions) * 100 : 0;
const cpc = adClicks > 0 ? adCost / adClicks : 0;
const cvrUnits = adClicks > 0 ? (adUnits / adClicks) * 100 : 0;
mergedData.push({
id: `merged-${key}`,
2025-12-11 14:03:33 +01:00
marketplace: sale.customer,
customer: sale.customer,
2025-12-11 14:03:33 +01:00
month: sale.month,
week: sale.week,
2025-12-11 14:03:33 +01:00
year: sale.year,
asin: sale.asin.trim().toUpperCase(),
2025-12-11 14:03:33 +01:00
title: sale.title,
line: sale.line,
sku: sale.sku,
salesTotal,
unitsTotal,
salesAds: adSales,
unitsAds: adUnits,
cost: adCost,
clicks: adClicks,
impressions: adImpressions,
2026-01-22 13:25:06 +01:00
conversions: ad?.conversions || 0,
2025-12-11 14:03:33 +01:00
salesOrganic,
unitsOrganic,
paidSalesShare: salesTotal > 0 ? (adSales / salesTotal) * 100 : 0,
organicSalesShare: salesTotal > 0 ? (salesOrganic / salesTotal) * 100 : 0,
2025-12-11 14:03:33 +01:00
acos,
tacos,
roas,
ctr,
cpc,
cvrUnits,
glanceViews: trafficMap.get(key) || 0
});
2025-12-11 14:03:33 +01:00
});
// 5. Add ads-only records - use provided metadata for line/title
adsMap.forEach((ad, key) => {
if (!processedKeys.has(key)) {
const asin = ad.asin.trim().toUpperCase();
const meta = asinMetadata.get(asin);
mergedData.push({
id: `ads-only-${key}`,
marketplace: ad.country,
customer: ad.country,
month: 'N/A',
week: ad.week,
year: ad.year,
asin: asin,
title: meta?.title || ad.asin,
line: meta?.line || 'Unassigned',
sku: meta?.sku || '',
salesTotal: 0,
unitsTotal: 0,
salesAds: ad.attributedSales30d,
unitsAds: ad.attributedUnits30d,
cost: ad.cost,
clicks: ad.clicks,
impressions: ad.impressions,
2026-01-22 13:25:06 +01:00
conversions: ad.conversions,
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,
glanceViews: trafficMap.get(key) || 0
});
}
});
2025-12-11 14:03:33 +01:00
return mergedData;
};
2025-12-11 14:03:33 +01:00
// --- EXISTING HELPERS ---
// Helper to check stock filter
const checkStockFilter = (sku: string, filters: string[], stockMap?: Map<string, number>): boolean => {
if (!filters || !Array.isArray(filters) || filters.length === 0) return true;
if (!stockMap) return true;
// Normalize SKU (remove DE/EN) to match stock map
const baseSku = sku?.replace(/(DE|EN)$/i, '');
const stockValue = stockMap.get(baseSku) || 0;
return filters.some(f => {
// Smart Filters
if (f === 'Out of Stock (0)') return stockValue === 0;
if (f === 'In Stock (>0)') return stockValue > 0;
if (f === 'Low Stock (<10)') return stockValue < 10;
// Numeric Range (e.g. ">10", "1-50")
const input = f.trim().toLowerCase();
if (input.includes('-')) {
const [start, end] = input.split('-').map(s => parseFloat(s.trim()));
if (!isNaN(start) && !isNaN(end)) return stockValue >= start && stockValue <= end;
} else if (input.startsWith('<=')) {
const val = parseFloat(input.substring(2).trim());
if (!isNaN(val)) return stockValue <= val;
} else if (input.startsWith('>=')) {
const val = parseFloat(input.substring(2).trim());
if (!isNaN(val)) return stockValue >= val;
} else if (input.startsWith('<')) {
const val = parseFloat(input.substring(1).trim());
if (!isNaN(val)) return stockValue < val;
} else if (input.startsWith('>')) {
const val = parseFloat(input.substring(1).trim());
if (!isNaN(val)) return stockValue > val;
}
// Exact Match
return stockValue.toString() === f;
});
};
2026-01-22 14:13:16 +01:00
// Filter Ads Data by Country, Year, Week, ASIN, SKU, and Product Line
export const filterAdsData = (
adsData: AdsRecord[],
filters: FilterState,
asinMetadata?: Map<string, { sku: string; line: string }>,
stockMap?: Map<string, number>
): AdsRecord[] => {
return adsData.filter(ad => {
2026-01-22 14:13:16 +01:00
const asin = ad.asin.trim().toUpperCase();
const meta = asinMetadata?.get(asin);
// 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 ||
2026-01-22 14:13:16 +01:00
filters.asin.some(a => a.toUpperCase() === asin);
// SKU match (Requires metadata)
let skuMatch = true;
if (filters.sku.length > 0) {
skuMatch = meta ? filters.sku.some(s => s.toUpperCase() === meta.sku.toUpperCase()) : false;
}
// Line match (Requires metadata)
let lineMatch = true;
2026-01-22 14:13:16 +01:00
if (filters.line.length > 0) {
lineMatch = meta ? filters.line.includes(meta.line) : false;
}
// Stock match
const stockMatch = checkStockFilter(meta?.sku || '', filters.stock, stockMap);
return countryMatch && yearMatch && weekMatch && asinMatch && skuMatch && lineMatch && stockMatch;
});
};
export const filterData = (data: SalesRecord[], filters: FilterState, stockMap?: Map<string, number>): 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));
// Stock match
const stockMatch = checkStockFilter(item.sku, filters.stock, stockMap);
return customerMatch && yearMatch && monthMatch && lineMatch && asinMatch && skuMatch && titleMatch && weekMatch && stockMatch;
});
};
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
};
};
2026-01-20 12:59:35 +01:00
/**
* 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 generateXLSX = (rows: PivotRow[], dimensions: string[], years: string[]) => {
// Flatten PivotRows into Excel-friendly objects
const flatData = rows.map(row => {
const flatRow: any = {};
// Add Dimension Columns
dimensions.forEach(dim => {
let header = dim;
if (dim === 'line') header = 'Product Line';
if (dim === 'title') header = 'Title';
if (dim === 'customer') header = 'Customer';
if (dim === 'sku' || dim === 'SKU') header = 'SKU';
if (dim === 'asin' || dim === 'ASIN') header = 'ASIN';
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;
});
const ws = XLSX.utils.json_to_sheet(flatData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Business Data');
XLSX.writeFile(wb, `Business_Data_Export_${new Date().toISOString().slice(0, 10)}.xlsx`);
};
export const generateItemMoversXLSX = (
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,
[`Sell Out ${periods.current}`]: item.currentYearSellOut,
'SO Diff': item.sellOutGrowthValue,
'SO Growth %': Number(item.sellOutGrowthPercentage.toFixed(2)),
[`Units ${periods.previous}`]: item.previousYearUnits,
[`Units ${periods.current}`]: item.currentYearUnits,
'Units Diff': item.unitsGrowthValue,
'Units Growth %': Number(item.unitsGrowthPercentage.toFixed(2)),
}));
const ws = XLSX.utils.json_to_sheet(flatData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, type);
XLSX.writeFile(wb, `${type}_${periods.current}_vs_${periods.previous}_${new Date().toISOString().split('T')[0]}.xlsx`);
};
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 };
// Support both SalesRecord (sellOut/units) and CombinedKPIs (salesTotal/unitsTotal)
current.sellOut += (record as any).sellOut ?? (record as any).salesTotal ?? 0;
current.units += (record as any).units ?? (record as any).unitsTotal ?? 0;
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`;
// Support both SalesRecord (sellOut/units) and CombinedKPIs (salesTotal/unitsTotal)
const sellOut = (record as any).sellOut ?? (record as any).salesTotal ?? 0;
const units = (record as any).units ?? (record as any).unitsTotal ?? 0;
weekData[sellOutKey] = (weekData[sellOutKey] || 0) + sellOut;
weekData[unitsKey] = (weekData[unitsKey] || 0) + 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);
2026-01-21 13:19:13 +01:00
};
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"
gvByWeek: { [weekKey: string]: number }; // Key: "YYYY-WW"
2026-01-21 13:19:13 +01:00
}
export const pivotWeeklySalesData = (data: CombinedKPIs[]): {
rows: WeeklyPivotRow[],
weeks: string[]
2026-01-21 13:19:13 +01:00
} => {
const weekKeysSet = new Set<string>();
2026-01-21 13:19:13 +01:00
const map = new Map<string, WeeklyPivotRow>();
// Cache week keys to avoid repeated string formatting
// Key: year|week, Value: YYYY-WW
const weekCache = new Map<string, string>();
const getWeekKey = (year: number, week: number) => {
const cacheKey = `${year}|${week}`;
let k = weekCache.get(cacheKey);
if (!k) {
k = `${year}-${String(week).padStart(2, '0')}`;
weekCache.set(cacheKey, k);
}
return k;
};
const len = data.length;
for (let i = 0; i < len; i++) {
const record = data[i];
if (!record.week) continue;
const weekKey = getWeekKey(record.year, record.week);
weekKeysSet.add(weekKey);
const key = record.asin || record.sku || `${record.title}-${record.line}`;
if (!key) continue;
2026-01-21 13:19:13 +01:00
let row = map.get(key);
if (!row) {
row = {
2026-01-21 13:19:13 +01:00
id: key,
sku: record.sku || '',
title: record.title || '',
asin: record.asin || '',
line: record.line || '',
customer: record.customer || record.marketplace || '',
unitsByWeek: {},
spendByWeek: {},
gvByWeek: {}
};
map.set(key, row);
2026-01-21 13:19:13 +01:00
}
row.unitsByWeek[weekKey] = (row.unitsByWeek[weekKey] || 0) + (record.unitsTotal || 0);
row.spendByWeek[weekKey] = (row.spendByWeek[weekKey] || 0) + (record.cost || 0);
row.gvByWeek[weekKey] = (row.gvByWeek[weekKey] || 0) + (record.glanceViews || 0);
}
2026-01-21 13:19:13 +01:00
const sortedWeeks = Array.from(weekKeysSet).sort((a, b) => b.localeCompare(a));
2026-01-21 13:19:13 +01:00
return {
rows: Array.from(map.values()),
weeks: sortedWeeks
};
};
export const processForecastExcel = async (fileOrBuffer: File | ArrayBuffer): Promise<ForecastRecord[]> => {
try {
const arrayBuffer = fileOrBuffer instanceof File
? await fileOrBuffer.arrayBuffer()
: fileOrBuffer;
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData: any[] = XLSX.utils.sheet_to_json(worksheet, { defval: "" });
return jsonData.map(row => ({
asin: String(row['ASIN'] || row['asin'] || '').trim().toUpperCase(),
annualForecast: parseUnits(String(row['Forecast 2026'] || row['forecast 2026'] || '0')),
sku: row['SKU'] || row['sku'] || undefined,
title: row['Title'] || row['title'] || row['Article Name'] || undefined,
line: row['Product Line'] || row['line'] || row['ProductLine'] || undefined
})).filter(r => r.asin && r.annualForecast > 0);
} catch (error) {
console.error("Error processing Forecast Excel:", error);
throw error;
}
};
export const calculateForecastViewData = (
rawData: SalesRecord[],
forecastData: ForecastRecord[],
asinMetadata: Map<string, { sku: string; title: string; line: string }>,
filters?: FilterState
): ProductForecastData[] => {
const data2025 = rawData.filter(r => r.year === 2025);
const data2026 = rawData.filter(r => r.year === 2026);
// Calculate Seasonality weights for 2025
const getWeights = (records: SalesRecord[]) => {
const weights = new Array(12).fill(0);
let total = 0;
records.forEach(r => {
const m = r.month.split('-')[0];
const idx = MONTH_ORDER.indexOf(m);
if (idx !== -1) {
weights[idx] += r.units;
total += r.units;
}
});
if (total === 0) return new Array(12).fill(1 / 12);
return weights.map(w => w / total);
};
// 1. Determine Global/Default Weights
const panEuData2025 = data2025.filter(r => PAN_EU_COUNTRIES.includes(r.customer));
const panEuWeights = getWeights(panEuData2025);
// Check if we are in UK-only mode
const isUkOnly = filters?.customer?.includes('Amazon UK') && filters.customer.length === 1;
const getHybridWeights = (paEuRecords: SalesRecord[], ukRecords: SalesRecord[]) => {
const peWeights = getWeights(paEuRecords);
const ukWeights = getWeights(ukRecords);
// Blend: Jan-Aug from Pan-EU, Sep-Dec from UK
const hybrid = new Array(12).fill(0);
const hasUkHistory = ukRecords.length > 0;
for (let i = 0; i < 12; i++) {
if (i < 8) { // Jan-Aug
hybrid[i] = peWeights[i];
} else { // Sep-Dec
hybrid[i] = hasUkHistory ? ukWeights[i] : peWeights[i];
}
}
// Normalize
const sum = hybrid.reduce((a, b) => a + b, 0);
return sum > 0 ? hybrid.map(w => w / sum) : peWeights;
};
const globalWeights = isUkOnly
? getHybridWeights(panEuData2025, data2025.filter(r => r.customer === 'Amazon UK'))
: panEuWeights;
// Map 2025 data by ASIN for quick access
const dataByAsin2025 = new Map<string, SalesRecord[]>();
data2025.forEach(r => {
const key = r.asin.trim().toUpperCase();
if (!dataByAsin2025.has(key)) dataByAsin2025.set(key, []);
dataByAsin2025.get(key)!.push(r);
});
// Map 2026 actual sales by ASIN and Month
const actuals2026 = new Map<string, Map<string, number>>();
data2026.forEach(r => {
const key = r.asin.trim().toUpperCase();
const m = r.month.split('-')[0];
if (!actuals2026.has(key)) actuals2026.set(key, new Map());
const monthMap = actuals2026.get(key)!;
monthMap.set(m, (monthMap.get(m) || 0) + r.units);
});
return forecastData.map(fc => {
const identifier = fc.asin.toUpperCase();
const meta = asinMetadata.get(identifier);
// 2. Determine weights for this ASIN
const productRecords2025 = dataByAsin2025.get(identifier) || [];
let productWeights = globalWeights;
if (productRecords2025.length > 0) {
if (isUkOnly) {
const peProd = productRecords2025.filter(r => PAN_EU_COUNTRIES.includes(r.customer));
const ukProd = productRecords2025.filter(r => r.customer === 'Amazon UK');
productWeights = getHybridWeights(peProd.length > 0 ? peProd : panEuData2025, ukProd);
} else {
productWeights = getWeights(productRecords2025);
}
}
// 3. Build monthly points and aggregate
const monthlyData: Record<string, MonthlyForecastPoint> = {};
let totalActualUnits = 0;
let totalForecastUnits = 0;
MONTH_ORDER.forEach((m, idx) => {
const forecastUnits = Math.round(fc.annualForecast * productWeights[idx]);
const actualUnits = actuals2026.get(identifier)?.get(m) || 0;
monthlyData[m] = {
month: m,
forecastUnits,
actualUnits,
units: actualUnits // Added for chart compatibility
} as any;
totalActualUnits += actualUnits;
totalForecastUnits += forecastUnits;
});
const accuracy = totalForecastUnits > 0
? Math.min(100, Math.round((1 - Math.abs(totalActualUnits - totalForecastUnits) / totalForecastUnits) * 100))
: 0;
return {
asin: identifier,
sku: meta?.sku || fc.sku || identifier,
title: meta?.title || fc.title || identifier,
line: meta?.line || fc.line || "Unassigned",
annualForecast: fc.annualForecast,
actualUnits: totalActualUnits,
forecastUnits: totalForecastUnits,
accuracy: Math.max(0, accuracy),
monthlyData
};
});
};
export const processVendorStockExcel = async (fileOrBuffer: File | ArrayBuffer): Promise<Map<string, { eu: number; uk: number }>> => {
try {
const arrayBuffer = fileOrBuffer instanceof File
? await fileOrBuffer.arrayBuffer()
: fileOrBuffer;
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
const vendorStockMap = new Map<string, { eu: number; uk: number }>();
// Find header row (it contains "ASIN")
let headerRowIndex = -1;
for (let i = 0; i < Math.min(jsonData.length, 20); i++) {
if (jsonData[i] && jsonData[i].includes('ASIN')) {
headerRowIndex = i;
break;
}
}
if (headerRowIndex === -1) {
console.warn("Could not find header row in Vendor Stock Excel");
return vendorStockMap;
}
// Skip headers
// A (0): ASIN
// D (3): Marketplace (Country)
// P (15): Sellable on hands units
for (let i = headerRowIndex + 1; i < jsonData.length; i++) {
const row = jsonData[i];
if (!row || row.length < 16) continue;
const asin = String(row[0] || '').trim().toUpperCase();
const marketplace = String(row[3] || '').trim().toLowerCase();
const stockValue = parseUnits(String(row[15] || '0'));
if (!asin) continue;
if (!vendorStockMap.has(asin)) {
vendorStockMap.set(asin, { eu: 0, uk: 0 });
}
const current = vendorStockMap.get(asin)!;
// Map to UK or EU
if (marketplace.includes('uk') || marketplace.includes('kingdom') || marketplace === 'gb') {
current.uk += stockValue;
} else {
// Assume everything else is Pan-EU for now if it's not UK
current.eu += stockValue;
}
}
return vendorStockMap;
} catch (error) {
console.error("Error processing Vendor Stock Excel:", error);
throw error;
}
};
export const processStockExcel = async (fileOrBuffer: File | ArrayBuffer): Promise<Map<string, number>> => {
try {
const arrayBuffer = fileOrBuffer instanceof File
? await fileOrBuffer.arrayBuffer()
: fileOrBuffer;
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
const stockMap = new Map<string, number>();
// Skip headers (index 0)
for (let i = 1; i < jsonData.length; i++) {
const row = jsonData[i];
const rawSku = String(row[0] || '').trim();
if (!rawSku) continue;
// Normalize SKU: Remove trailing EN or DE
const normalizedSku = rawSku.replace(/(DE|EN)$/i, '');
// User requested Column I which is index 8 (After Assembly Orders GMBH)
const stockValue = Number(row[8] || 0);
if (!isNaN(stockValue)) {
const current = stockMap.get(normalizedSku) || 0;
stockMap.set(normalizedSku, current + stockValue);
}
}
return stockMap;
} catch (error) {
console.error("Error processing Stock Excel:", error);
throw error;
}
};