2026-03-02 15:22:12 +01:00
|
|
|
import { SalesRecord, AdsRecord, TrafficRecord, CombinedKPIs, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint, ForecastRecord, MonthlyForecastPoint, ProductForecastData, VendorCSVRow, VendorDailyRow, BSRRecord } from '../types';
|
2025-12-11 11:25:26 +01:00
|
|
|
import * as XLSX from 'xlsx';
|
2025-12-11 14:03:33 +01:00
|
|
|
import Papa from 'papaparse';
|
2025-12-11 11:25:26 +01:00
|
|
|
|
2026-02-20 13:32:26 +01:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-11 11:25:26 +01:00
|
|
|
// 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 => {
|
2026-01-16 10:44:43 +01:00
|
|
|
if (!value) return 0;
|
2025-12-11 11:25:26 +01:00
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
// Remove currency symbol and whitespace
|
|
|
|
|
let clean = value.replace(/[€$£\s]/g, '').trim();
|
2025-12-11 11:25:26 +01:00
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
// 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(',', '.');
|
2026-02-25 16:34:55 +01:00
|
|
|
const num = parseFloat(clean);
|
|
|
|
|
return isNaN(num) ? 0 : num;
|
2026-01-16 10:44:43 +01:00
|
|
|
}
|
|
|
|
|
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, '');
|
|
|
|
|
}
|
2026-02-25 16:34:55 +01:00
|
|
|
const num = parseFloat(clean);
|
|
|
|
|
return isNaN(num) ? 0 : num;
|
2026-01-16 10:44:43 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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;
|
2025-12-11 11:25:26 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const parseUnits = (value: string): number => {
|
2026-01-16 10:44:43 +01:00
|
|
|
if (!value) return 0;
|
2025-12-11 11:25:26 +01:00
|
|
|
// 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'];
|
|
|
|
|
|
2026-01-30 19:28:45 +01:00
|
|
|
// 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;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-11 15:08:01 +01:00
|
|
|
// Comprehensive Month Mapping (English + Spanish + Short/Full)
|
|
|
|
|
const MONTH_MAP: Record<string, string> = {
|
|
|
|
|
// English Short
|
|
|
|
|
'jan': 'Jan', 'feb': 'Feb', 'mar': 'Mar', 'apr': 'Apr', 'may': 'May', 'jun': 'Jun',
|
|
|
|
|
'jul': 'Jul', 'aug': 'Aug', 'sep': 'Sep', 'oct': 'Oct', 'nov': 'Nov', 'dec': 'Dec',
|
|
|
|
|
// Spanish Short
|
|
|
|
|
'ene': 'Jan', 'abr': 'Apr', 'ago': 'Aug', 'dic': 'Dec', 'set': 'Sep',
|
|
|
|
|
// Spanish Full
|
|
|
|
|
'enero': 'Jan', 'febrero': 'Feb', 'marzo': 'Mar', 'abril': 'Apr', 'mayo': 'May', 'junio': 'Jun',
|
|
|
|
|
'julio': 'Jul', 'agosto': 'Aug', 'septiembre': 'Sep', 'octubre': 'Oct', 'noviembre': 'Nov', 'diciembre': 'Dec',
|
|
|
|
|
// English Full
|
|
|
|
|
'january': 'Jan', 'february': 'Feb', 'march': 'Mar', 'april': 'Apr', 'june': 'Jun',
|
|
|
|
|
'july': 'Jul', 'august': 'Aug', 'september': 'Sep', 'october': 'Oct', 'november': 'Nov', 'december': 'Dec'
|
2025-12-11 14:03:33 +01:00
|
|
|
};
|
|
|
|
|
|
2025-12-11 11:25:26 +01:00
|
|
|
// Robust Month Normalizer
|
2026-01-29 20:09:28 +01:00
|
|
|
const monthCache: Record<string, string> = {};
|
|
|
|
|
|
2025-12-11 11:25:26 +01:00
|
|
|
const normalizeMonth = (rawMonth: string): string => {
|
|
|
|
|
if (!rawMonth) return '';
|
2026-01-29 20:09:28 +01:00
|
|
|
if (monthCache[rawMonth]) return monthCache[rawMonth];
|
|
|
|
|
|
2025-12-11 15:08:01 +01:00
|
|
|
let m = String(rawMonth).trim().toLowerCase();
|
2026-01-16 10:44:43 +01:00
|
|
|
|
2025-12-11 15:08:01 +01:00
|
|
|
// 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.)
|
2026-01-29 20:09:28 +01:00
|
|
|
if (MONTH_MAP[m]) {
|
|
|
|
|
monthCache[rawMonth] = MONTH_MAP[m];
|
|
|
|
|
return MONTH_MAP[m];
|
|
|
|
|
}
|
2025-12-11 15:08:01 +01:00
|
|
|
|
|
|
|
|
// 2. Handle numeric months "01", "1", "01-2023"
|
2026-03-03 13:07:59 +01:00
|
|
|
// If it's a full date string like "2023-04-01", "01/04/2023", or "23/2/26" (DD/M/YY)
|
2025-12-11 14:03:33 +01:00
|
|
|
if (m.includes('/') || m.includes('-')) {
|
2026-03-03 13:14:30 +01:00
|
|
|
// Handle date strings with 3 parts separated by '/' or '-'
|
|
|
|
|
const sep = m.includes('/') ? '/' : '-';
|
|
|
|
|
const parts = m.split(sep);
|
2026-03-03 13:07:59 +01:00
|
|
|
if (parts.length === 3) {
|
|
|
|
|
const [a, b, c] = parts.map(p => parseInt(p, 10));
|
|
|
|
|
if (!isNaN(a) && !isNaN(b) && !isNaN(c)) {
|
2026-03-03 13:14:30 +01:00
|
|
|
// 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);
|
2026-03-03 13:07:59 +01:00
|
|
|
const result = `${MONTH_ORDER[b - 1]}-${yearShort}`;
|
|
|
|
|
monthCache[rawMonth] = result;
|
|
|
|
|
return result;
|
|
|
|
|
}
|
2026-03-03 13:14:30 +01:00
|
|
|
// 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');
|
2026-03-03 13:07:59 +01:00
|
|
|
const result = `${MONTH_ORDER[b - 1]}-${yearShort}`;
|
|
|
|
|
monthCache[rawMonth] = result;
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Try parsing standard date as fallback
|
2025-12-11 14:03:33 +01:00
|
|
|
const date = new Date(m);
|
|
|
|
|
if (!isNaN(date.getTime())) {
|
|
|
|
|
const monthIdx = date.getMonth();
|
|
|
|
|
const yearShort = date.getFullYear().toString().slice(2);
|
|
|
|
|
return `${MONTH_ORDER[monthIdx]}-${yearShort}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-11 11:25:26 +01:00
|
|
|
const numMatch = m.match(/^(\d{1,2})([^\d]|$)/);
|
|
|
|
|
if (numMatch) {
|
2026-01-16 10:44:43 +01:00
|
|
|
const num = parseInt(numMatch[1]);
|
|
|
|
|
if (num >= 1 && num <= 12) return MONTH_ORDER[num - 1];
|
2025-12-11 11:25:26 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-11 15:08:01 +01:00
|
|
|
// 3. Fallback: Extract first 3 letters and capitalize
|
2026-01-16 10:44:43 +01:00
|
|
|
const alphaMatch = m.match(/([a-zA-Z\u00C0-\u00FF]+)/);
|
2025-12-11 11:25:26 +01:00
|
|
|
if (alphaMatch) {
|
2025-12-11 15:08:01 +01:00
|
|
|
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];
|
2026-01-16 10:44:43 +01:00
|
|
|
|
2025-12-11 15:08:01 +01:00
|
|
|
return alpha.charAt(0).toUpperCase() + alpha.slice(1);
|
2025-12-11 11:25:26 +01:00
|
|
|
}
|
2026-01-16 10:44:43 +01:00
|
|
|
|
2025-12-11 15:08:01 +01:00
|
|
|
// Try to grab year from original string to append (e.g. "Apr-23") if strict matching failed
|
2025-12-11 14:03:33 +01:00
|
|
|
const yearMatch = rawMonth.match(/(\d{2,4})/);
|
|
|
|
|
if (yearMatch) {
|
|
|
|
|
let y = yearMatch[1];
|
|
|
|
|
if (y.length === 4) y = y.slice(2);
|
2025-12-11 15:08:01 +01:00
|
|
|
// This part is likely fallback for Sales Data records
|
|
|
|
|
const letters = m.replace(/[^a-z]/g, '');
|
|
|
|
|
if (letters && MONTH_MAP[letters]) {
|
2026-01-16 10:44:43 +01:00
|
|
|
return `${MONTH_MAP[letters]}-${y}`;
|
2025-12-11 14:03:33 +01:00
|
|
|
}
|
2025-12-11 11:25:26 +01:00
|
|
|
}
|
2025-12-11 14:03:33 +01:00
|
|
|
|
2026-01-29 20:09:28 +01:00
|
|
|
monthCache[rawMonth] = rawMonth;
|
2025-12-11 15:08:01 +01:00
|
|
|
return rawMonth; // Return as-is if all else fails
|
2025-12-11 11:25:26 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Robust CSV Column Value Extractor
|
2026-02-25 14:56:15 +01:00
|
|
|
const getColumnValue = (row: any, aliases: (string | RegExp)[]): string => {
|
2025-12-11 11:25:26 +01:00
|
|
|
const rowKeys = Object.keys(row);
|
|
|
|
|
const normalizedRowKeys: Record<string, string> = {};
|
2026-02-25 14:22:58 +01:00
|
|
|
|
2025-12-11 11:25:26 +01:00
|
|
|
rowKeys.forEach(k => {
|
2026-02-25 14:22:58 +01:00
|
|
|
// 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;
|
2025-12-11 11:25:26 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
for (const alias of aliases) {
|
2026-02-25 14:56:15 +01:00
|
|
|
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;
|
2025-12-11 11:25:26 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return '';
|
|
|
|
|
};
|
2026-01-16 10:44:43 +01:00
|
|
|
// Allowed Customers Whitelist
|
2026-04-16 11:37:33 +02:00
|
|
|
export const PAN_EU_COUNTRIES = ['Amazon DE', 'Amazon IT', 'Amazon FR', 'Amazon ES', 'Amazon NL', 'Amazon PL', 'Amazon BE', 'Amazon SE'];
|
|
|
|
|
const ALLOWED_CUSTOMERS = [...PAN_EU_COUNTRIES, 'Amazon UK', 'Amazon SC', 'Pan-EU'];
|
2026-01-16 10:44:43 +01:00
|
|
|
|
|
|
|
|
const isAllowedCustomer = (customer: string): boolean => {
|
|
|
|
|
if (!customer) return false;
|
|
|
|
|
const normCustomer = customer.trim().toLowerCase();
|
2026-04-16 11:37:33 +02:00
|
|
|
|
2026-04-20 13:07:25 +02:00
|
|
|
// Strict business rule: Only records from recognized Amazon marketplaces are included in Sell-Out revenue.
|
|
|
|
|
// This excludes marketing spend, financial adjustments, and non-Amazon channels.
|
|
|
|
|
const isAmazon = normCustomer.startsWith('amazon') || normCustomer === 'pan-eu';
|
|
|
|
|
|
|
|
|
|
// Additional check: Ensure it's not JUST a country code (often used in ad-spend records)
|
|
|
|
|
const isOnlyCountryCode = ['uk', 'de', 'it', 'fr', 'es', 'nl', 'se', 'pl', 'be'].includes(normCustomer);
|
|
|
|
|
|
|
|
|
|
if (isOnlyCountryCode && !normCustomer.startsWith('amazon')) return false;
|
|
|
|
|
|
|
|
|
|
return isAmazon;
|
2026-01-16 10:44:43 +01:00
|
|
|
};
|
2025-12-11 11:25:26 +01:00
|
|
|
|
2025-12-11 14:03:33 +01:00
|
|
|
// --- SALES / SELL OUT MAPPING ---
|
|
|
|
|
|
2026-02-25 11:46:02 +01:00
|
|
|
export const validateSellOutHeaders = (headers: string[]) => {
|
|
|
|
|
const normHeaders = headers.map(h => String(h).trim().toLowerCase());
|
|
|
|
|
|
2026-03-03 13:07:59 +01:00
|
|
|
// 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;
|
2026-02-25 11:46:02 +01:00
|
|
|
const hasCustomerRef = normHeaders.includes('customer reference') || normHeaders.includes('asin');
|
2026-04-16 11:49:48 +02:00
|
|
|
const hasEan = normHeaders.includes('ean') || normHeaders.includes('isbn');
|
|
|
|
|
const hasUnits = normHeaders.includes('units') || normHeaders.includes('qty') || normHeaders.includes('cantidad');
|
|
|
|
|
const hasAmount = ['amount_eur', 'amount', 'sell out', 'sellout', 'revenue', 'sales', 'valor'].some(a => normHeaders.includes(a));
|
2026-02-25 11:46:02 +01:00
|
|
|
|
|
|
|
|
const missing = [];
|
|
|
|
|
if (!hasYear) missing.push('YEAR');
|
|
|
|
|
if (!hasTime) missing.push('MONTH or WEEK');
|
2026-04-16 11:49:48 +02:00
|
|
|
if (!hasCustomerRef) missing.push('CUSTOMER REFERENCE / ASIN');
|
|
|
|
|
// EAN is optional now to prevent crashes with different report formats
|
2026-02-25 11:46:02 +01:00
|
|
|
if (!hasUnits) missing.push('UNITS');
|
2026-04-16 11:49:48 +02:00
|
|
|
if (!hasAmount) missing.push('AMOUNT / SALES');
|
2026-02-25 11:46:02 +01:00
|
|
|
|
|
|
|
|
if (missing.length > 0) {
|
|
|
|
|
throw new Error(`Invalid or missing critical columns in Sell-Out Report. Missing: ${missing.join(', ')}`);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-11 11:25:26 +01:00
|
|
|
const mapRowToRecord = (row: any, index: number): SalesRecord => {
|
2026-02-25 11:46:02 +01:00
|
|
|
// Add exact matches to the front of getColumnValue arrays
|
2026-04-16 11:41:58 +02:00
|
|
|
const rawCustomer = getColumnValue(row, ['NEW CUSTOMER', 'COUNTRY', 'Customer', 'Client', 'Account', 'Partner', 'Country', 'Market']) || 'Unknown';
|
|
|
|
|
const customer = mapCountryToMarketplace(rawCustomer);
|
2025-12-11 11:25:26 +01:00
|
|
|
const yearStr = getColumnValue(row, ['YEAR', 'Year', 'D']);
|
2025-12-11 14:03:33 +01:00
|
|
|
// Sanitize year string before parsing (remove commas/dots e.g. "2,023")
|
|
|
|
|
let year = parseInt(yearStr.replace(/[,.]/g, '')) || 0;
|
2026-03-03 13:07:59 +01:00
|
|
|
const monthStr = getColumnValue(row, ['MONTH', 'Month', 'Period', 'C', 'Date', 'DATE', 'Fecha', 'FECHA', 'Data', 'DATA']);
|
2025-12-11 11:25:26 +01:00
|
|
|
const month = normalizeMonth(monthStr);
|
2026-01-16 10:44:43 +01:00
|
|
|
|
2025-12-11 14:03:33 +01:00
|
|
|
// BACKFILL YEAR if missing but present in Month (e.g. "Apr-23")
|
|
|
|
|
if (year === 0 && month.includes('-')) {
|
|
|
|
|
const parts = month.split('-');
|
|
|
|
|
if (parts.length === 2) {
|
|
|
|
|
const yPart = parts[1];
|
|
|
|
|
// assume 20xx for 2 digits
|
|
|
|
|
if (yPart.length === 2) year = 2000 + parseInt(yPart);
|
|
|
|
|
else if (yPart.length === 4) year = parseInt(yPart);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-11 11:25:26 +01:00
|
|
|
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;
|
2026-02-25 12:38:58 +01:00
|
|
|
const line = getColumnValue(row, ['PRODUCT LINE', 'Product Line', 'LINE', 'LICENSE', 'License']) || 'Unassigned';
|
2026-01-16 10:44:43 +01:00
|
|
|
|
2025-12-11 11:25:26 +01:00
|
|
|
const asin = getColumnValue(row, [
|
2025-12-11 14:03:33 +01:00
|
|
|
'CUSTOMER REFERENCE', 'AMAZON ASIN', 'ASIN', 'Asin', 'PRODUCT ID', 'ITEM IDENTIFIER', 'ASIN NO.', 'Product ASIN', 'IDENTIFIER'
|
2025-12-11 11:25:26 +01:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const sku = getColumnValue(row, ['RAW ARTICLE NO.', 'SKU', 'Sku', 'Item No']);
|
2026-02-25 11:46:02 +01:00
|
|
|
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']);
|
2025-12-11 11:25:26 +01:00
|
|
|
|
|
|
|
|
const unitsRaw = getColumnValue(row, ['UNITS', 'Units', 'Quantity', 'Qty']);
|
2026-02-25 11:46:02 +01:00
|
|
|
const sellOutRaw = getColumnValue(row, ['AMOUNT_EUR', 'AMOUNT', 'Sell Out', 'SellOut', 'Revenue', 'Sales', 'Turnover']);
|
2025-12-11 11:25:26 +01:00
|
|
|
|
|
|
|
|
return {
|
2026-01-16 10:44:43 +01:00
|
|
|
id: `row-${index}`,
|
|
|
|
|
customer,
|
|
|
|
|
year,
|
|
|
|
|
month,
|
|
|
|
|
week,
|
|
|
|
|
asin,
|
|
|
|
|
sku,
|
|
|
|
|
title,
|
|
|
|
|
articleName,
|
|
|
|
|
units: parseUnits(unitsRaw),
|
|
|
|
|
sellOut: parseCurrency(sellOutRaw),
|
|
|
|
|
line
|
2025-12-11 11:25:26 +01:00
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const processCSV = (fileOrContent: File | string): Promise<SalesRecord[]> => {
|
2026-01-16 10:44:43 +01:00
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
// @ts-ignore
|
|
|
|
|
Papa.parse(fileOrContent, {
|
|
|
|
|
header: true,
|
|
|
|
|
skipEmptyLines: true,
|
|
|
|
|
complete: (results: any) => {
|
|
|
|
|
try {
|
2026-02-25 11:46:02 +01:00
|
|
|
if (results.meta && results.meta.fields) {
|
|
|
|
|
validateSellOutHeaders(results.meta.fields);
|
|
|
|
|
} else if (results.data && results.data.length > 0) {
|
|
|
|
|
validateSellOutHeaders(Object.keys(results.data[0]));
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
const data: SalesRecord[] = results.data.map((row: any, index: number) => {
|
|
|
|
|
return mapRowToRecord(row, index);
|
|
|
|
|
})
|
2026-04-20 12:54:40 +02:00
|
|
|
// Filter: Valid Year >= 2023 AND Allowed Customer AND non-zero units
|
|
|
|
|
// Excluding zero-unit records prevents ad spend or financial adjustments from inflating revenue.
|
|
|
|
|
.filter((r: SalesRecord) => r.year >= 2023 && isAllowedCustomer(r.customer) && r.units !== 0);
|
2026-01-16 10:44:43 +01:00
|
|
|
|
|
|
|
|
resolve(data);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
reject(err);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
error: (error: any) => reject(error)
|
|
|
|
|
});
|
2025-12-11 11:25:26 +01:00
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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: "" });
|
|
|
|
|
|
2026-02-25 11:46:02 +01:00
|
|
|
if (jsonData.length > 0) {
|
|
|
|
|
validateSellOutHeaders(Object.keys(jsonData[0] as object));
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-11 11:25:26 +01:00
|
|
|
const data: SalesRecord[] = jsonData.map((row: any, index: number) => {
|
|
|
|
|
return mapRowToRecord(row, index);
|
2025-12-11 14:03:33 +01:00
|
|
|
})
|
2026-04-20 12:54:40 +02:00
|
|
|
// Filter: Valid Year AND Allowed Customer AND non-zero units
|
|
|
|
|
.filter((r: SalesRecord) => r.year > 0 && isAllowedCustomer(r.customer) && r.units !== 0);
|
2025-12-11 11:25:26 +01:00
|
|
|
|
|
|
|
|
return data;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Error processing Excel file:", error);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-11 14:03:33 +01:00
|
|
|
// --- ADS DATA MAPPING ---
|
|
|
|
|
|
|
|
|
|
const mapCountryToMarketplace = (country: string): string => {
|
2025-12-11 15:08:01 +01:00
|
|
|
const c = String(country).toLowerCase().trim();
|
2026-04-16 11:41:58 +02:00
|
|
|
if (c === 'pan-eu' || c === 'paneu') return 'Pan-EU';
|
2025-12-11 15:08:01 +01:00
|
|
|
if (c.includes('germany') || c.includes('deutschland') || c.includes('de')) return 'Amazon DE';
|
|
|
|
|
if (c.includes('spain') || c.includes('espana') || c.includes('españa') || c.includes('es')) return 'Amazon ES';
|
|
|
|
|
if (c.includes('france') || c.includes('fr')) return 'Amazon FR';
|
|
|
|
|
if (c.includes('italy') || c.includes('italia') || c.includes('it')) return 'Amazon IT';
|
|
|
|
|
if (c.includes('kingdom') || c.includes('uk') || c === 'gb') return 'Amazon UK';
|
|
|
|
|
if (c.includes('netherlands') || c.includes('nederland') || c.includes('holland') || c.includes('nl')) return 'Amazon NL';
|
2025-12-11 14:03:33 +01:00
|
|
|
return country.toUpperCase(); // Fallback
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const processAdsCSV = (file: File): Promise<AdsRecord[]> => {
|
2026-01-16 10:44:43 +01:00
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
// @ts-ignore
|
|
|
|
|
Papa.parse(file, {
|
2026-02-25 14:01:11 +01:00
|
|
|
header: true,
|
2026-01-16 10:44:43 +01:00
|
|
|
skipEmptyLines: true,
|
|
|
|
|
complete: (results: any) => {
|
|
|
|
|
try {
|
|
|
|
|
const data: AdsRecord[] = [];
|
|
|
|
|
const rows = results.data;
|
|
|
|
|
const len = rows.length;
|
2025-12-11 14:03:33 +01:00
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
for (let i = 0; i < len; i++) {
|
|
|
|
|
const row = rows[i];
|
2026-02-25 14:01:11 +01:00
|
|
|
if (!row || Object.keys(row).length < 5) continue;
|
2025-12-11 14:03:33 +01:00
|
|
|
|
2026-02-25 14:01:11 +01:00
|
|
|
const countryRaw = getColumnValue(row, ['country', 'marketplace', 'portfolio', 'customer', 'kunde']);
|
|
|
|
|
const weekRaw = getColumnValue(row, ['week', 'woche', 'semana']);
|
|
|
|
|
const asin = getColumnValue(row, ['asin']);
|
2026-02-25 16:54:56 +01:00
|
|
|
const costRaw = getColumnValue(row, ['cost', 'spend', 'ausgaben', 'gasto', 'coste', /ad\s*spend/i, /^cost$/i, /^spend$/i, /(?<!of\s)cost(?!\s*of)/i]);
|
2026-02-25 14:56:15 +01:00
|
|
|
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]);
|
2026-02-26 08:30:23 +01:00
|
|
|
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]);
|
2025-12-11 14:03:33 +01:00
|
|
|
|
2026-03-12 16:59:11 +01:00
|
|
|
if (!countryRaw || weekRaw === undefined || weekRaw === '') continue;
|
2026-01-16 10:44:43 +01:00
|
|
|
|
2026-02-26 10:37:50 +01:00
|
|
|
const weekMatch = String(weekRaw).match(/\d+/);
|
|
|
|
|
const weekNum = weekMatch ? parseInt(weekMatch[0], 10) : NaN;
|
2026-01-21 09:45:50 +01:00
|
|
|
if (isNaN(weekNum) || weekNum < 1 || weekNum > 53) continue;
|
2026-01-16 10:44:43 +01:00
|
|
|
|
2026-01-21 09:45:50 +01:00
|
|
|
// For CSV without sheet names, assume current year
|
|
|
|
|
const currentYear = new Date().getFullYear();
|
2026-01-16 10:44:43 +01:00
|
|
|
|
|
|
|
|
data.push({
|
|
|
|
|
country: mapCountryToMarketplace(String(countryRaw)),
|
2026-01-21 09:45:50 +01:00
|
|
|
year: currentYear,
|
|
|
|
|
week: weekNum,
|
2026-03-12 16:59:11 +01:00
|
|
|
asin: asin ? String(asin).trim() : '',
|
2026-01-16 10:44:43 +01:00
|
|
|
cost: parseCurrency(String(costRaw)),
|
|
|
|
|
clicks: parseUnits(String(clicksRaw)),
|
|
|
|
|
impressions: parseUnits(String(impressionsRaw)),
|
2026-01-21 09:45:50 +01:00
|
|
|
cpc: parseCurrency(String(cpcRaw)),
|
|
|
|
|
ctr: parseCurrency(String(ctrRaw)),
|
|
|
|
|
acos: parseCurrency(String(acosRaw)),
|
|
|
|
|
conversions: parseUnits(String(conversionsRaw)),
|
2026-01-16 10:44:43 +01:00
|
|
|
attributedUnits30d: parseUnits(String(unitsRaw)),
|
2026-01-21 09:45:50 +01:00
|
|
|
attributedSales30d: parseCurrency(String(salesRaw)),
|
2026-01-16 10:44:43 +01:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
resolve(data);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
reject(err);
|
2025-12-11 15:08:01 +01:00
|
|
|
}
|
2026-01-16 10:44:43 +01:00
|
|
|
},
|
|
|
|
|
error: (error: any) => reject(error)
|
|
|
|
|
});
|
2025-12-11 14:03:33 +01:00
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-21 10:46:50 +01:00
|
|
|
export const processAdsExcel = async (fileOrBuffer: File | ArrayBuffer): Promise<AdsRecord[]> => {
|
2025-12-11 14:03:33 +01:00
|
|
|
try {
|
2026-01-21 10:46:50 +01:00
|
|
|
const arrayBuffer = fileOrBuffer instanceof File
|
|
|
|
|
? await fileOrBuffer.arrayBuffer()
|
|
|
|
|
: fileOrBuffer;
|
2026-01-21 10:52:30 +01:00
|
|
|
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
2026-01-21 09:45:50 +01:00
|
|
|
const allData: AdsRecord[] = [];
|
2026-01-16 10:44:43 +01:00
|
|
|
|
2026-01-21 09:45:50 +01:00
|
|
|
// 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;
|
2025-12-11 15:08:01 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-21 09:45:50 +01:00
|
|
|
const worksheet = workbook.Sheets[sheetName];
|
2026-02-25 14:01:11 +01:00
|
|
|
// Use default format (header mapping) instead of array rows
|
|
|
|
|
const jsonData: any[] = XLSX.utils.sheet_to_json(worksheet, { defval: "" });
|
2025-12-11 14:03:33 +01:00
|
|
|
|
2026-01-21 09:45:50 +01:00
|
|
|
console.log(`Processing sheet ${sheetName}: ${jsonData.length} rows`);
|
|
|
|
|
|
2026-02-25 16:54:56 +01:00
|
|
|
// 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(' | ')}`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 14:01:11 +01:00
|
|
|
// No need to skip index 0 since headers are mapped as keys automatically
|
|
|
|
|
for (let i = 0; i < jsonData.length; i++) {
|
2026-01-21 09:45:50 +01:00
|
|
|
const row = jsonData[i];
|
2026-02-25 14:01:11 +01:00
|
|
|
if (!row || Object.keys(row).length < 5) continue;
|
2026-01-21 09:45:50 +01:00
|
|
|
|
2026-02-25 14:01:11 +01:00
|
|
|
const countryRaw = getColumnValue(row, ['country', 'marketplace', 'portfolio', 'customer', 'kunde']);
|
|
|
|
|
const weekRaw = getColumnValue(row, ['week', 'woche', 'semana']);
|
|
|
|
|
const asin = getColumnValue(row, ['asin']);
|
2026-02-25 16:54:56 +01:00
|
|
|
const costRaw = getColumnValue(row, ['cost', 'spend', 'ausgaben', 'gasto', 'coste', /ad\s*spend/i, /^cost$/i, /^spend$/i, /(?<!of\s)cost(?!\s*of)/i]);
|
2026-02-25 14:56:15 +01:00
|
|
|
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]);
|
2026-02-26 08:30:23 +01:00
|
|
|
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]);
|
2026-01-21 09:45:50 +01:00
|
|
|
|
|
|
|
|
// Skip if missing essential data
|
2026-03-12 16:59:11 +01:00
|
|
|
if (!countryRaw || weekRaw === undefined || weekRaw === '') continue;
|
2026-01-21 09:45:50 +01:00
|
|
|
|
2026-02-26 10:37:50 +01:00
|
|
|
const weekMatch = String(weekRaw).match(/\d+/);
|
|
|
|
|
const weekNum = weekMatch ? parseInt(weekMatch[0], 10) : NaN;
|
2026-01-21 09:45:50 +01:00
|
|
|
if (isNaN(weekNum) || weekNum < 1 || weekNum > 53) continue;
|
|
|
|
|
|
2026-02-25 16:54:56 +01:00
|
|
|
// 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}"`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-21 09:45:50 +01:00
|
|
|
allData.push({
|
|
|
|
|
country: mapCountryToMarketplace(String(countryRaw)),
|
|
|
|
|
year,
|
|
|
|
|
week: weekNum,
|
2026-03-12 16:59:11 +01:00
|
|
|
asin: asin ? String(asin).trim() : '',
|
2026-01-21 09:45:50 +01:00
|
|
|
cost: parseCurrency(String(costRaw)),
|
|
|
|
|
clicks: parseUnits(String(clicksRaw)),
|
|
|
|
|
impressions: parseUnits(String(impressionsRaw)),
|
|
|
|
|
cpc: parseCurrency(String(cpcRaw)),
|
|
|
|
|
ctr: parseCurrency(String(ctrRaw)),
|
|
|
|
|
acos: parseCurrency(String(acosRaw)),
|
|
|
|
|
conversions: parseUnits(String(conversionsRaw)),
|
|
|
|
|
attributedUnits30d: parseUnits(String(unitsRaw)),
|
|
|
|
|
attributedSales30d: parseCurrency(String(salesRaw)),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`Total Ads records loaded: ${allData.length}`);
|
|
|
|
|
return allData;
|
2025-12-11 14:03:33 +01:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Error processing Ads Excel:", error);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-23 09:07:38 +01:00
|
|
|
// --- 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[] = [];
|
|
|
|
|
|
2026-02-19 15:34:00 +01:00
|
|
|
// 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: "" });
|
2026-01-23 09:07:38 +01:00
|
|
|
|
2026-02-19 15:34:00 +01:00
|
|
|
console.log(`Processing Traffic sheet "${sheetName}": ${jsonData.length} rows`);
|
2026-01-23 09:07:38 +01:00
|
|
|
|
2026-02-19 15:34:00 +01:00
|
|
|
// Detect if sheet name is a valid year (multi-sheet format)
|
|
|
|
|
const sheetYear = parseInt(sheetName);
|
|
|
|
|
const isYearSheet = !isNaN(sheetYear) && sheetYear >= 2020 && sheetYear <= 2100;
|
2026-01-23 09:07:38 +01:00
|
|
|
|
2026-02-19 15:34:00 +01:00
|
|
|
// Auto-detect column layout from header row
|
|
|
|
|
const headerRow = jsonData[0];
|
|
|
|
|
if (!headerRow) continue;
|
2026-01-23 09:07:38 +01:00
|
|
|
|
2026-02-19 15:34:00 +01:00
|
|
|
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');
|
2026-02-19 16:03:16 +01:00
|
|
|
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');
|
2026-01-23 09:07:38 +01:00
|
|
|
|
2026-02-19 15:34:00 +01:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-23 09:07:38 +01:00
|
|
|
|
2026-02-19 15:34:00 +01:00
|
|
|
const minCols = Math.max(asinIdx, countryIdx, gvIdx) + 1;
|
2026-01-23 09:07:38 +01:00
|
|
|
|
2026-02-19 15:34:00 +01:00
|
|
|
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));
|
2026-02-26 10:37:50 +01:00
|
|
|
const weekMatch = String(weekRaw).match(/\d+/);
|
|
|
|
|
const weekNum = weekMatch ? parseInt(weekMatch[0], 10) : NaN;
|
2026-02-19 15:34:00 +01:00
|
|
|
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)),
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-01-23 09:07:38 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`Total Traffic records loaded: ${allData.length}`);
|
|
|
|
|
return allData;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Error processing Traffic Excel:", error);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-02 15:22:12 +01:00
|
|
|
// --- 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[] = [];
|
|
|
|
|
|
2026-03-02 18:44:12 +01:00
|
|
|
// 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;
|
2026-03-02 15:22:12 +01:00
|
|
|
|
2026-03-02 18:44:12 +01:00
|
|
|
console.log(`[BSR] Processing sheet "${sheetName}": ${jsonData.length} rows. Columns:`, Object.keys(jsonData[0]));
|
2026-03-02 15:22:12 +01:00
|
|
|
|
2026-03-02 18:44:12 +01:00
|
|
|
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;
|
2026-03-02 15:22:12 +01:00
|
|
|
|
2026-03-02 18:44:12 +01:00
|
|
|
// Week: try direct week column first, else derive from Date column
|
|
|
|
|
let week = 0;
|
2026-03-02 18:53:45 +01:00
|
|
|
let isoDate: string | undefined;
|
2026-03-02 18:44:12 +01:00
|
|
|
const weekRaw = getColumnValue(row, ['week', 'woche', 'semana', 'week number', 'weeknumber', 'Week', 'Week Number']);
|
|
|
|
|
if (weekRaw) {
|
|
|
|
|
week = parseInt(String(weekRaw).match(/\d+/)?.[0] || '0', 10);
|
|
|
|
|
}
|
2026-03-02 18:53:45 +01:00
|
|
|
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) {
|
2026-03-02 18:44:12 +01:00
|
|
|
// 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;
|
2026-03-02 15:22:12 +01:00
|
|
|
|
2026-03-02 18:44:12 +01:00
|
|
|
allData.push({
|
|
|
|
|
week,
|
2026-03-02 18:53:45 +01:00
|
|
|
date: isoDate,
|
2026-03-02 18:44:12 +01:00
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-03-02 15:22:12 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-02 18:44:12 +01:00
|
|
|
console.log(`[BSR] Total records loaded: ${allData.length}`);
|
2026-03-02 15:22:12 +01:00
|
|
|
return allData;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Error processing BSR Excel:", error);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-11 14:03:33 +01:00
|
|
|
// --- DATA MERGING ---
|
|
|
|
|
|
2026-01-22 13:07:43 +01:00
|
|
|
export const mergeSalesAndAdsData = (
|
|
|
|
|
salesData: SalesRecord[],
|
|
|
|
|
adsData: AdsRecord[],
|
2026-01-23 09:07:38 +01:00
|
|
|
asinMetadataMap?: Map<string, { sku: string; title: string; line: string }>,
|
2026-01-28 11:01:05 +01:00
|
|
|
trafficData?: TrafficRecord[],
|
2026-03-10 13:58:44 +01:00
|
|
|
velocityMap?: Map<string, number>,
|
|
|
|
|
bsrData?: BSRRecord[]
|
2026-01-22 13:07:43 +01:00
|
|
|
): CombinedKPIs[] => {
|
2026-01-29 20:09:28 +01:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-22 11:37:53 +01:00
|
|
|
// Key for both sales and ads: ASIN|Customer|Year|Week
|
|
|
|
|
const createKey = (asin: string, customer: string, year: number, week: number) =>
|
2026-01-29 20:09:28 +01:00
|
|
|
`${getNorm(asin)}|${getNorm(customer)}|${year}|${week}`;
|
2026-01-22 11:37:53 +01:00
|
|
|
|
2026-01-27 21:43:25 +01:00
|
|
|
// Build traffic lookup map - Use a more efficient key
|
2026-01-23 09:07:38 +01:00
|
|
|
const trafficMap = new Map<string, number>();
|
|
|
|
|
if (trafficData) {
|
2026-01-27 21:43:25 +01:00
|
|
|
for (let i = 0; i < trafficData.length; i++) {
|
|
|
|
|
const t = trafficData[i];
|
2026-01-29 20:09:28 +01:00
|
|
|
const key = `${getNorm(t.asin)}|${getNorm(t.country)}|${t.year}|${t.week}`;
|
2026-01-27 21:43:25 +01:00
|
|
|
trafficMap.set(key, (trafficMap.get(key) || 0) + (t.glanceViews || 0));
|
|
|
|
|
}
|
2026-01-23 09:07:38 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-10 13:58:44 +01:00
|
|
|
// Build BSR lookup map
|
|
|
|
|
const bsrMap = new Map<string, number>();
|
|
|
|
|
if (bsrData) {
|
|
|
|
|
for (let i = 0; i < bsrData.length; i++) {
|
|
|
|
|
const b = bsrData[i];
|
|
|
|
|
const year = b.date ? parseInt(b.date.split('-')[0]) : new Date().getFullYear();
|
|
|
|
|
const country = mapCountryToMarketplace(b.market);
|
|
|
|
|
const key = `${getNorm(b.asin)}|${getNorm(country)}|${year}|${b.week}`;
|
|
|
|
|
if (b.detailLevelBSR !== null && b.detailLevelBSR !== undefined) {
|
|
|
|
|
bsrMap.set(key, b.detailLevelBSR);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-22 13:07:43 +01:00
|
|
|
// 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 }>();
|
2026-01-22 11:52:03 +01:00
|
|
|
|
2026-01-22 13:07:43 +01:00
|
|
|
// 2. Aggregate Sales by ASIN|Customer|Year|Week (combine all SKUs)
|
2026-01-22 11:37:53 +01:00
|
|
|
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;
|
|
|
|
|
}>();
|
|
|
|
|
|
2026-01-27 21:43:25 +01:00
|
|
|
for (let i = 0; i < salesData.length; i++) {
|
|
|
|
|
const sale = salesData[i];
|
2026-01-22 11:37:53 +01:00
|
|
|
const weekNum = sale.week || 0;
|
2026-01-27 21:43:25 +01:00
|
|
|
if (weekNum === 0) continue;
|
2026-01-22 11:37:53 +01:00
|
|
|
|
2026-01-29 20:09:28 +01:00
|
|
|
const asinUpper = getNorm(sale.asin);
|
|
|
|
|
const key = `${asinUpper}|${getNorm(sale.customer)}|${sale.year}|${weekNum}`;
|
2026-01-22 11:37:53 +01:00
|
|
|
|
2026-01-22 13:07:43 +01:00
|
|
|
// If no global map provided, build it on the fly
|
|
|
|
|
if (!asinMetadataMap) {
|
2026-01-27 21:43:25 +01:00
|
|
|
const existingMeta = asinMetadata.get(asinUpper);
|
2026-01-22 13:07:43 +01:00
|
|
|
if (!existingMeta || (sale.title && sale.title.length > (existingMeta.title?.length || 0))) {
|
2026-01-27 21:43:25 +01:00
|
|
|
asinMetadata.set(asinUpper, { sku: sale.sku, title: sale.title, line: sale.line });
|
2026-01-22 13:07:43 +01:00
|
|
|
}
|
2026-01-22 11:52:03 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-27 21:43:25 +01:00
|
|
|
const existing = salesMap.get(key);
|
|
|
|
|
if (existing) {
|
2026-01-22 11:37:53 +01:00
|
|
|
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
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-01-27 21:43:25 +01:00
|
|
|
}
|
2026-01-22 11:37:53 +01:00
|
|
|
|
2026-01-22 13:07:43 +01:00
|
|
|
// 3. Aggregate Ads by ASIN|Customer|Year|Week
|
2025-12-11 14:03:33 +01:00
|
|
|
const adsMap = new Map<string, AdsRecord>();
|
2026-01-27 21:43:25 +01:00
|
|
|
for (let i = 0; i < adsData.length; i++) {
|
|
|
|
|
const ad = adsData[i];
|
2026-01-29 20:09:28 +01:00
|
|
|
const key = `${getNorm(ad.asin)}|${getNorm(ad.country)}|${ad.year}|${ad.week}`;
|
2026-01-27 21:43:25 +01:00
|
|
|
const existing = adsMap.get(key);
|
|
|
|
|
if (existing) {
|
2025-12-11 14:03:33 +01:00
|
|
|
existing.cost += ad.cost;
|
|
|
|
|
existing.clicks += ad.clicks;
|
|
|
|
|
existing.impressions += ad.impressions;
|
|
|
|
|
existing.attributedSales30d += ad.attributedSales30d;
|
|
|
|
|
existing.attributedUnits30d += ad.attributedUnits30d;
|
2026-01-21 09:45:50 +01:00
|
|
|
existing.conversions += ad.conversions;
|
2025-12-11 14:03:33 +01:00
|
|
|
} else {
|
|
|
|
|
adsMap.set(key, { ...ad });
|
|
|
|
|
}
|
2026-01-27 21:43:25 +01:00
|
|
|
}
|
2025-12-11 14:03:33 +01:00
|
|
|
|
2026-01-22 11:37:53 +01:00
|
|
|
const mergedData: CombinedKPIs[] = [];
|
|
|
|
|
const processedKeys = new Set<string>();
|
|
|
|
|
|
2026-01-28 11:01:05 +01:00
|
|
|
// 4. Create ONE record per ASIN/Customer/Year/Week from sales
|
2026-01-22 11:37:53 +01:00
|
|
|
salesMap.forEach((sale, key) => {
|
|
|
|
|
processedKeys.add(key);
|
|
|
|
|
const ad = adsMap.get(key);
|
|
|
|
|
|
|
|
|
|
const adCost = ad?.cost || 0;
|
|
|
|
|
const adClicks = ad?.clicks || 0;
|
|
|
|
|
const adImpressions = ad?.impressions || 0;
|
|
|
|
|
const adSales = ad?.attributedSales30d || 0;
|
|
|
|
|
const adUnits = ad?.attributedUnits30d || 0;
|
2025-12-11 14:03:33 +01:00
|
|
|
|
|
|
|
|
const salesTotal = sale.sellOut;
|
|
|
|
|
const unitsTotal = sale.units;
|
2026-01-22 11:37:53 +01:00
|
|
|
const salesOrganic = Math.max(0, salesTotal - adSales);
|
|
|
|
|
const unitsOrganic = Math.max(0, unitsTotal - adUnits);
|
2025-12-11 14:03:33 +01:00
|
|
|
|
2026-01-22 11:37:53 +01:00
|
|
|
const acos = adSales > 0 ? (adCost / adSales) * 100 : 0;
|
|
|
|
|
const tacos = salesTotal > 0 ? (adCost / salesTotal) * 100 : 0;
|
|
|
|
|
const roas = adCost > 0 ? adSales / adCost : 0;
|
|
|
|
|
const ctr = adImpressions > 0 ? (adClicks / adImpressions) * 100 : 0;
|
|
|
|
|
const cpc = adClicks > 0 ? adCost / adClicks : 0;
|
|
|
|
|
const cvrUnits = adClicks > 0 ? (adUnits / adClicks) * 100 : 0;
|
2026-01-28 11:01:05 +01:00
|
|
|
const avgWeeklySales = velocityMap?.get(sale.asin.trim().toUpperCase()) || 0;
|
2026-01-16 10:44:43 +01:00
|
|
|
|
2026-01-22 11:37:53 +01:00
|
|
|
mergedData.push({
|
|
|
|
|
id: `merged-${key}`,
|
2025-12-11 14:03:33 +01:00
|
|
|
marketplace: sale.customer,
|
2026-01-21 11:04:53 +01:00
|
|
|
customer: sale.customer,
|
2025-12-11 14:03:33 +01:00
|
|
|
month: sale.month,
|
2026-01-22 11:37:53 +01:00
|
|
|
week: sale.week,
|
2025-12-11 14:03:33 +01:00
|
|
|
year: sale.year,
|
2026-01-29 20:09:28 +01:00
|
|
|
asin: getNorm(sale.asin),
|
2025-12-11 14:03:33 +01:00
|
|
|
title: sale.title,
|
|
|
|
|
line: sale.line,
|
|
|
|
|
sku: sale.sku,
|
|
|
|
|
salesTotal,
|
|
|
|
|
unitsTotal,
|
2026-01-22 11:37:53 +01:00
|
|
|
salesAds: adSales,
|
|
|
|
|
unitsAds: adUnits,
|
|
|
|
|
cost: adCost,
|
|
|
|
|
clicks: adClicks,
|
|
|
|
|
impressions: adImpressions,
|
2026-01-22 13:25:06 +01:00
|
|
|
conversions: ad?.conversions || 0,
|
2025-12-11 14:03:33 +01:00
|
|
|
salesOrganic,
|
|
|
|
|
unitsOrganic,
|
2026-01-22 11:37:53 +01:00
|
|
|
paidSalesShare: salesTotal > 0 ? (adSales / salesTotal) * 100 : 0,
|
|
|
|
|
organicSalesShare: salesTotal > 0 ? (salesOrganic / salesTotal) * 100 : 0,
|
2025-12-11 14:03:33 +01:00
|
|
|
acos,
|
|
|
|
|
tacos,
|
|
|
|
|
roas,
|
|
|
|
|
ctr,
|
|
|
|
|
cpc,
|
2026-01-23 09:07:38 +01:00
|
|
|
cvrUnits,
|
2026-01-28 10:34:46 +01:00
|
|
|
glanceViews: trafficMap.get(key) || 0,
|
2026-03-10 13:58:44 +01:00
|
|
|
detailLevelBSR: bsrMap.get(key),
|
2026-01-28 10:34:46 +01:00
|
|
|
avgWeeklySales
|
2026-01-22 11:37:53 +01:00
|
|
|
});
|
2025-12-11 14:03:33 +01:00
|
|
|
});
|
|
|
|
|
|
2026-01-22 13:07:43 +01:00
|
|
|
// 5. Add ads-only records - use provided metadata for line/title
|
2026-01-22 11:37:53 +01:00
|
|
|
adsMap.forEach((ad, key) => {
|
|
|
|
|
if (!processedKeys.has(key)) {
|
2026-01-29 20:09:28 +01:00
|
|
|
const asin = getNorm(ad.asin);
|
2026-01-22 13:07:43 +01:00
|
|
|
const meta = asinMetadata.get(asin);
|
2026-01-28 11:01:05 +01:00
|
|
|
const avgWeeklySales = velocityMap?.get(asin) || 0;
|
2026-01-22 09:24:40 +01:00
|
|
|
|
|
|
|
|
mergedData.push({
|
2026-01-22 11:37:53 +01:00
|
|
|
id: `ads-only-${key}`,
|
2026-01-22 09:24:40 +01:00
|
|
|
marketplace: ad.country,
|
|
|
|
|
customer: ad.country,
|
2026-01-22 11:37:53 +01:00
|
|
|
month: 'N/A',
|
2026-01-22 09:24:40 +01:00
|
|
|
week: ad.week,
|
|
|
|
|
year: ad.year,
|
2026-01-22 13:07:43 +01:00
|
|
|
asin: asin,
|
2026-01-22 11:37:53 +01:00
|
|
|
title: meta?.title || ad.asin,
|
|
|
|
|
line: meta?.line || 'Unassigned',
|
|
|
|
|
sku: meta?.sku || '',
|
2026-01-22 09:24:40 +01:00
|
|
|
salesTotal: 0,
|
|
|
|
|
unitsTotal: 0,
|
2026-02-25 16:34:55 +01:00
|
|
|
salesAds: ad.attributedSales30d || 0,
|
|
|
|
|
unitsAds: ad.attributedUnits30d || 0,
|
|
|
|
|
cost: ad.cost || 0,
|
|
|
|
|
clicks: ad.clicks || 0,
|
|
|
|
|
impressions: ad.impressions || 0,
|
|
|
|
|
conversions: ad.conversions || 0,
|
2026-01-22 09:24:40 +01:00
|
|
|
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,
|
2026-01-23 09:07:38 +01:00
|
|
|
cvrUnits: ad.clicks > 0 ? (ad.attributedUnits30d / ad.clicks) * 100 : 0,
|
2026-01-28 10:34:46 +01:00
|
|
|
glanceViews: trafficMap.get(key) || 0,
|
2026-03-10 13:58:44 +01:00
|
|
|
detailLevelBSR: bsrMap.get(key),
|
2026-01-28 10:34:46 +01:00
|
|
|
avgWeeklySales
|
2026-01-22 09:24:40 +01:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-11 14:03:33 +01:00
|
|
|
return mergedData;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
2026-01-22 11:37:53 +01:00
|
|
|
|
2025-12-11 14:03:33 +01:00
|
|
|
// --- EXISTING HELPERS ---
|
|
|
|
|
|
2026-01-27 19:03:42 +01:00
|
|
|
// Helper to check stock filter
|
|
|
|
|
const checkStockFilter = (sku: string, filters: string[], stockMap?: Map<string, number>): boolean => {
|
2026-01-27 20:22:58 +01:00
|
|
|
if (!filters || !Array.isArray(filters) || filters.length === 0) return true;
|
2026-01-27 19:03:42 +01:00
|
|
|
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;
|
|
|
|
|
|
2026-01-30 19:28:45 +01:00
|
|
|
return checkNumericConditions(stockValue, filters);
|
2026-01-27 19:03:42 +01:00
|
|
|
};
|
|
|
|
|
|
2026-01-28 10:18:08 +01:00
|
|
|
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;
|
|
|
|
|
|
2026-01-30 19:28:45 +01:00
|
|
|
return checkNumericConditions(stockValue, filters);
|
2026-01-28 10:18:08 +01:00
|
|
|
};
|
|
|
|
|
|
2026-01-22 14:13:16 +01:00
|
|
|
// Filter Ads Data by Country, Year, Week, ASIN, SKU, and Product Line
|
2026-01-22 13:07:43 +01:00
|
|
|
export const filterAdsData = (
|
|
|
|
|
adsData: AdsRecord[],
|
|
|
|
|
filters: FilterState,
|
2026-01-27 19:03:42 +01:00
|
|
|
asinMetadata?: Map<string, { sku: string; line: string }>,
|
2026-01-28 10:18:08 +01:00
|
|
|
stockMap?: Map<string, number>,
|
|
|
|
|
vendorStockMap?: Map<string, { eu: number; uk: number }>,
|
|
|
|
|
top50Mode: 'eu' | 'uk' = 'eu'
|
2026-01-22 13:07:43 +01:00
|
|
|
): AdsRecord[] => {
|
2026-01-21 10:46:50 +01:00
|
|
|
return adsData.filter(ad => {
|
2026-01-22 14:13:16 +01:00
|
|
|
const asin = ad.asin.trim().toUpperCase();
|
|
|
|
|
const meta = asinMetadata?.get(asin);
|
|
|
|
|
|
2026-01-21 10:46:50 +01:00
|
|
|
// Country/Customer match (ads use 'country', sales use 'customer')
|
2026-01-21 16:28:56 +01:00
|
|
|
const countryMatch = filters.customer.length === 0
|
2026-04-16 11:49:48 +02:00
|
|
|
? true // Show all countries by default
|
2026-01-21 16:28:56 +01:00
|
|
|
: filters.customer.some(c => c.toUpperCase() === ad.country.toUpperCase());
|
2026-01-21 10:46:50 +01:00
|
|
|
|
|
|
|
|
// Year match
|
|
|
|
|
const yearMatch = filters.year.length === 0 ||
|
|
|
|
|
filters.year.includes(ad.year.toString());
|
|
|
|
|
|
|
|
|
|
// Week match (filters use "W1", "W2" format)
|
|
|
|
|
const weekStr = `W${ad.week}`;
|
|
|
|
|
const weekMatch = filters.week.length === 0 || filters.week.includes(weekStr);
|
|
|
|
|
|
|
|
|
|
// ASIN match
|
|
|
|
|
const asinMatch = filters.asin.length === 0 ||
|
2026-01-22 14:13:16 +01:00
|
|
|
filters.asin.some(a => a.toUpperCase() === asin);
|
|
|
|
|
|
|
|
|
|
// SKU match (Requires metadata)
|
|
|
|
|
let skuMatch = true;
|
|
|
|
|
if (filters.sku.length > 0) {
|
|
|
|
|
skuMatch = meta ? filters.sku.some(s => s.toUpperCase() === meta.sku.toUpperCase()) : false;
|
|
|
|
|
}
|
2026-01-21 10:46:50 +01:00
|
|
|
|
2026-01-22 13:07:43 +01:00
|
|
|
// Line match (Requires metadata)
|
|
|
|
|
let lineMatch = true;
|
2026-01-22 14:13:16 +01:00
|
|
|
if (filters.line.length > 0) {
|
2026-01-22 13:07:43 +01:00
|
|
|
lineMatch = meta ? filters.line.includes(meta.line) : false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 19:03:42 +01:00
|
|
|
// Stock match
|
|
|
|
|
const stockMatch = checkStockFilter(meta?.sku || '', filters.stock, stockMap);
|
2026-01-28 10:18:08 +01:00
|
|
|
const vendorStockMatch = checkVendorStockFilter(asin, filters.vendorStock, vendorStockMap, top50Mode);
|
2026-01-27 19:03:42 +01:00
|
|
|
|
2026-02-06 09:22:24 +01:00
|
|
|
// 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;
|
2026-01-21 10:46:50 +01:00
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-28 10:18:08 +01:00
|
|
|
export const filterData = (
|
|
|
|
|
data: SalesRecord[],
|
|
|
|
|
filters: FilterState,
|
|
|
|
|
stockMap?: Map<string, number>,
|
|
|
|
|
vendorStockMap?: Map<string, { eu: number; uk: number }>,
|
|
|
|
|
top50Mode: 'eu' | 'uk' = 'eu'
|
|
|
|
|
): SalesRecord[] => {
|
2026-02-09 12:00:39 +01:00
|
|
|
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
|
2026-04-20 13:04:41 +02:00
|
|
|
return result.filter(item => {
|
|
|
|
|
// 0. Core Business Rules (Revenue Integrity)
|
|
|
|
|
// These filters ensure that only valid Amazon sales are aggregated,
|
|
|
|
|
// excluding ad spend and marketing records that might be present in the raw source.
|
|
|
|
|
if (!isAllowedCustomer(item.customer)) return false;
|
|
|
|
|
if (item.units === 0) return false;
|
|
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
// 1. Month Logic: Handle "Apr-23" matching "Apr" filter
|
|
|
|
|
const recordMonth = item.month; // e.g. "Apr-23"
|
|
|
|
|
const pureMonth = recordMonth.split('-')[0]; // "Apr"
|
2025-12-11 11:25:26 +01:00
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
// 2. Filter Checks
|
2026-01-21 16:28:56 +01:00
|
|
|
const customerMatch = filters.customer.length === 0
|
2026-04-16 11:49:48 +02:00
|
|
|
? true // When no filter is selected, show EVERYTHING by default
|
2026-01-21 16:28:56 +01:00
|
|
|
: filters.customer.includes(item.customer);
|
2026-01-16 10:44:43 +01:00
|
|
|
const yearMatch = filters.year.length === 0 || filters.year.includes(item.year.toString());
|
2025-12-11 11:25:26 +01:00
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
// 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);
|
|
|
|
|
|
2026-02-06 09:22:24 +01:00
|
|
|
// 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)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 11:47:59 +01:00
|
|
|
// 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));
|
|
|
|
|
|
2026-01-27 19:03:42 +01:00
|
|
|
// Stock match
|
|
|
|
|
const stockMatch = checkStockFilter(item.sku, filters.stock, stockMap);
|
2026-01-28 10:18:08 +01:00
|
|
|
const vendorStockMatch = checkVendorStockFilter(item.asin, filters.vendorStock, vendorStockMap, top50Mode);
|
2026-01-27 19:03:42 +01:00
|
|
|
|
2026-02-06 09:22:24 +01:00
|
|
|
return customerMatch && yearMatch && monthMatch && lineMatch && asinMatch && skuMatch && titleMatch && bulkMatch && weekMatch && stockMatch && vendorStockMatch;
|
2026-01-16 10:44:43 +01:00
|
|
|
});
|
2025-12-11 11:25:26 +01:00
|
|
|
};
|
|
|
|
|
|
2026-03-03 10:27:12 +01:00
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-11 11:25:26 +01:00
|
|
|
const calculateSeasonality = (data: SalesRecord[]): { seasonality: SeasonalityPoint[], seasonalityUnits: SeasonalityPoint[], years: string[] } => {
|
2026-01-16 10:44:43 +01:00
|
|
|
const seasonalityMap = new Map<string, SeasonalityPoint>();
|
|
|
|
|
const seasonalityUnitsMap = new Map<string, SeasonalityPoint>();
|
|
|
|
|
const yearsSet = new Set<string>();
|
2025-12-11 11:25:26 +01:00
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
// Initialize all months
|
|
|
|
|
MONTH_ORDER.forEach(m => {
|
|
|
|
|
seasonalityMap.set(m, { name: m });
|
|
|
|
|
seasonalityUnitsMap.set(m, { name: m });
|
|
|
|
|
});
|
2025-12-11 11:25:26 +01:00
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
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);
|
2025-12-11 11:25:26 +01:00
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
// We need to match month name purely (Jan, Feb) for the X Axis, ignoring year
|
|
|
|
|
const pureMonth = monthName.split('-')[0];
|
2025-12-11 11:25:26 +01:00
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
if (seasonalityMap.has(pureMonth)) {
|
|
|
|
|
// Sell Out
|
|
|
|
|
const entrySO = seasonalityMap.get(pureMonth)!;
|
|
|
|
|
const currentValSO = (entrySO[yearStr] as number) || 0;
|
|
|
|
|
entrySO[yearStr] = currentValSO + record.sellOut;
|
2025-12-11 11:25:26 +01:00
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
// Units
|
|
|
|
|
const entryUnits = seasonalityUnitsMap.get(pureMonth)!;
|
|
|
|
|
const currentValUnits = (entryUnits[yearStr] as number) || 0;
|
|
|
|
|
entryUnits[yearStr] = currentValUnits + record.units;
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-12-11 11:25:26 +01:00
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
const seasonality = Array.from(seasonalityMap.values());
|
|
|
|
|
const seasonalityUnits = Array.from(seasonalityUnitsMap.values());
|
|
|
|
|
const years = Array.from(yearsSet).sort();
|
|
|
|
|
|
|
|
|
|
return { seasonality, seasonalityUnits, years };
|
2025-12-11 11:25:26 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const calculateTopLinesSplit = (data: SalesRecord[]): YearlySplitData[] => {
|
2026-01-16 10:44:43 +01:00
|
|
|
// 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);
|
|
|
|
|
});
|
2025-12-11 11:25:26 +01:00
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
// Return ALL lines
|
|
|
|
|
const topLines = Array.from(lineTotals.entries())
|
|
|
|
|
.sort((a, b) => b[1] - a[1])
|
|
|
|
|
.map(([line]) => line);
|
2025-12-11 11:25:26 +01:00
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
// 2. Aggregate data by Year
|
|
|
|
|
const resultMap = new Map<string, YearlySplitData>();
|
2025-12-11 11:25:26 +01:00
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
topLines.forEach(line => {
|
|
|
|
|
resultMap.set(line, { name: line });
|
|
|
|
|
});
|
2025-12-11 11:25:26 +01:00
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
data.forEach(item => {
|
|
|
|
|
if (resultMap.has(item.line)) {
|
|
|
|
|
const entry = resultMap.get(item.line)!;
|
|
|
|
|
const keyVal = `${item.year}_value`;
|
|
|
|
|
const keyUnits = `${item.year}_units`;
|
2025-12-11 11:25:26 +01:00
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
entry[keyVal] = ((entry[keyVal] as number) || 0) + item.sellOut;
|
|
|
|
|
entry[keyUnits] = ((entry[keyUnits] as number) || 0) + item.units;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return Array.from(resultMap.values());
|
2025-12-11 11:25:26 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
});
|
2026-01-16 10:44:43 +01:00
|
|
|
|
|
|
|
|
let sortedKeys = Array.from(totals.entries()).sort((a, b) => b[1] - a[1]).map(e => e[0]);
|
2025-12-11 11:25:26 +01:00
|
|
|
if (limit) sortedKeys = sortedKeys.slice(0, limit);
|
|
|
|
|
const keySet = new Set(sortedKeys);
|
2026-01-16 10:44:43 +01:00
|
|
|
|
2025-12-11 11:25:26 +01:00
|
|
|
const resultMap = new Map<string, YearlySplitData>();
|
|
|
|
|
sortedKeys.forEach(k => resultMap.set(k, { name: k }));
|
2026-01-16 10:44:43 +01:00
|
|
|
|
2025-12-11 11:25:26 +01:00
|
|
|
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];
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-01-16 10:44:43 +01:00
|
|
|
|
2025-12-11 11:25:26 +01:00
|
|
|
return Array.from(resultMap.values());
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Renamed from calculateMovers
|
|
|
|
|
export const calculateLineMovers = (data: SalesRecord[]): { topMovers: LineGrowthMetric[], bottomMovers: LineGrowthMetric[], comparisonPeriods: { current: string, previous: string } } => {
|
2026-01-16 10:44:43 +01:00
|
|
|
const lineYearMap = new Map<string, Map<number, { sellOut: number; units: number }>>();
|
|
|
|
|
const allYears = new Set<number>();
|
2025-12-11 11:25:26 +01:00
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
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
|
2025-12-11 11:25:26 +01:00
|
|
|
});
|
2026-01-16 10:44:43 +01:00
|
|
|
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' } };
|
2025-12-11 11:25:26 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
const currentYear = sortedYears[0];
|
|
|
|
|
const prevYear = sortedYears[1];
|
2025-12-11 11:25:26 +01:00
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
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() }
|
|
|
|
|
};
|
2025-12-11 11:25:26 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 = (
|
2026-01-16 10:44:43 +01:00
|
|
|
currentFilteredData: SalesRecord[],
|
|
|
|
|
selectedCustomerFromPage: string | null,
|
2025-12-11 11:25:26 +01:00
|
|
|
currentComparisonYearFromPage: number | null
|
|
|
|
|
): { topMovers: ItemGrowthMetric[], bottomMovers: ItemGrowthMetric[], comparisonPeriods: { current: string, previous: string } } => {
|
2026-01-16 10:44:43 +01:00
|
|
|
|
2025-12-11 11:25:26 +01:00
|
|
|
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;
|
|
|
|
|
}
|
2026-01-16 10:44:43 +01:00
|
|
|
|
2025-12-11 11:25:26 +01:00
|
|
|
// 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
|
2026-01-16 10:44:43 +01:00
|
|
|
|
2025-12-11 11:25:26 +01:00
|
|
|
const bottomMovers = metrics
|
|
|
|
|
.sort((a, b) => a.unitsGrowthValue - b.unitsGrowthValue) // Sort by unitsGrowthValue
|
|
|
|
|
.slice(0, 20); // Top 20 Losers
|
|
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
return {
|
|
|
|
|
topMovers,
|
2025-12-11 11:25:26 +01:00
|
|
|
bottomMovers,
|
|
|
|
|
comparisonPeriods: { current: currentYear.toString(), previous: prevYear.toString() }
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const aggregateData = (data: SalesRecord[]): AggregatedData => {
|
2026-01-16 10:44:43 +01:00
|
|
|
const totalSellOut = data.reduce((acc, curr) => acc + curr.sellOut, 0);
|
|
|
|
|
const totalUnits = data.reduce((acc, curr) => acc + curr.units, 0);
|
2025-12-11 11:25:26 +01:00
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
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;
|
2025-12-11 11:25:26 +01:00
|
|
|
});
|
|
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
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);
|
2025-12-11 11:25:26 +01:00
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
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);
|
2025-12-11 11:25:26 +01:00
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
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
|
|
|
|
|
};
|
2025-12-11 11:25:26 +01:00
|
|
|
};
|
|
|
|
|
|
2026-01-20 12:59:35 +01:00
|
|
|
/**
|
|
|
|
|
* Groups Pan-EU countries (Amazon DE, IT, FR, ES) into a single "Pan-EU" customer
|
|
|
|
|
* when no customer filter is applied. This provides a consolidated view of European
|
|
|
|
|
* markets while keeping UK and SC separate.
|
|
|
|
|
*
|
|
|
|
|
* @param data - Array of sales records
|
|
|
|
|
* @param hasCustomerFilter - Whether a customer filter is currently applied
|
|
|
|
|
* @returns Processed data with Pan-EU grouping applied if appropriate
|
|
|
|
|
*/
|
|
|
|
|
export const applyPanEUGrouping = (
|
|
|
|
|
data: SalesRecord[],
|
|
|
|
|
hasCustomerFilter: boolean
|
|
|
|
|
): SalesRecord[] => {
|
|
|
|
|
// If customer filter is applied, don't group - show selected countries as-is
|
|
|
|
|
if (hasCustomerFilter) {
|
|
|
|
|
return data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Replace Pan-EU country names with "Pan-EU" for grouping
|
|
|
|
|
return data.map(record => {
|
|
|
|
|
if (PAN_EU_COUNTRIES.includes(record.customer)) {
|
|
|
|
|
return { ...record, customer: 'Pan-EU' };
|
|
|
|
|
}
|
|
|
|
|
return record;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-11 11:25:26 +01:00
|
|
|
export const getUniqueValues = (data: SalesRecord[], field: keyof SalesRecord): string[] => {
|
2026-01-16 10:44:43 +01:00
|
|
|
const values = new Set(data.map(item => String(item[field])));
|
|
|
|
|
return Array.from(values).sort();
|
2025-12-11 11:25:26 +01:00
|
|
|
};
|
|
|
|
|
|
2026-01-21 11:02:12 +01:00
|
|
|
export const pivotSalesData = (data: any[], dimensions: string[] = ['title', 'customer', 'line', 'sku']): { rows: PivotRow[], years: string[] } => {
|
2025-12-11 11:25:26 +01:00
|
|
|
// 1. Determine all years present in the data for columns
|
|
|
|
|
const yearsSet = new Set(data.map(d => d.year));
|
2026-01-16 10:44:43 +01:00
|
|
|
const years = Array.from(yearsSet).sort((a, b) => b - a).map(String);
|
2025-12-11 11:25:26 +01:00
|
|
|
|
|
|
|
|
const map = new Map<string, PivotRow>();
|
|
|
|
|
|
|
|
|
|
data.forEach(record => {
|
|
|
|
|
// Group by Dynamic Dimensions
|
2026-01-21 11:04:53 +01:00
|
|
|
// Use a fallback for 'customer' dimension as some records use 'marketplace'
|
2026-01-29 20:09:28 +01:00
|
|
|
const keyParts = new Array(dimensions.length);
|
|
|
|
|
for (let i = 0; i < dimensions.length; i++) {
|
|
|
|
|
const dim = dimensions[i];
|
2026-01-30 09:10:02 +01:00
|
|
|
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;
|
|
|
|
|
}
|
2026-01-29 20:09:28 +01:00
|
|
|
}
|
2025-12-11 11:25:26 +01:00
|
|
|
const key = keyParts.join('||');
|
2026-01-16 10:44:43 +01:00
|
|
|
|
2025-12-11 11:25:26 +01:00
|
|
|
if (!map.has(key)) {
|
|
|
|
|
map.set(key, {
|
|
|
|
|
id: key,
|
2026-01-29 15:43:18 +01:00
|
|
|
customer: record.customer || record.marketplace || '',
|
|
|
|
|
line: record.line || '',
|
|
|
|
|
title: record.title || '',
|
|
|
|
|
articleName: record.articleName || '',
|
|
|
|
|
sku: record.sku || '',
|
|
|
|
|
asin: record.asin || '',
|
2025-12-11 11:25:26 +01:00
|
|
|
// Initialize 12 months with empty year maps
|
|
|
|
|
months: Array(12).fill(null).map((_, i) => ({
|
2026-01-16 10:44:43 +01:00
|
|
|
monthIndex: i,
|
2025-12-11 11:25:26 +01:00
|
|
|
byYear: {}
|
|
|
|
|
})),
|
2026-01-21 11:02:12 +01:00
|
|
|
totalsByYear: {},
|
|
|
|
|
adsByYear: {}
|
2025-12-11 11:25:26 +01:00
|
|
|
});
|
|
|
|
|
}
|
2026-01-16 10:44:43 +01:00
|
|
|
|
2025-12-11 11:25:26 +01:00
|
|
|
const row = map.get(key)!;
|
2026-01-21 11:02:12 +01:00
|
|
|
const monthRaw = record.month || '';
|
|
|
|
|
const monthPart = monthRaw.split('-')[0]; // Handle "Apr-23" -> "Apr"
|
2025-12-11 11:25:26 +01:00
|
|
|
const monthIdx = MONTH_ORDER.indexOf(monthPart);
|
|
|
|
|
const yearStr = record.year.toString();
|
2026-01-16 10:44:43 +01:00
|
|
|
|
2025-12-11 11:25:26 +01:00
|
|
|
// 1. Update Row Totals for Year
|
|
|
|
|
if (!row.totalsByYear[yearStr]) {
|
|
|
|
|
row.totalsByYear[yearStr] = { sellOut: 0, units: 0 };
|
|
|
|
|
}
|
2026-01-21 11:02:12 +01:00
|
|
|
row.totalsByYear[yearStr].sellOut += (record.sellOut || record.salesTotal || 0);
|
|
|
|
|
row.totalsByYear[yearStr].units += (record.units || record.unitsTotal || 0);
|
2026-01-16 10:44:43 +01:00
|
|
|
|
2026-01-21 11:02:12 +01:00
|
|
|
// 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
|
2025-12-11 11:25:26 +01:00
|
|
|
if (monthIdx !== -1) {
|
|
|
|
|
const m = row.months[monthIdx];
|
|
|
|
|
if (!m.byYear[yearStr]) {
|
|
|
|
|
m.byYear[yearStr] = { sellOut: 0, units: 0 };
|
|
|
|
|
}
|
2026-01-21 11:02:12 +01:00
|
|
|
m.byYear[yearStr].sellOut += (record.sellOut || record.salesTotal || 0);
|
|
|
|
|
m.byYear[yearStr].units += (record.units || record.unitsTotal || 0);
|
2025-12-11 11:25:26 +01:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-16 10:44:43 +01:00
|
|
|
return {
|
|
|
|
|
rows: Array.from(map.values()),
|
2025-12-11 11:25:26 +01:00
|
|
|
years
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-27 15:55:59 +01:00
|
|
|
export const generateXLSX = (rows: PivotRow[], dimensions: string[], years: string[]) => {
|
|
|
|
|
// Flatten PivotRows into Excel-friendly objects
|
2025-12-11 11:25:26 +01:00
|
|
|
const flatData = rows.map(row => {
|
|
|
|
|
const flatRow: any = {};
|
2026-01-16 10:44:43 +01:00
|
|
|
|
2026-02-23 18:18:02 +01:00
|
|
|
// Always ensure ASIN and SKU are exported as foundational identifiers
|
|
|
|
|
flatRow['ASIN'] = row.asin || '-';
|
|
|
|
|
flatRow['SKU'] = row.sku || '-';
|
|
|
|
|
|
|
|
|
|
// Add User Selected Dimension Columns
|
2025-12-11 11:25:26 +01:00
|
|
|
dimensions.forEach(dim => {
|
2026-02-23 18:18:02 +01:00
|
|
|
if (dim.toLowerCase() === 'asin' || dim.toLowerCase() === 'sku') return; // Skip if already explicitly set
|
|
|
|
|
|
2025-12-11 11:25:26 +01:00
|
|
|
let header = dim;
|
|
|
|
|
if (dim === 'line') header = 'Product Line';
|
|
|
|
|
if (dim === 'title') header = 'Title';
|
|
|
|
|
if (dim === 'customer') header = 'Customer';
|
2026-01-16 10:44:43 +01:00
|
|
|
|
2025-12-11 11:25:26 +01:00
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-27 15:55:59 +01:00
|
|
|
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`);
|
2025-12-11 11:25:26 +01:00
|
|
|
};
|
|
|
|
|
|
2026-01-27 15:55:59 +01:00
|
|
|
export const generateItemMoversXLSX = (
|
2026-01-16 10:44:43 +01:00
|
|
|
data: ItemGrowthMetric[],
|
|
|
|
|
periods: { current: string; previous: string },
|
|
|
|
|
type: 'Gainers' | 'Losers'
|
2025-12-11 11:25:26 +01:00
|
|
|
) => {
|
2026-01-16 10:44:43 +01:00
|
|
|
const flatData = data.map(item => ({
|
|
|
|
|
SKU: item.sku || '-',
|
|
|
|
|
ASIN: item.asin || '-',
|
|
|
|
|
'Product Title': item.title || '-',
|
|
|
|
|
'Product Line': item.line || '-',
|
2026-01-27 15:55:59 +01:00
|
|
|
[`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)),
|
2026-01-16 10:44:43 +01:00
|
|
|
}));
|
2025-12-11 11:25:26 +01:00
|
|
|
|
2026-01-27 15:55:59 +01:00
|
|
|
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`);
|
2025-12-11 11:25:26 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 };
|
2026-01-26 14:36:42 +01:00
|
|
|
// 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;
|
2025-12-11 11:25:26 +01:00
|
|
|
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);
|
2026-01-16 10:44:43 +01:00
|
|
|
|
2025-12-11 11:25:26 +01:00
|
|
|
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)!;
|
2026-01-16 10:44:43 +01:00
|
|
|
|
2025-12-11 11:25:26 +01:00
|
|
|
const sellOutKey = `${record.year}_sellOut`;
|
|
|
|
|
const unitsKey = `${record.year}_units`;
|
|
|
|
|
|
2026-01-26 14:36:42 +01:00
|
|
|
// 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;
|
2026-01-16 10:44:43 +01:00
|
|
|
|
2025-12-11 11:25:26 +01:00
|
|
|
map.set(record.week, weekData);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Convert map to array, filter out weeks with no data across all years, and sort
|
|
|
|
|
return Array.from(map.entries())
|
|
|
|
|
.map(([week, values]) => ({
|
|
|
|
|
week,
|
|
|
|
|
name: `W${week}`,
|
|
|
|
|
...values,
|
|
|
|
|
}))
|
|
|
|
|
.filter(d => {
|
|
|
|
|
// Check if there is any non-zero value for this week
|
|
|
|
|
return Object.values(d).some(val => typeof val === 'number' && val > 0);
|
|
|
|
|
})
|
|
|
|
|
.sort((a, b) => a.week - b.week);
|
2026-01-21 13:19:13 +01:00
|
|
|
};
|
|
|
|
|
export interface WeeklyPivotRow {
|
|
|
|
|
id: string;
|
|
|
|
|
sku: string;
|
|
|
|
|
title: string;
|
|
|
|
|
asin: string;
|
|
|
|
|
line: string;
|
|
|
|
|
customer: string;
|
|
|
|
|
unitsByWeek: { [weekKey: string]: number }; // Key: "YYYY-WW"
|
2026-02-19 16:22:19 +01:00
|
|
|
spendByWeek: { [weekKey: string]: number }; // Key: "YYYY-WW" (Ads Cost)
|
|
|
|
|
revenueByWeek: { [weekKey: string]: number }; // Key: "YYYY-WW" (Sell-out)
|
2026-01-23 09:35:04 +01:00
|
|
|
gvByWeek: { [weekKey: string]: number }; // Key: "YYYY-WW"
|
2026-01-21 13:19:13 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-21 15:05:59 +01:00
|
|
|
export const pivotWeeklySalesData = (data: CombinedKPIs[]): {
|
|
|
|
|
rows: WeeklyPivotRow[],
|
|
|
|
|
weeks: string[]
|
2026-01-21 13:19:13 +01:00
|
|
|
} => {
|
2026-01-27 22:42:59 +01:00
|
|
|
const weekKeysSet = new Set<string>();
|
2026-01-21 13:19:13 +01:00
|
|
|
const map = new Map<string, WeeklyPivotRow>();
|
|
|
|
|
|
2026-01-27 22:42:59 +01:00
|
|
|
// 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++) {
|
2026-01-27 21:43:25 +01:00
|
|
|
const record = data[i];
|
2026-01-27 22:42:59 +01:00
|
|
|
if (!record.week) continue;
|
|
|
|
|
|
|
|
|
|
const weekKey = getWeekKey(record.year, record.week);
|
|
|
|
|
weekKeysSet.add(weekKey);
|
|
|
|
|
|
2026-01-30 09:10:02 +01:00
|
|
|
const recordAsin = (record.asin || '').trim().toUpperCase();
|
|
|
|
|
const recordSku = (record.sku || '').trim().toUpperCase();
|
|
|
|
|
const key = recordAsin || recordSku || `${record.title}-${record.line}`;
|
2026-01-27 21:43:25 +01:00
|
|
|
if (!key) continue;
|
2026-01-21 13:19:13 +01:00
|
|
|
|
2026-01-27 21:43:25 +01:00
|
|
|
let row = map.get(key);
|
|
|
|
|
if (!row) {
|
|
|
|
|
row = {
|
2026-01-21 13:19:13 +01:00
|
|
|
id: key,
|
|
|
|
|
sku: record.sku || '',
|
|
|
|
|
title: record.title || '',
|
|
|
|
|
asin: record.asin || '',
|
|
|
|
|
line: record.line || '',
|
|
|
|
|
customer: record.customer || record.marketplace || '',
|
2026-01-21 15:05:59 +01:00
|
|
|
unitsByWeek: {},
|
2026-01-23 09:35:04 +01:00
|
|
|
spendByWeek: {},
|
2026-02-19 16:22:19 +01:00
|
|
|
revenueByWeek: {},
|
2026-01-23 09:35:04 +01:00
|
|
|
gvByWeek: {}
|
2026-01-27 21:43:25 +01:00
|
|
|
};
|
|
|
|
|
map.set(key, row);
|
2026-01-21 13:19:13 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-27 22:42:59 +01:00
|
|
|
row.unitsByWeek[weekKey] = (row.unitsByWeek[weekKey] || 0) + (record.unitsTotal || 0);
|
|
|
|
|
row.spendByWeek[weekKey] = (row.spendByWeek[weekKey] || 0) + (record.cost || 0);
|
2026-02-19 16:22:19 +01:00
|
|
|
row.revenueByWeek[weekKey] = (row.revenueByWeek[weekKey] || 0) + (record.salesTotal || 0);
|
2026-01-27 22:42:59 +01:00
|
|
|
row.gvByWeek[weekKey] = (row.gvByWeek[weekKey] || 0) + (record.glanceViews || 0);
|
2026-01-27 21:43:25 +01:00
|
|
|
}
|
2026-01-21 13:19:13 +01:00
|
|
|
|
2026-01-27 22:42:59 +01:00
|
|
|
const sortedWeeks = Array.from(weekKeysSet).sort((a, b) => b.localeCompare(a));
|
|
|
|
|
|
2026-01-21 13:19:13 +01:00
|
|
|
return {
|
|
|
|
|
rows: Array.from(map.values()),
|
|
|
|
|
weeks: sortedWeeks
|
|
|
|
|
};
|
|
|
|
|
};
|
2026-01-26 15:47:36 +01:00
|
|
|
|
|
|
|
|
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(),
|
2026-01-26 16:50:46 +01:00
|
|
|
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
|
2026-01-26 15:47:36 +01:00
|
|
|
})).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[],
|
2026-01-27 09:49:35 +01:00
|
|
|
asinMetadata: Map<string, { sku: string; title: string; line: string }>,
|
2026-01-28 11:01:05 +01:00
|
|
|
filters?: FilterState,
|
2026-01-29 20:56:19 +01:00
|
|
|
velocityMap?: Map<string, number>,
|
|
|
|
|
referenceData?: SalesRecord[] // NEW: Full dataset for global seasonality context
|
2026-01-26 15:47:36 +01:00
|
|
|
): ProductForecastData[] => {
|
2026-01-29 20:56:19 +01:00
|
|
|
// Use referenceData if provided (for global weights), otherwise fallback to rawData
|
|
|
|
|
const seasonalitySource = referenceData || rawData;
|
2026-01-30 09:10:02 +01:00
|
|
|
const historicalData = seasonalitySource.filter(r => r.year < 2026);
|
2026-01-30 09:23:07 +01:00
|
|
|
|
|
|
|
|
// 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';
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-01-26 15:47:36 +01:00
|
|
|
|
2026-01-27 09:49:35 +01:00
|
|
|
// Calculate Seasonality weights for 2025
|
2026-01-28 15:17:58 +01:00
|
|
|
const getWeightsInfo = (records: SalesRecord[]): { weights: number[]; monthsCount: number } | null => {
|
2026-01-26 15:47:36 +01:00
|
|
|
const weights = new Array(12).fill(0);
|
|
|
|
|
let total = 0;
|
2026-01-28 15:17:58 +01:00
|
|
|
const seenMonths = new Set<string>();
|
2026-01-28 13:11:08 +01:00
|
|
|
|
2026-01-26 15:47:36 +01:00
|
|
|
records.forEach(r => {
|
|
|
|
|
const m = r.month.split('-')[0];
|
|
|
|
|
const idx = MONTH_ORDER.indexOf(m);
|
2026-01-28 15:17:58 +01:00
|
|
|
if (idx !== -1 && r.units > 0) {
|
2026-01-26 15:47:36 +01:00
|
|
|
weights[idx] += r.units;
|
|
|
|
|
total += r.units;
|
2026-01-28 15:17:58 +01:00
|
|
|
seenMonths.add(m);
|
2026-01-26 15:47:36 +01:00
|
|
|
}
|
|
|
|
|
});
|
2026-01-28 13:11:08 +01:00
|
|
|
|
2026-01-28 14:55:35 +01:00
|
|
|
// 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.
|
2026-01-28 13:17:04 +01:00
|
|
|
if (total === 0) return null;
|
|
|
|
|
|
2026-01-28 15:17:58 +01:00
|
|
|
return {
|
|
|
|
|
weights: weights.map(w => w / total),
|
|
|
|
|
monthsCount: seenMonths.size
|
|
|
|
|
};
|
2026-01-26 15:47:36 +01:00
|
|
|
};
|
|
|
|
|
|
2026-01-27 09:49:35 +01:00
|
|
|
// 1. Determine Global/Default Weights
|
2026-01-30 09:10:02 +01:00
|
|
|
const panEuHistoricalData = historicalData.filter(r => PAN_EU_COUNTRIES.includes(r.customer));
|
2026-01-28 13:17:04 +01:00
|
|
|
// 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;
|
2026-01-28 13:24:29 +01:00
|
|
|
const seenMonths = new Set<string>();
|
|
|
|
|
|
2026-01-28 13:17:04 +01:00
|
|
|
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;
|
2026-01-28 13:24:29 +01:00
|
|
|
if (r.units > 0) seenMonths.add(m);
|
2026-01-28 13:17:04 +01:00
|
|
|
}
|
|
|
|
|
});
|
2026-01-28 13:24:29 +01:00
|
|
|
|
|
|
|
|
// 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);
|
2026-01-28 13:17:04 +01:00
|
|
|
};
|
|
|
|
|
|
2026-01-30 09:10:02 +01:00
|
|
|
const panEuWeights = getGlobalWeightsInner(panEuHistoricalData);
|
2026-01-27 09:49:35 +01:00
|
|
|
|
|
|
|
|
// 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[]) => {
|
2026-01-28 13:17:04 +01:00
|
|
|
// Use Inner helper to ensure we always get weights for global subsets
|
|
|
|
|
const peWeights = getGlobalWeightsInner(paEuRecords);
|
|
|
|
|
const ukWeights = getGlobalWeightsInner(ukRecords);
|
2026-01-27 09:49:35 +01:00
|
|
|
|
|
|
|
|
// 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
|
2026-01-30 09:10:02 +01:00
|
|
|
? getHybridWeights(panEuHistoricalData, historicalData.filter(r => r.customer === 'Amazon UK'))
|
2026-01-27 09:49:35 +01:00
|
|
|
: panEuWeights;
|
2026-01-26 15:47:36 +01:00
|
|
|
|
2026-01-30 09:10:02 +01:00
|
|
|
// Map historical data by ASIN for quick access
|
|
|
|
|
const dataByAsinHistorical = new Map<string, SalesRecord[]>();
|
|
|
|
|
historicalData.forEach(r => {
|
2026-01-26 15:47:36 +01:00
|
|
|
const key = r.asin.trim().toUpperCase();
|
2026-01-30 09:10:02 +01:00
|
|
|
if (!dataByAsinHistorical.has(key)) dataByAsinHistorical.set(key, []);
|
|
|
|
|
dataByAsinHistorical.get(key)!.push(r);
|
2026-01-26 15:47:36 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-01 15:47:24 +01:00
|
|
|
// 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.
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-26 15:47:36 +01:00
|
|
|
return forecastData.map(fc => {
|
|
|
|
|
const identifier = fc.asin.toUpperCase();
|
|
|
|
|
const meta = asinMetadata.get(identifier);
|
2026-02-01 15:47:24 +01:00
|
|
|
|
|
|
|
|
// Resolve Line: Try meta first, then forecast file
|
|
|
|
|
const resolvedLine = meta?.line || fc.line || "Unassigned";
|
|
|
|
|
|
2026-01-28 11:01:05 +01:00
|
|
|
const avgWeeklySales = velocityMap?.get(identifier) || 0;
|
2026-01-26 15:47:36 +01:00
|
|
|
|
2026-01-27 09:49:35 +01:00
|
|
|
// 2. Determine weights for this ASIN
|
2026-01-30 09:10:02 +01:00
|
|
|
const productHistoricalRecords = dataByAsinHistorical.get(identifier) || [];
|
2026-02-01 15:47:24 +01:00
|
|
|
|
|
|
|
|
// 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;
|
2026-01-26 15:47:36 +01:00
|
|
|
|
2026-01-30 09:10:02 +01:00
|
|
|
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):
|
2026-02-01 15:47:24 +01:00
|
|
|
// We blend local seasonality with baseline (Line or Global) based on data density.
|
2026-01-30 09:10:02 +01:00
|
|
|
const trustFactor = (info.monthsCount / 12) * 0.85;
|
2026-02-01 15:47:24 +01:00
|
|
|
finalWeights = info.weights.map((w, i) => (w * trustFactor) + (baselineWeights[i] * (1 - trustFactor)));
|
2026-01-27 09:49:35 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 09:11:13 +01:00
|
|
|
// 3. Build monthly points and aggregate
|
|
|
|
|
const monthlyData: Record<string, MonthlyForecastPoint> = {};
|
|
|
|
|
let totalActualUnits = 0;
|
|
|
|
|
let totalForecastUnits = 0;
|
|
|
|
|
|
|
|
|
|
MONTH_ORDER.forEach((m, idx) => {
|
2026-02-01 15:47:24 +01:00
|
|
|
const forecastUnits = Math.round(fc.annualForecast * finalWeights[idx]);
|
2026-01-26 15:47:36 +01:00
|
|
|
const actualUnits = actuals2026.get(identifier)?.get(m) || 0;
|
2026-01-28 09:11:13 +01:00
|
|
|
|
|
|
|
|
monthlyData[m] = {
|
2026-01-26 15:47:36 +01:00
|
|
|
month: m,
|
|
|
|
|
forecastUnits,
|
2026-01-28 09:11:13 +01:00
|
|
|
actualUnits,
|
|
|
|
|
units: actualUnits // Added for chart compatibility
|
|
|
|
|
} as any;
|
|
|
|
|
|
|
|
|
|
totalActualUnits += actualUnits;
|
|
|
|
|
totalForecastUnits += forecastUnits;
|
2026-01-26 15:47:36 +01:00
|
|
|
});
|
|
|
|
|
|
2026-01-28 09:11:13 +01:00
|
|
|
const accuracy = totalForecastUnits > 0
|
2026-01-30 09:10:02 +01:00
|
|
|
? Math.max(0, Math.min(100, Math.round((1 - Math.abs(totalActualUnits - totalForecastUnits) / totalForecastUnits) * 100)))
|
|
|
|
|
: (totalActualUnits === 0 ? 100 : 0);
|
2026-01-28 09:11:13 +01:00
|
|
|
|
2026-01-26 15:47:36 +01:00
|
|
|
return {
|
|
|
|
|
asin: identifier,
|
2026-01-26 16:50:46 +01:00
|
|
|
sku: meta?.sku || fc.sku || identifier,
|
|
|
|
|
title: meta?.title || fc.title || identifier,
|
2026-01-28 09:11:13 +01:00
|
|
|
line: meta?.line || fc.line || "Unassigned",
|
2026-01-26 15:47:36 +01:00
|
|
|
annualForecast: fc.annualForecast,
|
2026-01-28 09:11:13 +01:00
|
|
|
actualUnits: totalActualUnits,
|
|
|
|
|
forecastUnits: totalForecastUnits,
|
|
|
|
|
accuracy: Math.max(0, accuracy),
|
2026-01-28 10:34:46 +01:00
|
|
|
avgWeeklySales,
|
2026-01-26 15:47:36 +01:00
|
|
|
monthlyData
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
};
|
2026-01-27 16:51:38 +01:00
|
|
|
|
2026-01-28 09:46:59 +01:00
|
|
|
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++) {
|
2026-01-30 09:10:02 +01:00
|
|
|
if (jsonData[i] && (jsonData[i].includes('ASIN') || jsonData[i].includes('asin'))) {
|
2026-01-28 09:46:59 +01:00
|
|
|
headerRowIndex = i;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (headerRowIndex === -1) {
|
2026-01-30 09:10:02 +01:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-01-28 09:46:59 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-30 09:10:02 +01:00
|
|
|
const headers: any[] = jsonData[headerRowIndex];
|
|
|
|
|
const asinIdx = headers.findIndex(h => String(h || '').toUpperCase() === 'ASIN');
|
2026-02-02 09:18:32 +01:00
|
|
|
|
|
|
|
|
// Enhanced marketplace detection - includes 'Store code' for PANEU reports
|
2026-01-30 09:10:02 +01:00
|
|
|
const marketplaceIdx = headers.findIndex(h => {
|
|
|
|
|
const sh = String(h || '').toUpperCase();
|
2026-02-02 09:18:32 +01:00
|
|
|
return sh === 'MARKETPLACE' || sh === 'COUNTRY' || sh === 'COUNTRY/REGION' ||
|
|
|
|
|
sh === 'STORE CODE' || sh === 'STORE';
|
2026-01-30 09:10:02 +01:00
|
|
|
});
|
|
|
|
|
|
2026-02-02 09:18:32 +01:00
|
|
|
// REFINED SEARCH STRATEGY for Stock column:
|
|
|
|
|
// Priority 1: Look specifically for "Sellable On Hand Units" (most accurate for current inventory)
|
2026-02-01 16:00:01 +01:00
|
|
|
let finalStockIdx = headers.findIndex(h => {
|
|
|
|
|
const sh = String(h || '').toUpperCase();
|
2026-02-02 09:18:32 +01:00
|
|
|
return sh === 'SELLABLE ON HAND UNITS';
|
2026-02-01 16:00:01 +01:00
|
|
|
});
|
|
|
|
|
|
2026-02-02 09:18:32 +01:00
|
|
|
// Priority 2: Look for columns with "SELLABLE" AND "UNITS" (but not necessarily exact match)
|
2026-02-01 16:00:01 +01:00
|
|
|
if (finalStockIdx === -1) {
|
|
|
|
|
finalStockIdx = headers.findIndex(h => {
|
|
|
|
|
const sh = String(h || '').toUpperCase();
|
2026-02-02 09:18:32 +01:00
|
|
|
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'));
|
2026-02-01 16:00:01 +01:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 09:10:02 +01:00
|
|
|
// 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;
|
2026-02-01 16:00:01 +01:00
|
|
|
finalStockIdx = finalStockIdx !== -1 ? finalStockIdx : 15;
|
|
|
|
|
|
|
|
|
|
console.log(`[VendorStock] Selected Stock Column: "${headers[finalStockIdx]}" (Index: ${finalStockIdx})`);
|
2026-02-02 09:18:32 +01:00
|
|
|
console.log(`[VendorStock] Marketplace Column: "${headers[finalMarketplaceIdx]}" (Index: ${finalMarketplaceIdx})`);
|
2026-01-30 09:10:02 +01:00
|
|
|
|
2026-01-28 09:46:59 +01:00
|
|
|
for (let i = headerRowIndex + 1; i < jsonData.length; i++) {
|
|
|
|
|
const row = jsonData[i];
|
2026-01-30 09:10:02 +01:00
|
|
|
if (!row || row.length <= Math.max(finalAsinIdx, finalMarketplaceIdx, finalStockIdx)) continue;
|
2026-01-28 09:46:59 +01:00
|
|
|
|
2026-01-30 09:10:02 +01:00
|
|
|
const asin = String(row[finalAsinIdx] || '').trim().toUpperCase();
|
2026-01-28 09:46:59 +01:00
|
|
|
if (!asin) continue;
|
|
|
|
|
|
2026-01-30 09:10:02 +01:00
|
|
|
const marketplace = String(row[finalMarketplaceIdx] || '').trim().toLowerCase();
|
|
|
|
|
const stockValue = parseUnits(String(row[finalStockIdx] || '0'));
|
|
|
|
|
|
2026-01-28 09:46:59 +01:00
|
|
|
if (!vendorStockMap.has(asin)) {
|
|
|
|
|
vendorStockMap.set(asin, { eu: 0, uk: 0 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const current = vendorStockMap.get(asin)!;
|
|
|
|
|
|
|
|
|
|
// Map to UK or EU
|
2026-01-30 09:10:02 +01:00
|
|
|
if (marketplace.includes('uk') || marketplace.includes('kingdom') || marketplace === 'gb' || marketplace === 'united kingdom') {
|
2026-01-28 09:46:59 +01:00
|
|
|
current.uk += stockValue;
|
2026-01-30 09:10:02 +01:00
|
|
|
} else if (marketplace) {
|
|
|
|
|
// Assume everything else with a marketplace is Pan-EU (DE, IT, FR, ES)
|
2026-01-28 09:46:59 +01:00
|
|
|
current.eu += stockValue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return vendorStockMap;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Error processing Vendor Stock Excel:", error);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-02 09:24:49 +01:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-28 11:01:05 +01:00
|
|
|
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();
|
|
|
|
|
|
2026-03-16 13:19:21 +01:00
|
|
|
const latestYear = validYears.reduce((a, b) => Math.max(a, b), 0);
|
2026-04-17 12:35:25 +02:00
|
|
|
|
|
|
|
|
// Find the latest week with actual units > 0 in the latest year
|
|
|
|
|
// This avoids using future weeks with 0 sales that might be in the data
|
2026-01-28 11:01:05 +01:00
|
|
|
const yearData = data.filter(r => r.year === latestYear);
|
2026-04-17 12:35:25 +02:00
|
|
|
const weeksWithSales = yearData
|
|
|
|
|
.filter(r => (r.units || 0) > 0 && r.week !== undefined)
|
|
|
|
|
.map(r => r.week as number);
|
|
|
|
|
|
|
|
|
|
let latestWeek = 0;
|
|
|
|
|
if (weeksWithSales.length > 0) {
|
|
|
|
|
latestWeek = Math.max(...weeksWithSales);
|
|
|
|
|
} else {
|
|
|
|
|
// Fallback to highest week if no sales found
|
|
|
|
|
latestWeek = yearData.length > 0
|
|
|
|
|
? (yearData.map(r => r.week).filter(w => w !== undefined) as number[])
|
|
|
|
|
.reduce((a, b) => Math.max(a, b), 0)
|
|
|
|
|
: 0;
|
|
|
|
|
}
|
2026-01-28 11:01:05 +01:00
|
|
|
|
|
|
|
|
const last4WeeksKeys = new Set<string>();
|
|
|
|
|
for (let i = 0; i < 4; i++) {
|
2026-01-28 11:50:59 +01:00
|
|
|
let w = latestWeek - i;
|
2026-01-28 11:01:05 +01:00
|
|
|
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();
|
2026-04-17 12:35:25 +02:00
|
|
|
asin4WeekSales.set(key, (asin4WeekSales.get(key) || 0) + (r.units || 0));
|
2026-01-28 11:01:05 +01:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const velocityMap = new Map<string, number>();
|
|
|
|
|
asin4WeekSales.forEach((total, asin) => {
|
|
|
|
|
velocityMap.set(asin, total / 4);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return velocityMap;
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-27 16:51:38 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-01-29 09:42:39 +01:00
|
|
|
|
|
|
|
|
export const processBuyBoxExcel = async (fileOrBuffer: File | ArrayBuffer): Promise<Map<string, { countries: string[]; reasons: Record<string, string> }>> => {
|
|
|
|
|
try {
|
2026-03-03 15:08:24 +01:00
|
|
|
const arrayBuffer = fileOrBuffer instanceof File ? await fileOrBuffer.arrayBuffer() : fileOrBuffer;
|
2026-01-29 09:42:39 +01:00
|
|
|
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
|
|
|
|
const buyBoxMap = new Map<string, { countries: string[]; reasons: Record<string, string> }>();
|
|
|
|
|
|
2026-03-03 15:08:24 +01:00
|
|
|
// 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']);
|
|
|
|
|
|
2026-02-05 08:39:39 +01:00
|
|
|
console.log('[BuyBox] Available sheets:', workbook.SheetNames.join(', '));
|
|
|
|
|
|
2026-03-03 15:38:39 +01:00
|
|
|
// Detect country code from sheet name — only BB_* sheets to avoid false positives
|
2026-03-03 15:08:24 +01:00
|
|
|
const detectCountry = (name: string): string | null => {
|
|
|
|
|
const n = name.toUpperCase().replace(/[_\- ]/g, '');
|
2026-03-03 15:38:39 +01:00
|
|
|
// 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';
|
2026-03-03 15:08:24 +01:00
|
|
|
return null;
|
|
|
|
|
};
|
2026-02-05 08:39:39 +01:00
|
|
|
|
2026-03-03 15:08:24 +01:00
|
|
|
for (const sheetName of workbook.SheetNames) {
|
|
|
|
|
const country = detectCountry(sheetName);
|
|
|
|
|
if (!country) {
|
|
|
|
|
console.log(`[BuyBox] Sheet "${sheetName}" — no country detected, skipping`);
|
2026-01-29 09:42:39 +01:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 08:39:39 +01:00
|
|
|
const worksheet = workbook.Sheets[sheetName];
|
2026-03-03 15:08:24 +01:00
|
|
|
const rows: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: '' });
|
|
|
|
|
if (!rows.length) continue;
|
2026-01-29 09:42:39 +01:00
|
|
|
|
2026-03-03 15:08:24 +01:00
|
|
|
// 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;
|
2026-02-05 08:48:17 +01:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-29 09:42:39 +01:00
|
|
|
}
|
2026-03-03 15:08:24 +01:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 15:47:56 +01:00
|
|
|
// Known reason column overrides (confirmed by user): FR=S(18), UK=J(9), DE=N(13)
|
|
|
|
|
const REASON_COL_OVERRIDE: Record<string, number> = { FR: 18, UK: 9, DE: 13 };
|
|
|
|
|
let reasonColIdx = REASON_COL_OVERRIDE[country] ?? -1;
|
|
|
|
|
|
|
|
|
|
// For countries without a hardcoded override, detect from header keywords
|
|
|
|
|
if (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('MOTIVO') || s.includes('CAUSA') || s.includes('ESTADO') ||
|
|
|
|
|
s.includes('COMMENT') || s.includes('OBSERV') || s.includes('SITUAC') ||
|
|
|
|
|
s.includes('LBB') || s.includes('PROBLEMA') || s.includes('JUSTIF') || s.includes('DETALL');
|
|
|
|
|
});
|
|
|
|
|
if (idx !== -1) { reasonColIdx = idx; break; }
|
|
|
|
|
}
|
2026-03-03 15:08:24 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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}`);
|
2026-01-29 09:42:39 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-03 15:08:24 +01:00
|
|
|
console.log(`[BuyBox] Total: ${buyBoxMap.size} ASINs with BB issues`);
|
2026-01-29 09:42:39 +01:00
|
|
|
return buyBoxMap;
|
|
|
|
|
} catch (error) {
|
2026-03-03 15:08:24 +01:00
|
|
|
console.error('[BuyBox] Error processing Excel:', error);
|
2026-01-29 09:42:39 +01:00
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
};
|