mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 12:55:23 +02:00
2367 lines
95 KiB
TypeScript
2367 lines
95 KiB
TypeScript
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';
|
|
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'];
|
|
|
|
// 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" or "01/04/2023"
|
|
if (m.includes('/') || m.includes('-')) {
|
|
// Try parsing standard date
|
|
const date = new Date(m);
|
|
if (!isNaN(date.getTime())) {
|
|
const monthIdx = date.getMonth();
|
|
const yearShort = date.getFullYear().toString().slice(2);
|
|
return `${MONTH_ORDER[monthIdx]}-${yearShort}`;
|
|
}
|
|
}
|
|
|
|
const numMatch = m.match(/^(\d{1,2})([^\d]|$)/);
|
|
if (numMatch) {
|
|
const num = parseInt(numMatch[1]);
|
|
if (num >= 1 && num <= 12) return MONTH_ORDER[num - 1];
|
|
}
|
|
|
|
// 3. Fallback: Extract first 3 letters and capitalize
|
|
const alphaMatch = m.match(/([a-zA-Z\u00C0-\u00FF]+)/);
|
|
if (alphaMatch) {
|
|
let alpha = alphaMatch[1];
|
|
if (alpha.length > 3) alpha = alpha.substring(0, 3);
|
|
// Check map again with short version
|
|
if (MONTH_MAP[alpha]) return MONTH_MAP[alpha];
|
|
|
|
return alpha.charAt(0).toUpperCase() + alpha.slice(1);
|
|
}
|
|
|
|
// Try to grab year from original string to append (e.g. "Apr-23") if strict matching failed
|
|
const yearMatch = rawMonth.match(/(\d{2,4})/);
|
|
if (yearMatch) {
|
|
let y = yearMatch[1];
|
|
if (y.length === 4) y = y.slice(2);
|
|
// This part is likely fallback for Sales Data records
|
|
const letters = m.replace(/[^a-z]/g, '');
|
|
if (letters && MONTH_MAP[letters]) {
|
|
return `${MONTH_MAP[letters]}-${y}`;
|
|
}
|
|
}
|
|
|
|
monthCache[rawMonth] = rawMonth;
|
|
return rawMonth; // Return as-is if all else fails
|
|
};
|
|
|
|
// Robust CSV Column Value Extractor
|
|
const getColumnValue = (row: any, aliases: string[]): string => {
|
|
const rowKeys = Object.keys(row);
|
|
const normalizedRowKeys: Record<string, string> = {};
|
|
rowKeys.forEach(k => {
|
|
normalizedRowKeys[k.trim().toLowerCase()] = k;
|
|
});
|
|
|
|
for (const alias of aliases) {
|
|
const lookup = alias.trim().toLowerCase();
|
|
if (normalizedRowKeys[lookup]) {
|
|
const actualKey = normalizedRowKeys[lookup];
|
|
const val = row[actualKey];
|
|
if (val !== undefined && val !== null) {
|
|
const strVal = String(val).trim();
|
|
if (strVal.length > 0) {
|
|
return strVal;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return '';
|
|
};
|
|
// Allowed Customers Whitelist
|
|
export const PAN_EU_COUNTRIES = ['Amazon DE', 'Amazon IT', 'Amazon FR', 'Amazon ES'];
|
|
const ALLOWED_CUSTOMERS = [...PAN_EU_COUNTRIES, 'Amazon UK', 'Amazon SC'];
|
|
|
|
const isAllowedCustomer = (customer: string): boolean => {
|
|
if (!customer) return false;
|
|
const normCustomer = customer.trim().toLowerCase();
|
|
return ALLOWED_CUSTOMERS.some(allowed => allowed.toLowerCase() === normCustomer);
|
|
};
|
|
|
|
// --- SALES / SELL OUT MAPPING ---
|
|
|
|
const mapRowToRecord = (row: any, index: number): SalesRecord => {
|
|
const customer = getColumnValue(row, ['NEW CUSTOMER', 'Customer', 'Client', 'Account', 'Partner', 'COUNTRY', 'Country', 'Market']) || 'Unknown';
|
|
const yearStr = getColumnValue(row, ['YEAR', 'Year', 'D']);
|
|
// Sanitize year string before parsing (remove commas/dots e.g. "2,023")
|
|
let year = parseInt(yearStr.replace(/[,.]/g, '')) || 0;
|
|
const monthStr = getColumnValue(row, ['MONTH', 'Month', 'Period']);
|
|
const month = normalizeMonth(monthStr);
|
|
|
|
// BACKFILL YEAR if missing but present in Month (e.g. "Apr-23")
|
|
if (year === 0 && month.includes('-')) {
|
|
const parts = month.split('-');
|
|
if (parts.length === 2) {
|
|
const yPart = parts[1];
|
|
// assume 20xx for 2 digits
|
|
if (yPart.length === 2) year = 2000 + parseInt(yPart);
|
|
else if (yPart.length === 4) year = parseInt(yPart);
|
|
}
|
|
}
|
|
|
|
const weekStr = getColumnValue(row, ['WEEK', 'Week', 'CW', 'Semana', 'KW', 'E']);
|
|
const weekNum = weekStr ? parseInt(weekStr.replace(/cw/i, '').trim(), 10) : NaN;
|
|
const week = isNaN(weekNum) ? undefined : weekNum;
|
|
const line = getColumnValue(row, ['LICENSE', 'License']) || 'Unassigned';
|
|
|
|
const asin = getColumnValue(row, [
|
|
'CUSTOMER REFERENCE', 'AMAZON ASIN', 'ASIN', 'Asin', 'PRODUCT ID', 'ITEM IDENTIFIER', 'ASIN NO.', 'Product ASIN', 'IDENTIFIER'
|
|
]);
|
|
|
|
const sku = getColumnValue(row, ['RAW ARTICLE NO.', 'SKU', 'Sku', 'Item No']);
|
|
const title = getColumnValue(row, ['ARTICLE NAME (Craze)', 'Title', 'TITLE', 'Product Title', 'Article Name', 'ArticleName']);
|
|
const articleName = getColumnValue(row, ['ARTICLE NAME (Craze)', 'Article Name', 'ArticleName', 'Title']);
|
|
|
|
const unitsRaw = getColumnValue(row, ['UNITS', 'Units', 'Quantity', 'Qty']);
|
|
const sellOutRaw = getColumnValue(row, ['AMOUNT', 'Sell Out', 'SellOut', 'Revenue', 'Sales', 'Turnover']);
|
|
|
|
return {
|
|
id: `row-${index}`,
|
|
customer,
|
|
year,
|
|
month,
|
|
week,
|
|
asin,
|
|
sku,
|
|
title,
|
|
articleName,
|
|
units: parseUnits(unitsRaw),
|
|
sellOut: parseCurrency(sellOutRaw),
|
|
line
|
|
};
|
|
};
|
|
|
|
export const processCSV = (fileOrContent: File | string): Promise<SalesRecord[]> => {
|
|
return new Promise((resolve, reject) => {
|
|
// @ts-ignore
|
|
Papa.parse(fileOrContent, {
|
|
header: true,
|
|
skipEmptyLines: true,
|
|
complete: (results: any) => {
|
|
try {
|
|
const data: SalesRecord[] = results.data.map((row: any, index: number) => {
|
|
return mapRowToRecord(row, index);
|
|
})
|
|
// Filter: Valid Year > 2023 (exclude incomplete 2023 data) AND Allowed Customer
|
|
.filter((r: SalesRecord) => r.year > 2023 && isAllowedCustomer(r.customer));
|
|
|
|
resolve(data);
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
},
|
|
error: (error: any) => reject(error)
|
|
});
|
|
});
|
|
};
|
|
|
|
export const processExcel = async (file: File): Promise<SalesRecord[]> => {
|
|
try {
|
|
const arrayBuffer = await file.arrayBuffer();
|
|
const workbook = XLSX.read(arrayBuffer);
|
|
const firstSheetName = workbook.SheetNames[0];
|
|
const worksheet = workbook.Sheets[firstSheetName];
|
|
const jsonData = XLSX.utils.sheet_to_json(worksheet, { defval: "" });
|
|
|
|
const data: SalesRecord[] = jsonData.map((row: any, index: number) => {
|
|
return mapRowToRecord(row, index);
|
|
})
|
|
// Filter: Valid Year AND Allowed Customer
|
|
.filter((r: SalesRecord) => r.year > 0 && isAllowedCustomer(r.customer));
|
|
|
|
return data;
|
|
} catch (error) {
|
|
console.error("Error processing Excel file:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// --- ADS DATA MAPPING ---
|
|
|
|
const mapCountryToMarketplace = (country: string): string => {
|
|
const c = String(country).toLowerCase().trim();
|
|
if (c.includes('germany') || c.includes('deutschland') || c.includes('de')) return 'Amazon DE';
|
|
if (c.includes('spain') || c.includes('espana') || c.includes('españa') || c.includes('es')) return 'Amazon ES';
|
|
if (c.includes('france') || c.includes('fr')) return 'Amazon FR';
|
|
if (c.includes('italy') || c.includes('italia') || c.includes('it')) return 'Amazon IT';
|
|
if (c.includes('kingdom') || c.includes('uk') || c === 'gb') return 'Amazon UK';
|
|
if (c.includes('netherlands') || c.includes('nederland') || c.includes('holland') || c.includes('nl')) return 'Amazon NL';
|
|
return country.toUpperCase(); // Fallback
|
|
};
|
|
|
|
export const processAdsCSV = (file: File): Promise<AdsRecord[]> => {
|
|
return new Promise((resolve, reject) => {
|
|
// @ts-ignore
|
|
Papa.parse(file, {
|
|
header: false, // Index-based mapping
|
|
skipEmptyLines: true,
|
|
complete: (results: any) => {
|
|
try {
|
|
const data: AdsRecord[] = [];
|
|
const rows = results.data;
|
|
const len = rows.length;
|
|
|
|
for (let i = 0; i < len; i++) {
|
|
const row = rows[i];
|
|
if (!Array.isArray(row) || row.length < 12) continue;
|
|
|
|
// Check header row (Column A: Customer or Country)
|
|
const c0 = String(row[0]).trim().toLowerCase();
|
|
if (c0.includes('customer') || c0.includes('country') || c0.includes('marketplace')) continue;
|
|
|
|
// Column mapping for CSV (same as Excel):
|
|
// A (0): Country
|
|
// B (1): Week
|
|
// C (2): ASIN
|
|
// D (3): Cost
|
|
// E (4): Clicks
|
|
// F (5): Impressions
|
|
// G (6): CPC
|
|
// H (7): CTR %
|
|
// I (8): ACOS %
|
|
// J (9): Conversions (30d)
|
|
// K (10): Units (30d)
|
|
// L (11): Sales (30d)
|
|
|
|
const countryRaw = row[0];
|
|
const weekRaw = row[1];
|
|
const asin = row[2];
|
|
const costRaw = row[3];
|
|
const clicksRaw = row[4];
|
|
const impressionsRaw = row[5];
|
|
const cpcRaw = row[6];
|
|
const ctrRaw = row[7];
|
|
const acosRaw = row[8];
|
|
const conversionsRaw = row[9];
|
|
const unitsRaw = row[10];
|
|
const salesRaw = row[11];
|
|
|
|
if (!asin || !countryRaw || weekRaw === undefined) continue;
|
|
|
|
const weekNum = parseInt(String(weekRaw));
|
|
if (isNaN(weekNum) || weekNum < 1 || weekNum > 53) continue;
|
|
|
|
// For CSV without sheet names, assume current year
|
|
const currentYear = new Date().getFullYear();
|
|
|
|
data.push({
|
|
country: mapCountryToMarketplace(String(countryRaw)),
|
|
year: currentYear,
|
|
week: weekNum,
|
|
asin: String(asin).trim(),
|
|
cost: parseCurrency(String(costRaw)),
|
|
clicks: parseUnits(String(clicksRaw)),
|
|
impressions: parseUnits(String(impressionsRaw)),
|
|
cpc: parseCurrency(String(cpcRaw)),
|
|
ctr: parseCurrency(String(ctrRaw)),
|
|
acos: parseCurrency(String(acosRaw)),
|
|
conversions: parseUnits(String(conversionsRaw)),
|
|
attributedUnits30d: parseUnits(String(unitsRaw)),
|
|
attributedSales30d: parseCurrency(String(salesRaw)),
|
|
});
|
|
}
|
|
resolve(data);
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
},
|
|
error: (error: any) => reject(error)
|
|
});
|
|
});
|
|
};
|
|
|
|
export const processAdsExcel = async (fileOrBuffer: File | ArrayBuffer): Promise<AdsRecord[]> => {
|
|
try {
|
|
const arrayBuffer = fileOrBuffer instanceof File
|
|
? await fileOrBuffer.arrayBuffer()
|
|
: fileOrBuffer;
|
|
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
|
const allData: AdsRecord[] = [];
|
|
|
|
// Process ALL sheets (e.g., "2025", "2026")
|
|
for (const sheetName of workbook.SheetNames) {
|
|
const year = parseInt(sheetName);
|
|
if (isNaN(year) || year < 2020 || year > 2100) {
|
|
console.warn(`Skipping sheet "${sheetName}" - not a valid year`);
|
|
continue;
|
|
}
|
|
|
|
const worksheet = workbook.Sheets[sheetName];
|
|
// Use header: 1 to get array of arrays (row-based)
|
|
const jsonData: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: "" });
|
|
|
|
console.log(`Processing sheet ${sheetName}: ${jsonData.length} rows`);
|
|
|
|
// Skip header row (index 0), process data rows
|
|
for (let i = 1; i < jsonData.length; i++) {
|
|
const row = jsonData[i];
|
|
if (!row || row.length < 12) continue;
|
|
|
|
// Column mapping for Ads Weekly.xlsx:
|
|
// A (0): Country
|
|
// B (1): Week
|
|
// C (2): ASIN
|
|
// D (3): Cost
|
|
// E (4): Clicks
|
|
// F (5): Impressions
|
|
// G (6): CPC
|
|
// H (7): CTR %
|
|
// I (8): ACOS %
|
|
// J (9): Conversions (30d)
|
|
// K (10): Units (30d)
|
|
// L (11): Sales (30d)
|
|
|
|
const countryRaw = row[0];
|
|
const weekRaw = row[1];
|
|
const asin = row[2];
|
|
const costRaw = row[3];
|
|
const clicksRaw = row[4];
|
|
const impressionsRaw = row[5];
|
|
const cpcRaw = row[6];
|
|
const ctrRaw = row[7];
|
|
const acosRaw = row[8];
|
|
const conversionsRaw = row[9];
|
|
const unitsRaw = row[10];
|
|
const salesRaw = row[11];
|
|
|
|
// Skip if missing essential data
|
|
if (!asin || !countryRaw || weekRaw === undefined || weekRaw === '') continue;
|
|
|
|
const weekNum = parseInt(String(weekRaw));
|
|
if (isNaN(weekNum) || weekNum < 1 || weekNum > 53) continue;
|
|
|
|
allData.push({
|
|
country: mapCountryToMarketplace(String(countryRaw)),
|
|
year,
|
|
week: weekNum,
|
|
asin: String(asin).trim(),
|
|
cost: parseCurrency(String(costRaw)),
|
|
clicks: parseUnits(String(clicksRaw)),
|
|
impressions: parseUnits(String(impressionsRaw)),
|
|
cpc: parseCurrency(String(cpcRaw)),
|
|
ctr: parseCurrency(String(ctrRaw)),
|
|
acos: parseCurrency(String(acosRaw)),
|
|
conversions: parseUnits(String(conversionsRaw)),
|
|
attributedUnits30d: parseUnits(String(unitsRaw)),
|
|
attributedSales30d: parseCurrency(String(salesRaw)),
|
|
});
|
|
}
|
|
}
|
|
|
|
console.log(`Total Ads records loaded: ${allData.length}`);
|
|
return allData;
|
|
} catch (error) {
|
|
console.error("Error processing Ads Excel:", error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// --- 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;
|
|
}
|
|
};
|
|
|
|
// --- 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,
|
|
unitsAds: ad.attributedUnits30d,
|
|
cost: ad.cost,
|
|
clicks: ad.clicks,
|
|
impressions: ad.impressions,
|
|
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,
|
|
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)
|
|
: 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;
|
|
});
|
|
};
|
|
|
|
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 = {};
|
|
|
|
// 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);
|
|
};
|
|
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"
|
|
}
|
|
|
|
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: {},
|
|
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.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;
|
|
}
|
|
};
|
|
|
|
const BB_SHEET_CONFIG: { sheet: string; country: string }[] = [
|
|
{ sheet: 'BB_FR', country: 'FR' },
|
|
{ sheet: 'BB_UK', country: 'UK' },
|
|
{ sheet: 'BB_DE', country: 'DE' },
|
|
{ sheet: 'BB_IT', country: 'IT' },
|
|
{ sheet: 'BB_ES', country: 'ES' },
|
|
];
|
|
|
|
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' });
|
|
|
|
// Map: ASIN -> { countries: [], reasons: {} }
|
|
const buyBoxMap = new Map<string, { countries: string[]; reasons: Record<string, string> }>();
|
|
|
|
// Log available sheets for debugging
|
|
console.log('[BuyBox] Available sheets:', workbook.SheetNames.join(', '));
|
|
|
|
for (const config of BB_SHEET_CONFIG) {
|
|
// Find sheet case-insensitively and with flexible separators (BB_ES, BB-ES, BB ES, ES BB)
|
|
const sheetName = workbook.SheetNames.find(name => {
|
|
const n = name.toUpperCase().replace(/[-_ ]/g, '');
|
|
const target = config.sheet.toUpperCase().replace(/[-_ ]/g, '');
|
|
const country = config.country.toUpperCase();
|
|
// Match if:
|
|
// 1. Exact pattern (BB_ES)
|
|
// 2. Just the country code (ES)
|
|
// 3. Contains 'BB' and the country code
|
|
// 4. Special cases for Spain (SPAIN, ESPAÑA)
|
|
return n === target ||
|
|
n === country ||
|
|
(n.includes('BB') && n.includes(country)) ||
|
|
(country === 'ES' && (n.includes('SPAIN') || n.includes('ESPAÑA') || n.includes('ESPANA')));
|
|
});
|
|
|
|
if (!sheetName) {
|
|
console.warn(`[BuyBox] Sheet matching ${config.sheet} not found, skipping...`);
|
|
continue;
|
|
}
|
|
|
|
const worksheet = workbook.Sheets[sheetName];
|
|
const jsonData: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
|
|
if (jsonData.length === 0) continue;
|
|
|
|
// Find the header row (first row with ASIN or similar)
|
|
let headerRowIdx = 0;
|
|
let headers: any[] = jsonData[0] || [];
|
|
for (let r = 0; r < Math.min(jsonData.length, 10); r++) {
|
|
const rowData = jsonData[r];
|
|
if (!rowData) continue;
|
|
const isHeader = rowData.some(cell => {
|
|
const s = String(cell || '').toUpperCase().trim();
|
|
return s === 'ASIN' || s.includes('AMAZON ASIN') || s.includes('PRODUCT ID') || s.includes('SKU');
|
|
});
|
|
if (isHeader) {
|
|
headerRowIdx = r;
|
|
headers = rowData;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const asinIdx = headers.findIndex(h => {
|
|
const s = String(h || '').toUpperCase().trim();
|
|
return s === 'ASIN' || s.includes('AMAZON ASIN') || s.includes('CHILD ASIN') || s.includes('IDENTIFIER') || s.includes('PRODUCT ID') || s.includes('SKU');
|
|
});
|
|
let finalAsinIdx = asinIdx !== -1 ? asinIdx : 1; // Default to Col B if not found
|
|
|
|
// Dynamically find the "Issue Type" or "Reason" column
|
|
let reasonIdx = headers.findIndex(h => {
|
|
const s = String(h || '').toUpperCase();
|
|
return s.includes('ISSUE TYPE') || s.includes('REASON') || s.includes('BUY BOX STATUS') || s.includes('LBB REASON') || s.includes('ESTADO BB') || s.includes('COMENTARIO') || s.includes('MOTIVO') || s.includes('CAUSA') || s.includes('OBSERVACIONES') || s.includes('DETALLES') || s.includes('JUSTIFICACIÓN') || s.includes('SITUACIÓN') || s.includes('STATUS');
|
|
});
|
|
|
|
// If still not found, check for exact matches of common Spanish headers
|
|
if (reasonIdx === -1) {
|
|
reasonIdx = headers.findIndex(h => {
|
|
const s = String(h || '').toUpperCase().trim();
|
|
return s === 'COMENTARIOS' || s === 'OBSERVACIONES' || s === 'MOTIVO' || s === 'ESTADO';
|
|
});
|
|
}
|
|
|
|
// Fallback to previous hardcoded indices if header search fails or for specific known sheet structures
|
|
if (reasonIdx === -1 || (config.country === 'FR' && reasonIdx !== 18) || (config.country === 'UK' && reasonIdx !== 9) || (config.country === 'DE' && reasonIdx !== 13)) {
|
|
if (config.country === 'FR') reasonIdx = 18; // Force Column S for FR (Index 18)
|
|
else if (config.country === 'UK') reasonIdx = 9; // Force Column J for UK (Index 9)
|
|
else if (config.country === 'DE') reasonIdx = 13; // Force Column N for DE (Index 13)
|
|
else if (reasonIdx === -1) {
|
|
if (config.country === 'IT') reasonIdx = 12;
|
|
else if (config.country === 'ES') reasonIdx = 13;
|
|
else reasonIdx = 13;
|
|
}
|
|
}
|
|
|
|
// Also force ASIN column for DE if not correctly detected
|
|
if (config.country === 'DE') finalAsinIdx = 1; // Force Column B for Germany (Index 1)
|
|
if (config.country === 'ES') {
|
|
console.log(`[BuyBox Debug] ES Headers found:`, headers);
|
|
}
|
|
|
|
// Skip header row and all rows above it
|
|
for (let i = headerRowIdx + 1; i < jsonData.length; i++) {
|
|
const row = jsonData[i];
|
|
if (!row || row.length <= Math.max(finalAsinIdx, reasonIdx)) continue;
|
|
|
|
const rawAsin = String(row[finalAsinIdx] || '').trim().toUpperCase();
|
|
if (!rawAsin || rawAsin.length < 5) continue;
|
|
|
|
const rawReason = String(row[reasonIdx] || '').trim();
|
|
|
|
// Debug specific ASIN reported by user
|
|
const isTargetAsin = rawAsin === 'B0D3874WSD' || rawAsin.includes('B0D3874WSD');
|
|
if (isTargetAsin) {
|
|
console.log(`[BuyBox Debug] Found ASIN ${rawAsin} in ${config.country}. Row data at reasonIdx (${reasonIdx}): "${row[reasonIdx]}", Processed Reason: "${rawReason}"`);
|
|
}
|
|
|
|
// Only consider as BB lost if there is an Issue Type / Reason specified
|
|
const lowReason = rawReason.toLowerCase();
|
|
if (!rawReason || lowReason === 'fixed' || lowReason === 'ok' || lowReason === 'hecho' || lowReason === 'solucionado' || lowReason === 'corrected') {
|
|
if (isTargetAsin) {
|
|
console.log(`[BuyBox Debug] SKIPPING ASIN ${rawAsin} in ${config.country} because reason is empty or matches positive status list ("${rawReason}")`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
let entry = buyBoxMap.get(rawAsin);
|
|
if (!entry) {
|
|
entry = { countries: [], reasons: {} };
|
|
buyBoxMap.set(rawAsin, entry);
|
|
}
|
|
|
|
if (!entry.countries.includes(config.country)) {
|
|
entry.countries.push(config.country);
|
|
}
|
|
|
|
// Collect reason for this country
|
|
entry.reasons[config.country] = rawReason;
|
|
}
|
|
}
|
|
|
|
console.log(`[BuyBox] Processed ${buyBoxMap.size} total ASINs with BB lost across all countries`);
|
|
return buyBoxMap;
|
|
} catch (error) {
|
|
console.error("Error processing Buy Box Excel:", error);
|
|
throw error;
|
|
}
|
|
};
|