2026-02-20 13:32:26 +01:00
import { SalesRecord , AdsRecord , TrafficRecord , CombinedKPIs , FilterState , AggregatedData , LineGrowthMetric , ItemGrowthMetric , SeasonalityPoint , YearlySplitData , PivotRow , YearlyData , TimeSeriesData , ComparisonTimeSeriesPoint , ForecastRecord , MonthlyForecastPoint , ProductForecastData , VendorCSVRow , VendorDailyRow } 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"
2025-12-11 14:03:33 +01:00
// If it's a full date string like "2023-04-01" or "01/04/2023"
if ( m . includes ( '/' ) || m . includes ( '-' )) {
2025-12-11 15:08:01 +01:00
// Try parsing standard date
2025-12-11 14:03:33 +01:00
const date = new Date ( m );
if ( ! isNaN ( date . getTime ())) {
const monthIdx = date . getMonth ();
const yearShort = date . getFullYear (). toString (). slice ( 2 );
return ` ${ MONTH_ORDER [ monthIdx ] } - ${ yearShort } ` ;
}
}
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-01-21 16:28:56 +01:00
export const PAN_EU_COUNTRIES = [ 'Amazon DE' , 'Amazon IT' , 'Amazon FR' , 'Amazon ES' ];
const ALLOWED_CUSTOMERS = [... PAN_EU_COUNTRIES , 'Amazon UK' , 'Amazon SC' ];
2026-01-16 10:44:43 +01:00
const isAllowedCustomer = ( customer : string ) : boolean => {
if ( ! customer ) return false ;
const normCustomer = customer . trim (). toLowerCase ();
return ALLOWED_CUSTOMERS . some ( allowed => allowed . toLowerCase () === normCustomer );
};
2025-12-11 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 ());
const hasYear = normHeaders . includes ( 'year' );
const hasTime = normHeaders . includes ( 'month' ) || normHeaders . includes ( 'week' );
const hasCustomerRef = normHeaders . includes ( 'customer reference' ) || normHeaders . includes ( 'asin' );
const hasEan = normHeaders . includes ( 'ean' );
const hasUnits = normHeaders . includes ( 'units' );
const hasAmount = normHeaders . includes ( 'amount_eur' ) || normHeaders . includes ( 'amount' );
const missing = [];
if ( ! hasYear ) missing . push ( 'YEAR' );
if ( ! hasTime ) missing . push ( 'MONTH or WEEK' );
if ( ! hasCustomerRef ) missing . push ( 'CUSTOMER REFERENCE' );
if ( ! hasEan ) missing . push ( 'EAN' );
if ( ! hasUnits ) missing . push ( 'UNITS' );
if ( ! hasAmount ) missing . push ( 'AMOUNT_EUR' );
if ( missing . length > 0 ) {
throw new Error ( `Invalid or missing critical columns in Sell-Out Report. Missing: ${ missing . join ( ', ' ) } ` );
}
};
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
const customer = getColumnValue ( row , [ 'NEW CUSTOMER' , 'COUNTRY' , 'Customer' , 'Client' , 'Account' , 'Partner' , 'Country' , 'Market' ]) || 'Unknown' ;
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 ;
2025-12-11 11:25:26 +01:00
const monthStr = getColumnValue ( row , [ 'MONTH' , 'Month' , 'Period' ]);
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-01-16 11:07:35 +01:00
// Filter: Valid Year > 2023 (exclude incomplete 2023 data) AND Allowed Customer
. filter (( r : SalesRecord ) => r . year > 2023 && isAllowedCustomer ( r . customer ));
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-01-16 10:44:43 +01:00
// Filter: Valid Year AND Allowed Customer
. filter (( r : SalesRecord ) => r . year > 0 && isAllowedCustomer ( r . customer ));
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 ();
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-02-25 14:01:11 +01:00
if ( ! asin || ! 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-01-16 10:44:43 +01:00
asin : String ( asin ). trim (),
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
if ( ! asin || ! countryRaw || weekRaw === undefined || weekRaw === '' ) continue ;
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 ,
asin : String ( asin ). trim (),
cost : parseCurrency ( String ( costRaw )),
clicks : parseUnits ( String ( clicksRaw )),
impressions : parseUnits ( String ( impressionsRaw )),
cpc : parseCurrency ( String ( cpcRaw )),
ctr : parseCurrency ( String ( ctrRaw )),
acos : parseCurrency ( String ( acosRaw )),
conversions : parseUnits ( String ( conversionsRaw )),
attributedUnits30d : parseUnits ( String ( unitsRaw )),
attributedSales30d : parseCurrency ( String ( salesRaw )),
});
}
}
console . log ( `Total Ads records loaded: ${ allData . length } ` );
return allData ;
2025-12-11 14:03:33 +01:00
} catch ( error ) {
console . error ( "Error processing Ads Excel:" , error );
throw error ;
}
};
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 ;
}
};
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 [],
velocityMap? : Map < string , number >
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-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 ,
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 ,
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
? PAN_EU_COUNTRIES . some ( c => c . toUpperCase () === ad . country . toUpperCase ())
: 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
return result . filter ( item => { // Apply remaining filters to the 'result'
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-02-09 12:05:03 +01:00
? ( PAN_EU_COUNTRIES . includes ( item . customer ) || item . customer === 'Pan-EU' )
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
};
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 ();
const latestYear = Math . max (... validYears );
const yearData = data . filter ( r => r . year === latestYear );
const latestWeek = yearData . length > 0 ? Math . max (... yearData . map ( r => r . week ). filter ( w => w !== undefined ) as number []) : 0 ;
const last4WeeksKeys = new Set < string >();
for ( let i = 0 ; i < 4 ; i ++ ) {
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 ();
asin4WeekSales . set ( key , ( asin4WeekSales . get ( key ) || 0 ) + r . units );
}
});
const velocityMap = new Map < string , number >();
asin4WeekSales . forEach (( total , asin ) => {
velocityMap . set ( asin , total / 4 );
});
return velocityMap ;
};
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
2026-02-05 08:39:39 +01:00
const BB_SHEET_CONFIG : { sheet : string ; country : string }[] = [
{ sheet : 'BB_FR' , country : 'FR' },
{ sheet : 'BB_UK' , country : 'UK' },
{ sheet : 'BB_DE' , country : 'DE' },
{ sheet : 'BB_IT' , country : 'IT' },
{ sheet : 'BB_ES' , country : 'ES' },
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 {
const arrayBuffer = fileOrBuffer instanceof File
? await fileOrBuffer . arrayBuffer ()
: fileOrBuffer ;
const workbook = XLSX . read ( arrayBuffer , { type : 'array' });
// Map: ASIN -> { countries: [], reasons: {} }
const buyBoxMap = new Map < string , { countries : string []; reasons : Record < string , string > }>();
2026-02-05 08:39:39 +01:00
// Log available sheets for debugging
console . log ( '[BuyBox] Available sheets:' , workbook . SheetNames . join ( ', ' ));
2026-01-29 09:42:39 +01:00
for ( const config of BB_SHEET_CONFIG ) {
2026-02-05 08:48:27 +01:00
// Find sheet case-insensitively and with flexible separators (BB_ES, BB-ES, BB ES, ES BB)
2026-02-05 08:39:39 +01:00
const sheetName = workbook . SheetNames . find ( name => {
const n = name . toUpperCase (). replace ( /[-_ ]/g , '' );
const target = config . sheet . toUpperCase (). replace ( /[-_ ]/g , '' );
2026-02-05 08:52:49 +01:00
const country = config . country . toUpperCase ();
// Match if:
// 1. Exact pattern (BB_ES)
// 2. Just the country code (ES)
// 3. Contains 'BB' and the country code
// 4. Special cases for Spain (SPAIN, ESPAÑA)
return n === target ||
n === country ||
( n . includes ( 'BB' ) && n . includes ( country )) ||
( country === 'ES' && ( n . includes ( 'SPAIN' ) || n . includes ( 'ESPAÑA' ) || n . includes ( 'ESPANA' )));
2026-02-05 08:39:39 +01:00
});
if ( ! sheetName ) {
console . warn ( `[BuyBox] Sheet matching ${ config . sheet } not found, skipping...` );
2026-01-29 09:42:39 +01:00
continue ;
}
2026-02-05 08:39:39 +01:00
const worksheet = workbook . Sheets [ sheetName ];
2026-01-29 09:42:39 +01:00
const jsonData : any [][] = XLSX . utils . sheet_to_json ( worksheet , { header : 1 });
2026-02-05 08:39:39 +01:00
if ( jsonData . length === 0 ) continue ;
2026-01-29 09:42:39 +01:00
2026-02-05 08:44:33 +01:00
// Find the header row (first row with ASIN or similar)
let headerRowIdx = 0 ;
let headers : any [] = jsonData [ 0 ] || [];
for ( let r = 0 ; r < Math . min ( jsonData . length , 10 ); r ++ ) {
const rowData = jsonData [ r ];
if ( ! rowData ) continue ;
const isHeader = rowData . some ( cell => {
const s = String ( cell || '' ). toUpperCase (). trim ();
return s === 'ASIN' || s . includes ( 'AMAZON ASIN' ) || s . includes ( 'PRODUCT ID' ) || s . includes ( 'SKU' );
});
if ( isHeader ) {
headerRowIdx = r ;
headers = rowData ;
break ;
}
}
2026-02-05 08:39:39 +01:00
const asinIdx = headers . findIndex ( h => {
const s = String ( h || '' ). toUpperCase (). trim ();
2026-02-05 10:48:37 +01:00
return s === 'ASIN' || s . includes ( 'AMAZON ASIN' ) || s . includes ( 'CHILD ASIN' ) || s . includes ( 'IDENTIFIER' ) || s . includes ( 'PRODUCT ID' ) || s . includes ( 'SKU' );
2026-02-05 08:39:39 +01:00
});
2026-02-06 10:57:03 +01:00
let finalAsinIdx = asinIdx !== - 1 ? asinIdx : 1 ; // Default to Col B if not found
2026-01-30 09:10:02 +01:00
2026-02-05 08:39:39 +01:00
// Dynamically find the "Issue Type" or "Reason" column
let reasonIdx = headers . findIndex ( h => {
const s = String ( h || '' ). toUpperCase ();
2026-02-05 10:48:37 +01:00
return s . includes ( 'ISSUE TYPE' ) || s . includes ( 'REASON' ) || s . includes ( 'BUY BOX STATUS' ) || s . includes ( 'LBB REASON' ) || s . includes ( 'ESTADO BB' ) || s . includes ( 'COMENTARIO' ) || s . includes ( 'MOTIVO' ) || s . includes ( 'CAUSA' ) || s . includes ( 'OBSERVACIONES' ) || s . includes ( 'DETALLES' ) || s . includes ( 'JUSTIFICACIÓN' ) || s . includes ( 'SITUACIÓN' ) || s . includes ( 'STATUS' );
2026-02-05 08:39:39 +01:00
});
2026-02-05 10:48:37 +01:00
// If still not found, check for exact matches of common Spanish headers
if ( reasonIdx === - 1 ) {
reasonIdx = headers . findIndex ( h => {
const s = String ( h || '' ). toUpperCase (). trim ();
return s === 'COMENTARIOS' || s === 'OBSERVACIONES' || s === 'MOTIVO' || s === 'ESTADO' ;
});
}
2026-02-06 10:50:18 +01:00
// Fallback to previous hardcoded indices if header search fails or for specific known sheet structures
2026-02-06 10:57:03 +01:00
if ( reasonIdx === - 1 || ( config . country === 'FR' && reasonIdx !== 18 ) || ( config . country === 'UK' && reasonIdx !== 9 ) || ( config . country === 'DE' && reasonIdx !== 13 )) {
2026-02-06 10:54:06 +01:00
if ( config . country === 'FR' ) reasonIdx = 18 ; // Force Column S for FR (Index 18)
2026-02-06 10:57:03 +01:00
else if ( config . country === 'UK' ) reasonIdx = 9 ; // Force Column J for UK (Index 9)
else if ( config . country === 'DE' ) reasonIdx = 13 ; // Force Column N for DE (Index 13)
2026-02-06 10:50:18 +01:00
else if ( reasonIdx === - 1 ) {
2026-02-06 10:57:03 +01:00
if ( config . country === 'IT' ) reasonIdx = 12 ;
2026-02-06 10:50:18 +01:00
else if ( config . country === 'ES' ) reasonIdx = 13 ;
else reasonIdx = 13 ;
}
2026-02-05 08:39:39 +01:00
}
2026-02-06 10:57:03 +01:00
// Also force ASIN column for DE if not correctly detected
if ( config . country === 'DE' ) finalAsinIdx = 1 ; // Force Column B for Germany (Index 1)
2026-02-05 10:48:37 +01:00
if ( config . country === 'ES' ) {
console . log ( `[BuyBox Debug] ES Headers found:` , headers );
}
2026-02-05 08:39:39 +01:00
2026-02-05 08:44:33 +01:00
// Skip header row and all rows above it
for ( let i = headerRowIdx + 1 ; i < jsonData . length ; i ++ ) {
2026-01-29 09:42:39 +01:00
const row = jsonData [ i ];
2026-02-05 08:39:39 +01:00
if ( ! row || row . length <= Math . max ( finalAsinIdx , reasonIdx )) continue ;
2026-01-30 09:10:02 +01:00
const rawAsin = String ( row [ finalAsinIdx ] || '' ). trim (). toUpperCase ();
if ( ! rawAsin || rawAsin . length < 5 ) continue ;
2026-01-29 09:42:39 +01:00
2026-02-05 08:39:39 +01:00
const rawReason = String ( row [ reasonIdx ] || '' ). trim ();
// Debug specific ASIN reported by user
2026-02-05 08:48:17 +01:00
const isTargetAsin = rawAsin === 'B0D3874WSD' || rawAsin . includes ( 'B0D3874WSD' );
if ( isTargetAsin ) {
console . log ( `[BuyBox Debug] Found ASIN ${ rawAsin } in ${ config . country } . Row data at reasonIdx ( ${ reasonIdx } ): " ${ row [ reasonIdx ] } ", Processed Reason: " ${ rawReason } "` );
2026-02-05 08:39:39 +01:00
}
2026-01-29 09:42:39 +01:00
2026-01-29 09:51:34 +01:00
// Only consider as BB lost if there is an Issue Type / Reason specified
2026-02-05 08:39:39 +01:00
const lowReason = rawReason . toLowerCase ();
2026-02-05 08:48:17 +01:00
if ( ! rawReason || lowReason === 'fixed' || lowReason === 'ok' || lowReason === 'hecho' || lowReason === 'solucionado' || lowReason === 'corrected' ) {
if ( isTargetAsin ) {
console . log ( `[BuyBox Debug] SKIPPING ASIN ${ rawAsin } in ${ config . country } because reason is empty or matches positive status list (" ${ rawReason } ")` );
}
continue ;
}
2026-01-29 09:51:34 +01:00
2026-01-30 09:10:02 +01:00
let entry = buyBoxMap . get ( rawAsin );
if ( ! entry ) {
entry = { countries : [], reasons : {} };
buyBoxMap . set ( rawAsin , entry );
2026-01-29 09:42:39 +01:00
}
if ( ! entry . countries . includes ( config . country )) {
entry . countries . push ( config . country );
}
2026-01-30 09:10:02 +01:00
// Collect reason for this country
entry . reasons [ config . country ] = rawReason ;
2026-01-29 09:42:39 +01:00
}
}
2026-02-05 08:39:39 +01:00
console . log ( `[BuyBox] Processed ${ buyBoxMap . size } total ASINs with BB lost across all countries` );
2026-01-29 09:42:39 +01:00
return buyBoxMap ;
} catch ( error ) {
console . error ( "Error processing Buy Box Excel:" , error );
throw error ;
}
};