Files
CrazeAnalytix/services/dataProcessor.ts
T
Christian Vidal WolfandClaude Sonnet 4.6 916c349126 fix(buybox): restrict sheet detection to BB_* sheets only
Inventory sheets like 'INV ITALY' were being incorrectly matched as
buybox sheets, adding all their ASINs as false BB lost entries.
Now only sheets starting with 'BB' are processed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:38:39 +01:00

2630 lines
110 KiB
TypeScript

import { SalesRecord, AdsRecord, TrafficRecord, CombinedKPIs, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint, ForecastRecord, MonthlyForecastPoint, ProductForecastData, VendorCSVRow, VendorDailyRow, BSRRecord } from '../types';
import * as XLSX from 'xlsx';
import Papa from 'papaparse';
/**
* Parses Vendor CSV and returns mapped VendorDailyRow items.
*/
export const processVendorCSV = (fileOrContent: File | string): Promise<VendorDailyRow[]> => {
return new Promise((resolve, reject) => {
const config = {
header: true,
skipEmptyLines: true,
complete: (results: any) => {
const rows = results.data
.filter((row: any) => row['Date'] && row['Market'] && row['ASIN'])
.map((row: any) => ({
date: row['Date'],
market: row['Market'],
asin: row['ASIN'],
product_title: row['Product Title'] || null,
tags: row['Tags'] || null,
bsr_top_rank: parseIntSafe(row['Top Level Category (Rank)']),
bsr_top_category: row['Top Level Category (Name)'] || null,
bsr_detail_rank: parseIntSafe(row['Detail Level Category (Rank)']),
bsr_detail_category: row['Detail Level Category (Name)'] || null,
avg_rating: parseCurrency(row['Average Rating']), // Uses existing parseCurrency which handles EU/US
num_reviews: parseIntSafe(row['Number of Reviews']),
buybox_owner: row['Buybox Seller Name'] || null,
buybox_price: parseCurrency(row['Buybox Price']),
amazon_has_buybox: row['Amazon Has Buybox'] === '1',
glance_views: parseIntSafe(row['Glance Views']),
}));
resolve(rows);
},
error: (error: any) => {
reject(error);
}
};
if (typeof fileOrContent === 'string') {
Papa.parse(fileOrContent, config);
} else {
Papa.parse(fileOrContent, config);
}
});
};
function parseIntSafe(val: string | undefined | null): number | null {
if (!val || typeof val !== 'string' || val.trim() === '') return null;
const cleaned = val.replace(/\./g, '').replace(',', '.').replace(/[^0-9.]/g, '');
const num = parseInt(cleaned, 10);
return isNaN(num) ? null : num;
}
// 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(',', '.');
const num = parseFloat(clean);
return isNaN(num) ? 0 : num;
}
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, '');
}
const num = parseFloat(clean);
return isNaN(num) ? 0 : num;
}
// 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'];
// Helper for numeric filtering (e.g. ">5", "10-20")
export const checkNumericConditions = (value: number, filters: string[]): boolean => {
if (!filters || filters.length === 0) return true;
return filters.some(f => {
// Handle specific string labels
if (f.includes('Out of Stock') || f === 'Out of Stock') return value === 0;
if (f.includes('In Stock') && !f.includes('Low')) return value > 0;
if (f.includes('Low Stock')) return value < 10;
if (f === '< 4 Weeks') return value < 4;
if (f === '> 4 Weeks') return value >= 4;
if (f === 'Infinite Cover') return value === 999;
const input = f.trim().toLowerCase();
// Range: 10-20
if (input.includes('-') && !input.startsWith('-')) { // Avoid negative numbers confusion if possible, though simple range usually 10-20
const parts = input.split('-').map(s => parseFloat(s.trim()));
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
return value >= parts[0] && value <= parts[1];
}
}
// Expressions
if (input.startsWith('<=')) {
const val = parseFloat(input.substring(2));
return !isNaN(val) && value <= val;
}
if (input.startsWith('>=')) {
const val = parseFloat(input.substring(2));
return !isNaN(val) && value >= val;
}
if (input.startsWith('<')) {
const val = parseFloat(input.substring(1));
return !isNaN(val) && value < val;
}
if (input.startsWith('>')) {
const val = parseFloat(input.substring(1));
return !isNaN(val) && value > val;
}
// Exact Match
const val = parseFloat(input);
if (!isNaN(val)) return value === val;
return false;
});
};
// 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 monthCache: Record<string, string> = {};
const normalizeMonth = (rawMonth: string): string => {
if (!rawMonth) return '';
if (monthCache[rawMonth]) return monthCache[rawMonth];
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]) {
monthCache[rawMonth] = 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", "01/04/2023", or "23/2/26" (DD/M/YY)
if (m.includes('/') || m.includes('-')) {
// Handle date strings with 3 parts separated by '/' or '-'
const sep = m.includes('/') ? '/' : '-';
const parts = m.split(sep);
if (parts.length === 3) {
const [a, b, c] = parts.map(p => parseInt(p, 10));
if (!isNaN(a) && !isNaN(b) && !isNaN(c)) {
// YYYY-MM-DD or YYYY/MM/DD (ISO-like, year is 4 digits in first position)
if (a > 31 && b >= 1 && b <= 12) {
const yearShort = String(a).slice(2);
const result = `${MONTH_ORDER[b - 1]}-${yearShort}`;
monthCache[rawMonth] = result;
return result;
}
// DD/M/YY European format (e.g. "5/1/26"=5 Jan 2026, "23/2/26"=23 Feb 2026).
// When last part is a 2-digit year and middle part is a valid month, always
// treat as day-first (Spanish/EU convention). Covers ambiguous cases like
// "5/1/26" where day <= 12, avoiding JS Date's US MM/DD/YY misparse.
if (c < 100 && b >= 1 && b <= 12 && a >= 1 && a <= 31) {
const yearShort = String(c).padStart(2, '0');
const result = `${MONTH_ORDER[b - 1]}-${yearShort}`;
monthCache[rawMonth] = result;
return result;
}
}
}
// Try parsing standard date as fallback
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}`;
}
}
monthCache[rawMonth] = rawMonth;
return rawMonth; // Return as-is if all else fails
};
// Robust CSV Column Value Extractor
const getColumnValue = (row: any, aliases: (string | RegExp)[]): string => {
const rowKeys = Object.keys(row);
const normalizedRowKeys: Record<string, string> = {};
rowKeys.forEach(k => {
// Normalize by removing all non-alphanumeric characters for a bulletproof exact match
// e.g., "ACOS %" -> "acos", "Sales (30d)" -> "sales30d"
const cleanKey = k.toLowerCase().replace(/[^a-z0-9]/g, '');
normalizedRowKeys[cleanKey] = k;
});
for (const alias of aliases) {
if (alias instanceof RegExp) {
// Find the first original key that matches the regex
const matchedKey = rowKeys.find(k => alias.test(k));
if (matchedKey) {
const val = row[matchedKey];
if (val !== undefined && val !== null) {
const strVal = String(val).trim();
if (strVal.length > 0) return strVal;
}
}
} else {
const lookup = alias.toLowerCase().replace(/[^a-z0-9]/g, '');
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 ---
export const validateSellOutHeaders = (headers: string[]) => {
const normHeaders = headers.map(h => String(h).trim().toLowerCase());
// A date column (e.g. column named "C", "Date", "Fecha") can provide both year and month
const hasDateCol = ['c', 'date', 'fecha', 'data'].some(d => normHeaders.includes(d));
const hasYear = normHeaders.includes('year') || hasDateCol;
const hasTime = normHeaders.includes('month') || normHeaders.includes('week') || hasDateCol;
const hasCustomerRef = normHeaders.includes('customer reference') || normHeaders.includes('asin');
const hasEan = normHeaders.includes('ean');
const hasUnits = normHeaders.includes('units');
const hasAmount = normHeaders.includes('amount_eur') || normHeaders.includes('amount');
const missing = [];
if (!hasYear) missing.push('YEAR');
if (!hasTime) missing.push('MONTH or WEEK');
if (!hasCustomerRef) missing.push('CUSTOMER REFERENCE');
if (!hasEan) missing.push('EAN');
if (!hasUnits) missing.push('UNITS');
if (!hasAmount) missing.push('AMOUNT_EUR');
if (missing.length > 0) {
throw new Error(`Invalid or missing critical columns in Sell-Out Report. Missing: ${missing.join(', ')}`);
}
};
const mapRowToRecord = (row: any, index: number): SalesRecord => {
// Add exact matches to the front of getColumnValue arrays
const customer = getColumnValue(row, ['NEW CUSTOMER', 'COUNTRY', 'Customer', 'Client', 'Account', 'Partner', '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', 'C', 'Date', 'DATE', 'Fecha', 'FECHA', 'Data', 'DATA']);
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, ['PRODUCT LINE', 'Product Line', 'LINE', '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 (Customer)', 'ARTICLE NAME (Craze)', 'Title', 'TITLE', 'Product Title', 'Article Name']);
const articleName = getColumnValue(row, ['ARTICLE NAME (Craze)', 'ARTICLE NAME (Customer)', 'Article Name', 'ArticleName', 'Title']);
const unitsRaw = getColumnValue(row, ['UNITS', 'Units', 'Quantity', 'Qty']);
const sellOutRaw = getColumnValue(row, ['AMOUNT_EUR', '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 {
if (results.meta && results.meta.fields) {
validateSellOutHeaders(results.meta.fields);
} else if (results.data && results.data.length > 0) {
validateSellOutHeaders(Object.keys(results.data[0]));
}
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: "" });
if (jsonData.length > 0) {
validateSellOutHeaders(Object.keys(jsonData[0] as object));
}
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: true,
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 (!row || Object.keys(row).length < 5) continue;
const countryRaw = getColumnValue(row, ['country', 'marketplace', 'portfolio', 'customer', 'kunde']);
const weekRaw = getColumnValue(row, ['week', 'woche', 'semana']);
const asin = getColumnValue(row, ['asin']);
const costRaw = getColumnValue(row, ['cost', 'spend', 'ausgaben', 'gasto', 'coste', /ad\s*spend/i, /^cost$/i, /^spend$/i, /(?<!of\s)cost(?!\s*of)/i]);
const clicksRaw = getColumnValue(row, ['clicks', 'klicks', 'clics', /click/i]);
const impressionsRaw = getColumnValue(row, ['impressions', 'impresiones', 'imp', /impression/i]);
const cpcRaw = getColumnValue(row, ['cpc', 'cost-per-click', 'coste por clic', /cost.*per.*click/i, /cpc/i]);
const ctrRaw = getColumnValue(row, ['ctr', 'click-through rate', 'click-through-rate', 'click through rate', /click.*through.*rate/i, /ctr/i]);
const acosRaw = getColumnValue(row, ['acos', 'advertising cost of sales', 'aCOS', /cost.*of.*sales/i, /acos/i]);
const conversionsRaw = getColumnValue(row, ['conversions', 'konversionen', 'orders', 'pedidos', 'total orders', /order/i, /conversion/i]);
const unitsRaw = getColumnValue(row, ['units', 'einheiten', 'unidades', 'units sold', 'total units', /\d+\s*day.*unit/i, /unit.*within.*\d+\s*day/i, /total\s+unit/i, /units?\s*sold/i, /^units?$/i, /unit/i, /einheit/i, /unidad/i]);
const salesRaw = getColumnValue(row, ['sales', 'umsatz', 'ventas', 'ad sales', 'total sales', 'sales (30d)', /\d+\s*day.*sale/i, /sale.*within.*\d+\s*day/i, /total\s+sale/i, /attributed.*sale/i, /ad\s+sale/i, /^sales$/i, /(?<!cost of )sale/i, /umsatz/i, /ventas/i]);
if (!asin || !countryRaw || weekRaw === undefined || weekRaw === '') continue;
const weekMatch = String(weekRaw).match(/\d+/);
const weekNum = weekMatch ? parseInt(weekMatch[0], 10) : NaN;
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 default format (header mapping) instead of array rows
const jsonData: any[] = XLSX.utils.sheet_to_json(worksheet, { defval: "" });
console.log(`Processing sheet ${sheetName}: ${jsonData.length} rows`);
// Log column headers for first row to diagnose column matching
if (jsonData.length > 0) {
console.log(`[Ads Excel] Sheet "${sheetName}" columns: ${Object.keys(jsonData[0]).join(' | ')}`);
}
// No need to skip index 0 since headers are mapped as keys automatically
for (let i = 0; i < jsonData.length; i++) {
const row = jsonData[i];
if (!row || Object.keys(row).length < 5) continue;
const countryRaw = getColumnValue(row, ['country', 'marketplace', 'portfolio', 'customer', 'kunde']);
const weekRaw = getColumnValue(row, ['week', 'woche', 'semana']);
const asin = getColumnValue(row, ['asin']);
const costRaw = getColumnValue(row, ['cost', 'spend', 'ausgaben', 'gasto', 'coste', /ad\s*spend/i, /^cost$/i, /^spend$/i, /(?<!of\s)cost(?!\s*of)/i]);
const clicksRaw = getColumnValue(row, ['clicks', 'klicks', 'clics', /click/i]);
const impressionsRaw = getColumnValue(row, ['impressions', 'impresiones', 'imp', /impression/i]);
const cpcRaw = getColumnValue(row, ['cpc', 'cost-per-click', 'coste por clic', /cost.*per.*click/i, /cpc/i]);
const ctrRaw = getColumnValue(row, ['ctr', 'click-through rate', 'click-through-rate', 'click through rate', /click.*through.*rate/i, /ctr/i]);
const acosRaw = getColumnValue(row, ['acos', 'advertising cost of sales', 'aCOS', /cost.*of.*sales/i, /acos/i]);
const conversionsRaw = getColumnValue(row, ['conversions', 'konversionen', 'orders', 'pedidos', 'total orders', /order/i, /conversion/i]);
const unitsRaw = getColumnValue(row, ['units', 'einheiten', 'unidades', 'units sold', 'total units', /\d+\s*day.*unit/i, /unit.*within.*\d+\s*day/i, /total\s+unit/i, /units?\s*sold/i, /^units?$/i, /unit/i, /einheit/i, /unidad/i]);
const salesRaw = getColumnValue(row, ['sales', 'umsatz', 'ventas', 'ad sales', 'total sales', 'sales (30d)', /\d+\s*day.*sale/i, /sale.*within.*\d+\s*day/i, /total\s+sale/i, /attributed.*sale/i, /ad\s+sale/i, /^sales$/i, /(?<!cost of )sale/i, /umsatz/i, /ventas/i]);
// Skip if missing essential data
if (!asin || !countryRaw || weekRaw === undefined || weekRaw === '') continue;
const weekMatch = String(weekRaw).match(/\d+/);
const weekNum = weekMatch ? parseInt(weekMatch[0], 10) : NaN;
if (isNaN(weekNum) || weekNum < 1 || weekNum > 53) continue;
// Log first parsed row per sheet for column match diagnosis
if (allData.length === 0 || (allData.length > 0 && allData[allData.length - 1]?.year !== year)) {
console.log(`[Ads Excel] First row sheet ${year}: costRaw="${costRaw}" salesRaw="${salesRaw}" unitsRaw="${unitsRaw}" acosRaw="${acosRaw}"`);
}
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;
}
};
// --- 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 ALL sheets (e.g., "2025", "2026") - same pattern as processAdsExcel
for (const sheetName of workbook.SheetNames) {
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`);
// Detect if sheet name is a valid year (multi-sheet format)
const sheetYear = parseInt(sheetName);
const isYearSheet = !isNaN(sheetYear) && sheetYear >= 2020 && sheetYear <= 2100;
// Auto-detect column layout from header row
const headerRow = jsonData[0];
if (!headerRow) continue;
const headers = headerRow.map((h: any) => String(h).trim().toLowerCase());
let yearIdx = headers.findIndex((h: string) => h === 'year' || h === 'año');
let weekIdx = headers.findIndex((h: string) => h === 'week' || h === 'semana');
let asinIdx = headers.findIndex((h: string) => h === 'asin');
let countryIdx = headers.findIndex((h: string) => h === 'country' || h === 'país' || h === 'pais' || h === 'marketplace' || h === 'store code');
let gvIdx = headers.findIndex((h: string) => h.includes('glance') || h === 'gv' || h.includes('page view') || h === 'featured offer page views');
// Fallback to positional mapping if headers not found
if (asinIdx === -1 || countryIdx === -1 || gvIdx === -1) {
if (isYearSheet) {
// Year-based sheets: no year column
weekIdx = 0; asinIdx = 1; countryIdx = 4; gvIdx = 5; yearIdx = -1;
} else {
// Single sheet with year column
yearIdx = 0; weekIdx = 1; asinIdx = 2; countryIdx = 5; gvIdx = 6;
}
}
const minCols = Math.max(asinIdx, countryIdx, gvIdx) + 1;
for (let i = 1; i < jsonData.length; i++) {
const row = jsonData[i];
if (!row || row.length < minCols) continue;
const yearRaw = isYearSheet ? sheetYear : (yearIdx >= 0 ? row[yearIdx] : undefined);
const weekRaw = weekIdx >= 0 ? row[weekIdx] : undefined;
const asin = row[asinIdx];
const countryRaw = row[countryIdx];
const gvRaw = row[gvIdx];
if (!asin || yearRaw === undefined || weekRaw === undefined || !countryRaw) continue;
const year = typeof yearRaw === 'number' ? yearRaw : parseInt(String(yearRaw));
const weekMatch = String(weekRaw).match(/\d+/);
const weekNum = weekMatch ? parseInt(weekMatch[0], 10) : NaN;
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;
}
};
// --- BSR DATA PARSING ---
export const processBSRExcel = async (fileOrBuffer: File | ArrayBuffer): Promise<BSRRecord[]> => {
try {
const arrayBuffer = fileOrBuffer instanceof File
? await fileOrBuffer.arrayBuffer()
: fileOrBuffer;
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
const allData: BSRRecord[] = [];
// Process all sheets — markets may be split across tabs
for (const sheetName of workbook.SheetNames) {
const worksheet = workbook.Sheets[sheetName];
const jsonData: any[] = XLSX.utils.sheet_to_json(worksheet, { defval: "" });
if (jsonData.length === 0) continue;
console.log(`[BSR] Processing sheet "${sheetName}": ${jsonData.length} rows. Columns:`, Object.keys(jsonData[0]));
for (const row of jsonData) {
const marketRaw = getColumnValue(row, ['market', 'country', 'marketplace', 'Market', 'Country', 'Marketplace']);
const asinRaw = getColumnValue(row, ['asin', 'ASIN']);
if (!marketRaw || !asinRaw) continue;
// Week: try direct week column first, else derive from Date column
let week = 0;
let isoDate: string | undefined;
const weekRaw = getColumnValue(row, ['week', 'woche', 'semana', 'week number', 'weeknumber', 'Week', 'Week Number']);
if (weekRaw) {
week = parseInt(String(weekRaw).match(/\d+/)?.[0] || '0', 10);
}
const dateRaw = getColumnValue(row, ['date', 'fecha', 'datum', 'Date']);
if (dateRaw) {
// Handle Excel serial date numbers
let d: Date;
const dateNum = Number(dateRaw);
if (!isNaN(dateNum) && dateNum > 1000) {
// Excel serial date: days since 1899-12-30
d = new Date((dateNum - 25569) * 86400 * 1000);
} else {
d = new Date(dateRaw);
}
if (!isNaN(d.getTime())) {
isoDate = d.toISOString().slice(0, 10); // "YYYY-MM-DD"
if (!week) {
// ISO week number
const tmp = new Date(d);
tmp.setHours(0, 0, 0, 0);
tmp.setDate(tmp.getDate() + 3 - ((tmp.getDay() + 6) % 7));
const w1 = new Date(tmp.getFullYear(), 0, 4);
week = 1 + Math.round(((tmp.getTime() - w1.getTime()) / 86400000 - 3 + ((w1.getDay() + 6) % 7)) / 7);
}
}
}
if (!week) continue;
allData.push({
week,
date: isoDate,
market: String(marketRaw).trim(),
asin: String(asinRaw).trim(),
topLevelBSR: parseIntSafe(getColumnValue(row, [
'Mean Weekly Top Level BSR', 'Top Level Category (Rank)', 'Top Level BSR',
'TopLevelBSR', 'bsr top', 'BSR Top Level', 'top level bsr',
])),
topLevelName: getColumnValue(row, [
'Top Level Category Name', 'Top Level Category (Name)',
'TopLevelName', 'top category name', 'Top Category',
]) || null,
detailLevelBSR: parseIntSafe(getColumnValue(row, [
'Mean Weekly Detail Level BSR', 'Detail Level Category (Rank)', 'Detail Level BSR',
'DetailLevelBSR', 'bsr detail', 'BSR Detail Level', 'detail level bsr',
])),
detailLevelName: getColumnValue(row, [
'Detail Level Category Name', 'Detail Level Category (Name)',
'DetailLevelName', 'detail category name', 'Detail Category',
]) || null,
avgRating: parseFloat(getColumnValue(row, [
'Mean Weekly Average Rating', 'Average Rating', 'AvgRating',
'Rating', 'avg rating',
])) || null,
});
}
}
console.log(`[BSR] Total records loaded: ${allData.length}`);
return allData;
} catch (error) {
console.error("Error processing BSR Excel:", error);
throw error;
}
};
// --- DATA MERGING ---
export const mergeSalesAndAdsData = (
salesData: SalesRecord[],
adsData: AdsRecord[],
asinMetadataMap?: Map<string, { sku: string; title: string; line: string }>,
trafficData?: TrafficRecord[],
velocityMap?: Map<string, number>
): CombinedKPIs[] => {
const stringCache: Record<string, string> = {};
const getNorm = (s: string) => {
if (!s) return '';
if (stringCache[s]) return stringCache[s];
const v = s.trim().toUpperCase();
stringCache[s] = v;
return v;
};
// Key for both sales and ads: ASIN|Customer|Year|Week
const createKey = (asin: string, customer: string, year: number, week: number) =>
`${getNorm(asin)}|${getNorm(customer)}|${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 = `${getNorm(t.asin)}|${getNorm(t.country)}|${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 = getNorm(sale.asin);
const key = `${asinUpper}|${getNorm(sale.customer)}|${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
const adsMap = new Map<string, AdsRecord>();
for (let i = 0; i < adsData.length; i++) {
const ad = adsData[i];
const key = `${getNorm(ad.asin)}|${getNorm(ad.country)}|${ad.year}|${ad.week}`;
const existing = adsMap.get(key);
if (existing) {
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 });
}
}
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;
const salesTotal = sale.sellOut;
const unitsTotal = sale.units;
const salesOrganic = Math.max(0, salesTotal - adSales);
const unitsOrganic = Math.max(0, unitsTotal - adUnits);
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;
const avgWeeklySales = velocityMap?.get(sale.asin.trim().toUpperCase()) || 0;
mergedData.push({
id: `merged-${key}`,
marketplace: sale.customer,
customer: sale.customer,
month: sale.month,
week: sale.week,
year: sale.year,
asin: getNorm(sale.asin),
title: sale.title,
line: sale.line,
sku: sale.sku,
salesTotal,
unitsTotal,
salesAds: adSales,
unitsAds: adUnits,
cost: adCost,
clicks: adClicks,
impressions: adImpressions,
conversions: ad?.conversions || 0,
salesOrganic,
unitsOrganic,
paidSalesShare: salesTotal > 0 ? (adSales / salesTotal) * 100 : 0,
organicSalesShare: salesTotal > 0 ? (salesOrganic / salesTotal) * 100 : 0,
acos,
tacos,
roas,
ctr,
cpc,
cvrUnits,
glanceViews: trafficMap.get(key) || 0,
avgWeeklySales
});
});
// 5. Add ads-only records - use provided metadata for line/title
adsMap.forEach((ad, key) => {
if (!processedKeys.has(key)) {
const asin = getNorm(ad.asin);
const meta = asinMetadata.get(asin);
const avgWeeklySales = velocityMap?.get(asin) || 0;
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 || 0,
unitsAds: ad.attributedUnits30d || 0,
cost: ad.cost || 0,
clicks: ad.clicks || 0,
impressions: ad.impressions || 0,
conversions: ad.conversions || 0,
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,
avgWeeklySales
});
}
});
return mergedData;
};
// --- 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 checkNumericConditions(stockValue, filters);
};
const checkVendorStockFilter = (asin: string, filters: string[], vendorStockMap?: Map<string, { eu: number; uk: number }>, mode: 'eu' | 'uk' = 'eu'): boolean => {
if (!filters || !Array.isArray(filters) || filters.length === 0) return true;
if (!vendorStockMap) return true;
const data = vendorStockMap.get(asin);
const stockValue = data ? (mode === 'uk' ? data.uk : data.eu) : 0;
return checkNumericConditions(stockValue, filters);
};
// 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>,
vendorStockMap?: Map<string, { eu: number; uk: number }>,
top50Mode: 'eu' | 'uk' = 'eu'
): AdsRecord[] => {
return adsData.filter(ad => {
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 ||
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;
if (filters.line.length > 0) {
lineMatch = meta ? filters.line.includes(meta.line) : false;
}
// Stock match
const stockMatch = checkStockFilter(meta?.sku || '', filters.stock, stockMap);
const vendorStockMatch = checkVendorStockFilter(asin, filters.vendorStock, vendorStockMap, top50Mode);
// Bulk Search Logic
let bulkMatch = true;
if (filters.bulkSearch && filters.bulkSearch.trim()) {
const searchTerms = filters.bulkSearch
.split(/[\s,\n]+/)
.map(t => t.trim().toUpperCase())
.filter(t => t.length > 0);
if (searchTerms.length > 0) {
const itemAsin = (asin || '').toUpperCase();
const itemSku = (meta?.sku || '').toUpperCase();
bulkMatch = searchTerms.some(term =>
itemAsin.includes(term) || itemSku.includes(term)
);
}
}
return countryMatch && yearMatch && weekMatch && asinMatch && skuMatch && lineMatch && stockMatch && vendorStockMatch && bulkMatch;
});
};
export const filterData = (
data: SalesRecord[],
filters: FilterState,
stockMap?: Map<string, number>,
vendorStockMap?: Map<string, { eu: number; uk: number }>,
top50Mode: 'eu' | 'uk' = 'eu'
): SalesRecord[] => {
let result = data; // Changed from rawData to data
// 1. Column Filters (Excel-style)
if (filters.columnFilters) {
Object.entries(filters.columnFilters).forEach(([key, condition]) => {
if (!condition) return;
// Apply selected values filter
if (condition.selectedValues && condition.selectedValues.length > 0) {
result = result.filter(r => {
const val = String((r as any)[key] || '');
return condition.selectedValues?.includes(val);
});
}
// Apply text condition filter
if (condition.textFilter) {
const { operator, value } = condition.textFilter;
const lowerValue = value.toLowerCase();
result = result.filter(r => {
const rowVal = String((r as any)[key] || '').toLowerCase();
switch (operator) {
case 'equals': return rowVal === lowerValue;
case 'notEquals': return rowVal !== lowerValue;
case 'contains': return rowVal.includes(lowerValue);
case 'notContains': return !rowVal.includes(lowerValue);
case 'startsWith': return rowVal.startsWith(lowerValue);
case 'notStartsWith': return !rowVal.startsWith(lowerValue);
case 'endsWith': return rowVal.endsWith(lowerValue);
case 'notEndsWith': return !rowVal.endsWith(lowerValue);
default: return true;
}
});
}
});
}
// 2. Standard Filters
return result.filter(item => { // Apply remaining filters to the 'result'
// 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) || item.customer === 'Pan-EU')
: 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);
// Bulk Search Logic
let bulkMatch = true;
if (filters.bulkSearch && filters.bulkSearch.trim()) {
const searchTerms = filters.bulkSearch
.split(/[\s,\n]+/)
.map(t => t.trim().toUpperCase())
.filter(t => t.length > 0);
if (searchTerms.length > 0) {
const itemAsin = (item.asin || '').toUpperCase();
const itemSku = (item.sku || '').toUpperCase();
bulkMatch = searchTerms.some(term =>
itemAsin.includes(term) || itemSku.includes(term)
);
}
}
// 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);
const vendorStockMatch = checkVendorStockFilter(item.asin, filters.vendorStock, vendorStockMap, top50Mode);
return customerMatch && yearMatch && monthMatch && lineMatch && asinMatch && skuMatch && titleMatch && bulkMatch && weekMatch && stockMatch && vendorStockMatch;
});
};
export const filterBsrData = (
bsrData: BSRRecord[],
filters: FilterState,
asinMetadata?: Map<string, { sku: string; title: string; line: string }>,
stockMap?: Map<string, number>,
vendorStockMap?: Map<string, { eu: number; uk: number }>,
top50Mode: 'eu' | 'uk' = 'eu'
): BSRRecord[] => {
return bsrData.filter(record => {
const asin = record.asin.trim().toUpperCase();
const meta = asinMetadata?.get(asin);
const marketMapping: Record<string, string[]> = {
'DE': ['Amazon DE', 'Amazon SC'],
'UK': ['Amazon UK', 'Amazon SC'],
'IT': ['Amazon IT', 'Amazon SC'],
'FR': ['Amazon FR', 'Amazon SC'],
'ES': ['Amazon ES', 'Amazon SC'],
};
let countryMatch = true;
if (filters.customer.length > 0) {
const mappedCustomers = marketMapping[record.market] || [];
countryMatch = filters.customer.some(c => mappedCustomers.includes(c));
} else {
countryMatch = PAN_EU_COUNTRIES.some(c => c.toUpperCase().includes(record.market));
}
const weekStr = `W${record.week}`;
const weekMatch = filters.week.length === 0 || filters.week.includes(weekStr);
const asinMatch = filters.asin.length === 0 || filters.asin.some(a => a.toUpperCase() === asin);
let skuMatch = true;
if (filters.sku.length > 0) {
skuMatch = meta ? filters.sku.some(s => s.toUpperCase() === meta.sku.toUpperCase()) : false;
}
let lineMatch = true;
if (filters.line.length > 0) {
lineMatch = meta ? filters.line.includes(meta.line) : false;
}
let titleMatch = true;
if (filters.title.length > 0) {
titleMatch = meta ? filters.title.includes(meta.title) : false;
}
const stockMatch = checkStockFilter(meta?.sku || '', filters.stock, stockMap);
const vendorStockMatch = checkVendorStockFilter(asin, filters.vendorStock, vendorStockMap, top50Mode);
let bulkMatch = true;
if (filters.bulkSearch && filters.bulkSearch.trim()) {
const searchTerms = filters.bulkSearch
.split(/[\s,\n]+/)
.map(t => t.trim().toUpperCase())
.filter(t => t.length > 0);
if (searchTerms.length > 0) {
const itemAsin = (record.asin || '').toUpperCase();
const itemSku = (meta?.sku || '').toUpperCase();
bulkMatch = searchTerms.some(term =>
itemAsin.includes(term) || itemSku.includes(term)
);
}
}
return countryMatch && weekMatch && asinMatch && skuMatch && lineMatch && titleMatch && stockMatch && vendorStockMatch && bulkMatch;
});
};
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 = new Array(dimensions.length);
for (let i = 0; i < dimensions.length; i++) {
const dim = dimensions[i];
let val = '';
if (dim === 'customer') val = String(record.customer || record.marketplace || '');
else val = String(record[dim] || '');
// Normalize ASIN and SKU in keys to fold duplicates
if (dim === 'asin' || dim === 'sku' || dim === 'customer') {
keyParts[i] = val.trim().toUpperCase();
} else {
keyParts[i] = val;
}
}
const key = keyParts.join('||');
if (!map.has(key)) {
map.set(key, {
id: key,
customer: record.customer || record.marketplace || '',
line: record.line || '',
title: record.title || '',
articleName: record.articleName || '',
sku: record.sku || '',
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 = {};
// Always ensure ASIN and SKU are exported as foundational identifiers
flatRow['ASIN'] = row.asin || '-';
flatRow['SKU'] = row.sku || '-';
// Add User Selected Dimension Columns
dimensions.forEach(dim => {
if (dim.toLowerCase() === 'asin' || dim.toLowerCase() === 'sku') return; // Skip if already explicitly set
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;
});
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);
};
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" (Ads Cost)
revenueByWeek: { [weekKey: string]: number }; // Key: "YYYY-WW" (Sell-out)
gvByWeek: { [weekKey: string]: number }; // Key: "YYYY-WW"
}
export const pivotWeeklySalesData = (data: CombinedKPIs[]): {
rows: WeeklyPivotRow[],
weeks: string[]
} => {
const weekKeysSet = new Set<string>();
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 recordAsin = (record.asin || '').trim().toUpperCase();
const recordSku = (record.sku || '').trim().toUpperCase();
const key = recordAsin || recordSku || `${record.title}-${record.line}`;
if (!key) continue;
let row = map.get(key);
if (!row) {
row = {
id: key,
sku: record.sku || '',
title: record.title || '',
asin: record.asin || '',
line: record.line || '',
customer: record.customer || record.marketplace || '',
unitsByWeek: {},
spendByWeek: {},
revenueByWeek: {},
gvByWeek: {}
};
map.set(key, row);
}
row.unitsByWeek[weekKey] = (row.unitsByWeek[weekKey] || 0) + (record.unitsTotal || 0);
row.spendByWeek[weekKey] = (row.spendByWeek[weekKey] || 0) + (record.cost || 0);
row.revenueByWeek[weekKey] = (row.revenueByWeek[weekKey] || 0) + (record.salesTotal || 0);
row.gvByWeek[weekKey] = (row.gvByWeek[weekKey] || 0) + (record.glanceViews || 0);
}
const sortedWeeks = Array.from(weekKeysSet).sort((a, b) => b.localeCompare(a));
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,
velocityMap?: Map<string, number>,
referenceData?: SalesRecord[] // NEW: Full dataset for global seasonality context
): ProductForecastData[] => {
// Use referenceData if provided (for global weights), otherwise fallback to rawData
const seasonalitySource = referenceData || rawData;
const historicalData = seasonalitySource.filter(r => r.year < 2026);
// IMPORTANT: Actuals for 2026 must be strictly scoped to the forecast region
// to avoid mixing UK stats into Pan-EU or vice-versa.
const isForecastUKMode = filters?.customer?.includes('Amazon UK');
const data2026 = rawData.filter(r => {
if (r.year !== 2026) return false;
if (isForecastUKMode) {
return r.customer === 'Amazon UK';
} else {
// In Pan-EU mode, explicitly exclude UK units even if they are in the dataset
return r.customer !== 'Amazon UK';
}
});
// Calculate Seasonality weights for 2025
const getWeightsInfo = (records: SalesRecord[]): { weights: number[]; monthsCount: number } | null => {
const weights = new Array(12).fill(0);
let total = 0;
const seenMonths = new Set<string>();
records.forEach(r => {
const m = r.month.split('-')[0];
const idx = MONTH_ORDER.indexOf(m);
if (idx !== -1 && r.units > 0) {
weights[idx] += r.units;
total += r.units;
seenMonths.add(m);
}
});
// If ASIN has any 2025 sales, we trust its specific seasonality.
// Return null ONLY if there's no data at all for this ASIN in 2025.
if (total === 0) return null;
return {
weights: weights.map(w => w / total),
monthsCount: seenMonths.size
};
};
// 1. Determine Global/Default Weights
const panEuHistoricalData = historicalData.filter(r => PAN_EU_COUNTRIES.includes(r.customer));
// For Global Weights, we do NOT return null on sparse data (we accept whatever we have for the whole catalog)
// We recreate a simple version of getWeights that doesn't return null for the global set
const getGlobalWeightsInner = (records: SalesRecord[]) => {
const weights = new Array(12).fill(0);
let total = 0;
const seenMonths = new Set<string>();
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 (r.units > 0) seenMonths.add(m);
}
});
// SAFETY NET: Even for Global Weights, if the reference file (2025 Sales)
// has fewer than 4 months of data (e.g. user only uploaded Jan 2025),
// we should NOT assume 100% seasonality in those months. Fallback to flat.
if (total === 0 || seenMonths.size < 4) {
return new Array(12).fill(1 / 12);
}
return weights.map(w => w / total);
};
const panEuWeights = getGlobalWeightsInner(panEuHistoricalData);
// 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[]) => {
// Use Inner helper to ensure we always get weights for global subsets
const peWeights = getGlobalWeightsInner(paEuRecords);
const ukWeights = getGlobalWeightsInner(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(panEuHistoricalData, historicalData.filter(r => r.customer === 'Amazon UK'))
: panEuWeights;
// Map historical data by ASIN for quick access
const dataByAsinHistorical = new Map<string, SalesRecord[]>();
historicalData.forEach(r => {
const key = r.asin.trim().toUpperCase();
if (!dataByAsinHistorical.has(key)) dataByAsinHistorical.set(key, []);
dataByAsinHistorical.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);
});
// 1b. Determine Line-Level Weights (NEW STRATEGY)
const lineWeightsMap = new Map<string, number[]>();
const linesMap = new Map<string, SalesRecord[]>();
historicalData.forEach(r => {
if (!r.line) return;
if (!linesMap.has(r.line)) linesMap.set(r.line, []);
linesMap.get(r.line)!.push(r);
});
linesMap.forEach((records, line) => {
// We use the same getWeightsInfo logic but for the whole line
const info = getWeightsInfo(records);
if (info) {
lineWeightsMap.set(line, info.weights);
} else {
// Fallback for line if it has data but odd distribution?
// Actually getWeightsInfo returns null only if total=0.
// If we have records but 0 units total, we skip map set, so it will fall to global.
}
});
return forecastData.map(fc => {
const identifier = fc.asin.toUpperCase();
const meta = asinMetadata.get(identifier);
// Resolve Line: Try meta first, then forecast file
const resolvedLine = meta?.line || fc.line || "Unassigned";
const avgWeeklySales = velocityMap?.get(identifier) || 0;
// 2. Determine weights for this ASIN
const productHistoricalRecords = dataByAsinHistorical.get(identifier) || [];
// LAYERED FALLBACK STRATEGY:
// Level 1: Product's own history (Most accurate)
// Level 2: Product Line's history (Good for new items in known category e.g. Advent Calendars)
// Level 3: Global/Pan-EU history (Generic fallback)
const lineWeights = lineWeightsMap.get(resolvedLine);
const baselineWeights = lineWeights || globalWeights;
let finalWeights = baselineWeights;
if (productHistoricalRecords.length > 0) {
const historyToUse = isUkOnly
? productHistoricalRecords.filter(r => r.customer === 'Amazon UK')
: productHistoricalRecords;
const info = getWeightsInfo(historyToUse);
if (info) {
// Adaptive Blending (Bayesian Shrinkage):
// We blend local seasonality with baseline (Line or Global) based on data density.
const trustFactor = (info.monthsCount / 12) * 0.85;
finalWeights = info.weights.map((w, i) => (w * trustFactor) + (baselineWeights[i] * (1 - trustFactor)));
}
}
// 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 * finalWeights[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.max(0, Math.min(100, Math.round((1 - Math.abs(totalActualUnits - totalForecastUnits) / totalForecastUnits) * 100)))
: (totalActualUnits === 0 ? 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),
avgWeeklySales,
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') || jsonData[i].includes('asin'))) {
headerRowIndex = i;
break;
}
}
if (headerRowIndex === -1) {
// Fallback: look for ASIN in first row if not found in header scan
if (jsonData[0] && (jsonData[0].includes('ASIN') || jsonData[0].includes('asin'))) headerRowIndex = 0;
else {
console.warn("Could not find header row in Vendor Stock Excel");
return vendorStockMap;
}
}
const headers: any[] = jsonData[headerRowIndex];
const asinIdx = headers.findIndex(h => String(h || '').toUpperCase() === 'ASIN');
// Enhanced marketplace detection - includes 'Store code' for PANEU reports
const marketplaceIdx = headers.findIndex(h => {
const sh = String(h || '').toUpperCase();
return sh === 'MARKETPLACE' || sh === 'COUNTRY' || sh === 'COUNTRY/REGION' ||
sh === 'STORE CODE' || sh === 'STORE';
});
// REFINED SEARCH STRATEGY for Stock column:
// Priority 1: Look specifically for "Sellable On Hand Units" (most accurate for current inventory)
let finalStockIdx = headers.findIndex(h => {
const sh = String(h || '').toUpperCase();
return sh === 'SELLABLE ON HAND UNITS';
});
// Priority 2: Look for columns with "SELLABLE" AND "UNITS" (but not necessarily exact match)
if (finalStockIdx === -1) {
finalStockIdx = headers.findIndex(h => {
const sh = String(h || '').toUpperCase();
return sh.includes('SELLABLE') && sh.includes('UNITS') &&
!sh.includes('UNSELLABLE') && !sh.includes('AGED');
});
}
// Priority 3: Look for "On Hand Units" variations
if (finalStockIdx === -1) {
finalStockIdx = headers.findIndex(h => {
const sh = String(h || '').toUpperCase();
return sh.includes('ON HAND') && sh.includes('UNITS') &&
!sh.includes('UNSELLABLE');
});
}
// Priority 4: Fallback to generic stock column search (but avoid monetary columns)
if (finalStockIdx === -1) {
finalStockIdx = headers.findIndex(h => {
const sh = String(h || '').toUpperCase();
// Exclude columns that are clearly wrong
if (sh.includes('COST') || sh.includes('VALUE') || sh.includes('AMOUNT') ||
sh.includes('PRICE') || sh.includes('RECEIVED') || sh.includes('UNFILLED') ||
sh.includes('AGED') || sh.includes('UNSELLABLE')) {
return false;
}
return sh.includes('STOCK') || sh.includes('AVAILABILITY') ||
(sh.includes('UNITS') && sh.includes('SELLABLE'));
});
}
// Final sanity check for indexes, fallback to defaults if headers.findIndex returned -1
const finalAsinIdx = asinIdx !== -1 ? asinIdx : 0;
const finalMarketplaceIdx = marketplaceIdx !== -1 ? marketplaceIdx : 3;
finalStockIdx = finalStockIdx !== -1 ? finalStockIdx : 15;
console.log(`[VendorStock] Selected Stock Column: "${headers[finalStockIdx]}" (Index: ${finalStockIdx})`);
console.log(`[VendorStock] Marketplace Column: "${headers[finalMarketplaceIdx]}" (Index: ${finalMarketplaceIdx})`);
for (let i = headerRowIndex + 1; i < jsonData.length; i++) {
const row = jsonData[i];
if (!row || row.length <= Math.max(finalAsinIdx, finalMarketplaceIdx, finalStockIdx)) continue;
const asin = String(row[finalAsinIdx] || '').trim().toUpperCase();
if (!asin) continue;
const marketplace = String(row[finalMarketplaceIdx] || '').trim().toLowerCase();
const stockValue = parseUnits(String(row[finalStockIdx] || '0'));
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' || marketplace === 'united kingdom') {
current.uk += stockValue;
} else if (marketplace) {
// Assume everything else with a marketplace is Pan-EU (DE, IT, FR, ES)
current.eu += stockValue;
}
}
return vendorStockMap;
} catch (error) {
console.error("Error processing Vendor Stock Excel:", error);
throw error;
}
};
/**
* Process UK Inventory Excel file from Amazon Vendor Central.
* This file contains ONLY UK inventory, so all stock goes to the 'uk' property.
* It merges with an existing vendorStockMap to combine PANEU + UK data.
*/
export const processUKInventoryExcel = async (
fileOrBuffer: File | ArrayBuffer,
existingMap?: Map<string, { eu: number; uk: number }>
): 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 });
// Start with existing map or create new one
const vendorStockMap = existingMap || 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') || jsonData[i].includes('asin'))) {
headerRowIndex = i;
break;
}
}
if (headerRowIndex === -1) {
console.warn("[UK Inventory] Could not find header row");
return vendorStockMap;
}
const headers: any[] = jsonData[headerRowIndex];
const asinIdx = headers.findIndex(h => String(h || '').toUpperCase() === 'ASIN');
// Find "Sellable On Hand Units" column (same logic as PANEU)
let stockIdx = headers.findIndex(h => String(h || '').toUpperCase() === 'SELLABLE ON HAND UNITS');
if (stockIdx === -1) {
stockIdx = headers.findIndex(h => {
const sh = String(h || '').toUpperCase();
return sh.includes('SELLABLE') && sh.includes('UNITS') &&
!sh.includes('UNSELLABLE') && !sh.includes('AGED');
});
}
// Fallback to default index if not found
const finalAsinIdx = asinIdx !== -1 ? asinIdx : 0;
const finalStockIdx = stockIdx !== -1 ? stockIdx : 14; // UK file has it at index 14
console.log(`[UK Inventory] ASIN Column: "${headers[finalAsinIdx]}" (Index: ${finalAsinIdx})`);
console.log(`[UK Inventory] Stock Column: "${headers[finalStockIdx]}" (Index: ${finalStockIdx})`);
let ukCount = 0;
for (let i = headerRowIndex + 1; i < jsonData.length; i++) {
const row = jsonData[i];
if (!row || row.length <= Math.max(finalAsinIdx, finalStockIdx)) continue;
const asin = String(row[finalAsinIdx] || '').trim().toUpperCase();
if (!asin) continue;
const stockValue = parseUnits(String(row[finalStockIdx] || '0'));
if (!vendorStockMap.has(asin)) {
vendorStockMap.set(asin, { eu: 0, uk: 0 });
}
const current = vendorStockMap.get(asin)!;
current.uk += stockValue;
ukCount++;
}
console.log(`[UK Inventory] Processed ${ukCount} UK stock entries`);
return vendorStockMap;
} catch (error) {
console.error("Error processing UK Inventory Excel:", error);
throw error;
}
};
export const calculateVelocityMap = (data: SalesRecord[]): Map<string, number> => {
// 4-Week Average Sales Calculation
const validYears = data.map(r => r.year).filter(y => y > 0);
if (validYears.length === 0) return new Map();
const latestYear = Math.max(...validYears);
const yearData = data.filter(r => r.year === latestYear);
const latestWeek = yearData.length > 0 ? Math.max(...yearData.map(r => r.week).filter(w => w !== undefined) as number[]) : 0;
const last4WeeksKeys = new Set<string>();
for (let i = 0; i < 4; i++) {
let w = latestWeek - i;
let y = latestYear;
if (w <= 0) {
w = 52 + w;
y = latestYear - 1;
}
last4WeeksKeys.add(`${y}|${w}`);
}
const asin4WeekSales = new Map<string, number>();
data.forEach(r => {
if (r.week === undefined) return;
if (last4WeeksKeys.has(`${r.year}|${r.week}`)) {
const key = r.asin.trim().toUpperCase();
asin4WeekSales.set(key, (asin4WeekSales.get(key) || 0) + r.units);
}
});
const velocityMap = new Map<string, number>();
asin4WeekSales.forEach((total, asin) => {
velocityMap.set(asin, total / 4);
});
return velocityMap;
};
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;
}
};
export const processBuyBoxExcel = async (fileOrBuffer: File | ArrayBuffer): Promise<Map<string, { countries: string[]; reasons: Record<string, string> }>> => {
try {
const arrayBuffer = fileOrBuffer instanceof File ? await fileOrBuffer.arrayBuffer() : fileOrBuffer;
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
const buyBoxMap = new Map<string, { countries: string[]; reasons: Record<string, string> }>();
// Amazon ASIN pattern: B followed by exactly 9 alphanumeric chars
const ASIN_REGEX = /^B[0-9A-Z]{9}$/;
const RESOLVED = new Set(['fixed', 'ok', 'hecho', 'solucionado', 'corrected', 'resolved', 'resuelto', 'done', 'listo']);
console.log('[BuyBox] Available sheets:', workbook.SheetNames.join(', '));
// Detect country code from sheet name — only BB_* sheets to avoid false positives
const detectCountry = (name: string): string | null => {
const n = name.toUpperCase().replace(/[_\- ]/g, '');
// Require "BB" prefix to avoid matching inventory/other sheets (e.g. INV ITALY)
if (!n.startsWith('BB')) return null;
if (n.includes('FR') || n.includes('FRANCE')) return 'FR';
if (n.includes('UK') || n.includes('GB')) return 'UK';
if (n.includes('DE') || n.includes('GERMANY') || n.includes('DEUTSCH')) return 'DE';
if (n.includes('IT') || n.includes('ITALY') || n.includes('ITALIA')) return 'IT';
if (n.includes('ES') || n.includes('SPAIN') || n.includes('ESPANA')) return 'ES';
return null;
};
for (const sheetName of workbook.SheetNames) {
const country = detectCountry(sheetName);
if (!country) {
console.log(`[BuyBox] Sheet "${sheetName}" — no country detected, skipping`);
continue;
}
const worksheet = workbook.Sheets[sheetName];
const rows: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: '' });
if (!rows.length) continue;
// Detect ASIN column by scanning first 100 rows for cells matching ASIN pattern
const asinHits: number[] = [];
const scanLimit = Math.min(rows.length, 100);
for (let r = 0; r < scanLimit; r++) {
const row = rows[r] || [];
for (let c = 0; c < row.length; c++) {
if (ASIN_REGEX.test(String(row[c] || '').trim().toUpperCase())) {
asinHits[c] = (asinHits[c] || 0) + 1;
}
}
}
// Column with most ASIN-pattern matches wins
let asinColIdx = -1;
let maxHits = 0;
asinHits.forEach((count, idx) => {
if (count > maxHits) { maxHits = count; asinColIdx = idx; }
});
// Fallback: look for a header cell containing "ASIN"
if (asinColIdx === -1) {
for (let r = 0; r < Math.min(rows.length, 15); r++) {
const idx = (rows[r] || []).findIndex((cell: any) =>
String(cell || '').toUpperCase().includes('ASIN'));
if (idx !== -1) { asinColIdx = idx; break; }
}
}
if (asinColIdx === -1) {
console.warn(`[BuyBox] Sheet "${sheetName}" (${country}): ASIN column not found, skipping`);
continue;
}
// Detect reason column from header row keywords
let reasonColIdx = -1;
for (let r = 0; r < Math.min(rows.length, 15); r++) {
const idx = (rows[r] || []).findIndex((cell: any) => {
const s = String(cell || '').toUpperCase();
return s.includes('ISSUE') || s.includes('REASON') || s.includes('STATUS') ||
s.includes('MOTIVO') || s.includes('CAUSA') || s.includes('ESTADO') ||
s.includes('COMMENT') || s.includes('OBSERV') || s.includes('SITUAC') ||
s.includes('LBB') || s.includes('PROBLEMA') || s.includes('NOTE') ||
s.includes('JUSTIF') || s.includes('DETALL');
});
if (idx !== -1) { reasonColIdx = idx; break; }
}
console.log(`[BuyBox] Sheet "${sheetName}" → ${country}: asinCol=${asinColIdx} (${maxHits} ASINs found), reasonCol=${reasonColIdx}`);
let count = 0;
for (const row of rows) {
if (!row || row.length <= asinColIdx) continue;
const rawAsin = String(row[asinColIdx] || '').trim().toUpperCase();
if (!ASIN_REGEX.test(rawAsin)) continue;
const rawReason = reasonColIdx !== -1 && row.length > reasonColIdx
? String(row[reasonColIdx] || '').trim()
: '';
if (RESOLVED.has(rawReason.toLowerCase())) continue;
const reason = rawReason || 'BB Lost';
let entry = buyBoxMap.get(rawAsin);
if (!entry) { entry = { countries: [], reasons: {} }; buyBoxMap.set(rawAsin, entry); }
if (!entry.countries.includes(country)) entry.countries.push(country);
entry.reasons[country] = reason;
count++;
}
console.log(`[BuyBox] Sheet "${sheetName}": ${count} BB lost entries for ${country}`);
}
console.log(`[BuyBox] Total: ${buyBoxMap.size} ASINs with BB issues`);
return buyBoxMap;
} catch (error) {
console.error('[BuyBox] Error processing Excel:', error);
throw error;
}
};