mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 12:55:23 +02:00
fix: improve forecast seasonality logic and catalog data integrity
This commit is contained in:
+86
-61
@@ -1319,8 +1319,16 @@ export const pivotSalesData = (data: any[], dimensions: string[] = ['title', 'cu
|
||||
const keyParts = new Array(dimensions.length);
|
||||
for (let i = 0; i < dimensions.length; i++) {
|
||||
const dim = dimensions[i];
|
||||
if (dim === 'customer') keyParts[i] = String(record.customer || record.marketplace || '');
|
||||
else keyParts[i] = String(record[dim] || '');
|
||||
let val = '';
|
||||
if (dim === 'customer') val = String(record.customer || record.marketplace || '');
|
||||
else val = String(record[dim] || '');
|
||||
|
||||
// Normalize ASIN and SKU in keys to fold duplicates
|
||||
if (dim === 'asin' || dim === 'sku' || dim === 'customer') {
|
||||
keyParts[i] = val.trim().toUpperCase();
|
||||
} else {
|
||||
keyParts[i] = val;
|
||||
}
|
||||
}
|
||||
const key = keyParts.join('||');
|
||||
|
||||
@@ -1578,7 +1586,9 @@ export const pivotWeeklySalesData = (data: CombinedKPIs[]): {
|
||||
const weekKey = getWeekKey(record.year, record.week);
|
||||
weekKeysSet.add(weekKey);
|
||||
|
||||
const key = record.asin || record.sku || `${record.title}-${record.line}`;
|
||||
const recordAsin = (record.asin || '').trim().toUpperCase();
|
||||
const recordSku = (record.sku || '').trim().toUpperCase();
|
||||
const key = recordAsin || recordSku || `${record.title}-${record.line}`;
|
||||
if (!key) continue;
|
||||
|
||||
let row = map.get(key);
|
||||
@@ -1643,7 +1653,7 @@ export const calculateForecastViewData = (
|
||||
): ProductForecastData[] => {
|
||||
// Use referenceData if provided (for global weights), otherwise fallback to rawData
|
||||
const seasonalitySource = referenceData || rawData;
|
||||
const data2025 = seasonalitySource.filter(r => r.year === 2025);
|
||||
const historicalData = seasonalitySource.filter(r => r.year < 2026);
|
||||
const data2026 = seasonalitySource.filter(r => r.year === 2026);
|
||||
|
||||
// Calculate Seasonality weights for 2025
|
||||
@@ -1673,7 +1683,7 @@ export const calculateForecastViewData = (
|
||||
};
|
||||
|
||||
// 1. Determine Global/Default Weights
|
||||
const panEuData2025 = data2025.filter(r => PAN_EU_COUNTRIES.includes(r.customer));
|
||||
const panEuHistoricalData = historicalData.filter(r => PAN_EU_COUNTRIES.includes(r.customer));
|
||||
// For Global Weights, we do NOT return null on sparse data (we accept whatever we have for the whole catalog)
|
||||
// We recreate a simple version of getWeights that doesn't return null for the global set
|
||||
const getGlobalWeightsInner = (records: SalesRecord[]) => {
|
||||
@@ -1701,7 +1711,7 @@ export const calculateForecastViewData = (
|
||||
return weights.map(w => w / total);
|
||||
};
|
||||
|
||||
const panEuWeights = getGlobalWeightsInner(panEuData2025);
|
||||
const panEuWeights = getGlobalWeightsInner(panEuHistoricalData);
|
||||
|
||||
// Check if we are in UK-only mode
|
||||
const isUkOnly = filters?.customer?.includes('Amazon UK') && filters.customer.length === 1;
|
||||
@@ -1729,15 +1739,15 @@ export const calculateForecastViewData = (
|
||||
};
|
||||
|
||||
const globalWeights = isUkOnly
|
||||
? getHybridWeights(panEuData2025, data2025.filter(r => r.customer === 'Amazon UK'))
|
||||
? getHybridWeights(panEuHistoricalData, historicalData.filter(r => r.customer === 'Amazon UK'))
|
||||
: panEuWeights;
|
||||
|
||||
// Map 2025 data by ASIN for quick access
|
||||
const dataByAsin2025 = new Map<string, SalesRecord[]>();
|
||||
data2025.forEach(r => {
|
||||
// Map historical data by ASIN for quick access
|
||||
const dataByAsinHistorical = new Map<string, SalesRecord[]>();
|
||||
historicalData.forEach(r => {
|
||||
const key = r.asin.trim().toUpperCase();
|
||||
if (!dataByAsin2025.has(key)) dataByAsin2025.set(key, []);
|
||||
dataByAsin2025.get(key)!.push(r);
|
||||
if (!dataByAsinHistorical.has(key)) dataByAsinHistorical.set(key, []);
|
||||
dataByAsinHistorical.get(key)!.push(r);
|
||||
});
|
||||
|
||||
// Map 2026 actual sales by ASIN and Month
|
||||
@@ -1756,32 +1766,23 @@ export const calculateForecastViewData = (
|
||||
const avgWeeklySales = velocityMap?.get(identifier) || 0;
|
||||
|
||||
// 2. Determine weights for this ASIN
|
||||
const productRecords2025 = dataByAsin2025.get(identifier) || [];
|
||||
const productHistoricalRecords = dataByAsinHistorical.get(identifier) || [];
|
||||
let productWeights = globalWeights;
|
||||
|
||||
if (productRecords2025.length > 0) {
|
||||
if (isUkOnly) {
|
||||
const ukProd = productRecords2025.filter(r => r.customer === 'Amazon UK');
|
||||
const info = getWeightsInfo(ukProd);
|
||||
if (info) {
|
||||
if (info.monthsCount < 6) {
|
||||
// Smart Blending: 50% specific, 50% global
|
||||
productWeights = info.weights.map((w, i) => (w * 0.5) + (globalWeights[i] * 0.5));
|
||||
} else {
|
||||
productWeights = info.weights;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const info = getWeightsInfo(productRecords2025);
|
||||
if (info) {
|
||||
if (info.monthsCount < 6) {
|
||||
// Smart Blending: 50% specific, 50% global
|
||||
productWeights = info.weights.map((w, i) => (w * 0.5) + (globalWeights[i] * 0.5));
|
||||
} else {
|
||||
productWeights = info.weights;
|
||||
}
|
||||
}
|
||||
// If info is null (sparse data), productWeights remains globalWeights (Default)
|
||||
if (productHistoricalRecords.length > 0) {
|
||||
const historyToUse = isUkOnly
|
||||
? productHistoricalRecords.filter(r => r.customer === 'Amazon UK')
|
||||
: productHistoricalRecords;
|
||||
|
||||
const info = getWeightsInfo(historyToUse);
|
||||
if (info) {
|
||||
// Adaptive Blending (Bayesian Shrinkage):
|
||||
// We blend local seasonality with global seasonality based on how many months of data we have.
|
||||
// 12 months = 85% local, 15% global (safety net)
|
||||
// 6 months = 42.5% local, 57.5% global
|
||||
// 0 months = 0% local, 100% global
|
||||
const trustFactor = (info.monthsCount / 12) * 0.85;
|
||||
productWeights = info.weights.map((w, i) => (w * trustFactor) + (globalWeights[i] * (1 - trustFactor)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1806,8 +1807,8 @@ export const calculateForecastViewData = (
|
||||
});
|
||||
|
||||
const accuracy = totalForecastUnits > 0
|
||||
? Math.min(100, Math.round((1 - Math.abs(totalActualUnits - totalForecastUnits) / totalForecastUnits) * 100))
|
||||
: 0;
|
||||
? Math.max(0, Math.min(100, Math.round((1 - Math.abs(totalActualUnits - totalForecastUnits) / totalForecastUnits) * 100)))
|
||||
: (totalActualUnits === 0 ? 100 : 0);
|
||||
|
||||
return {
|
||||
asin: identifier,
|
||||
@@ -1839,31 +1840,48 @@ export const processVendorStockExcel = async (fileOrBuffer: File | ArrayBuffer):
|
||||
// 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')) {
|
||||
if (jsonData[i] && (jsonData[i].includes('ASIN') || jsonData[i].includes('asin'))) {
|
||||
headerRowIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (headerRowIndex === -1) {
|
||||
console.warn("Could not find header row in Vendor Stock Excel");
|
||||
return vendorStockMap;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip headers
|
||||
// A (0): ASIN
|
||||
// D (3): Marketplace (Country)
|
||||
// P (15): Sellable on hands units
|
||||
const headers: any[] = jsonData[headerRowIndex];
|
||||
const asinIdx = headers.findIndex(h => String(h || '').toUpperCase() === 'ASIN');
|
||||
const marketplaceIdx = headers.findIndex(h => {
|
||||
const sh = String(h || '').toUpperCase();
|
||||
return sh === 'MARKETPLACE' || sh === 'COUNTRY' || sh === 'COUNTRY/REGION';
|
||||
});
|
||||
|
||||
const stockIdx = headers.findIndex(h => {
|
||||
const sh = String(h || '').toUpperCase();
|
||||
return sh.includes('ON HAND') || sh.includes('SELLABLE') || sh.includes('STOCK') || sh.includes('AVAILABILITY');
|
||||
});
|
||||
|
||||
// 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;
|
||||
const finalStockIdx = stockIdx !== -1 ? stockIdx : 15;
|
||||
|
||||
for (let i = headerRowIndex + 1; i < jsonData.length; i++) {
|
||||
const row = jsonData[i];
|
||||
if (!row || row.length < 16) continue;
|
||||
|
||||
const asin = String(row[0] || '').trim().toUpperCase();
|
||||
const marketplace = String(row[3] || '').trim().toLowerCase();
|
||||
const stockValue = parseUnits(String(row[15] || '0'));
|
||||
if (!row || row.length <= Math.max(finalAsinIdx, finalMarketplaceIdx, finalStockIdx)) continue;
|
||||
|
||||
const asin = String(row[finalAsinIdx] || '').trim().toUpperCase();
|
||||
if (!asin) continue;
|
||||
|
||||
const marketplace = String(row[finalMarketplaceIdx] || '').trim().toLowerCase();
|
||||
const stockValue = parseUnits(String(row[finalStockIdx] || '0'));
|
||||
|
||||
if (!vendorStockMap.has(asin)) {
|
||||
vendorStockMap.set(asin, { eu: 0, uk: 0 });
|
||||
}
|
||||
@@ -1871,10 +1889,10 @@ export const processVendorStockExcel = async (fileOrBuffer: File | ArrayBuffer):
|
||||
const current = vendorStockMap.get(asin)!;
|
||||
|
||||
// Map to UK or EU
|
||||
if (marketplace.includes('uk') || marketplace.includes('kingdom') || marketplace === 'gb') {
|
||||
if (marketplace.includes('uk') || marketplace.includes('kingdom') || marketplace === 'gb' || marketplace === 'united kingdom') {
|
||||
current.uk += stockValue;
|
||||
} else {
|
||||
// Assume everything else is Pan-EU for now if it's not UK
|
||||
} else if (marketplace) {
|
||||
// Assume everything else with a marketplace is Pan-EU (DE, IT, FR, ES)
|
||||
current.eu += stockValue;
|
||||
}
|
||||
}
|
||||
@@ -1988,11 +2006,17 @@ export const processBuyBoxExcel = async (fileOrBuffer: File | ArrayBuffer): Prom
|
||||
|
||||
const jsonData: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
|
||||
|
||||
const headers: any[] = jsonData[0] || [];
|
||||
const asinIdx = headers.findIndex(h => String(h || '').toUpperCase() === 'ASIN');
|
||||
const finalAsinIdx = asinIdx !== -1 ? asinIdx : 1; // Default to Col B if not found
|
||||
|
||||
// Skip header row (index 0)
|
||||
for (let i = 1; i < jsonData.length; i++) {
|
||||
const row = jsonData[i];
|
||||
const asin = String(row[1] || '').trim().toUpperCase(); // Col B = index 1
|
||||
if (!asin || asin.length < 5) continue;
|
||||
if (!row || row.length <= Math.max(finalAsinIdx, config.reasonCol)) continue;
|
||||
|
||||
const rawAsin = String(row[finalAsinIdx] || '').trim().toUpperCase();
|
||||
if (!rawAsin || rawAsin.length < 5) continue;
|
||||
|
||||
const rawReason = String(row[config.reasonCol] || '').trim();
|
||||
|
||||
@@ -2000,17 +2024,18 @@ export const processBuyBoxExcel = async (fileOrBuffer: File | ArrayBuffer): Prom
|
||||
// Empty reason or "Fixed" status means no issue = they have the Buy Box
|
||||
if (!rawReason || rawReason.toLowerCase() === 'fixed') continue;
|
||||
|
||||
const reason = rawReason;
|
||||
|
||||
if (!buyBoxMap.has(asin)) {
|
||||
buyBoxMap.set(asin, { countries: [], reasons: {} });
|
||||
let entry = buyBoxMap.get(rawAsin);
|
||||
if (!entry) {
|
||||
entry = { countries: [], reasons: {} };
|
||||
buyBoxMap.set(rawAsin, entry);
|
||||
}
|
||||
|
||||
const entry = buyBoxMap.get(asin)!;
|
||||
if (!entry.countries.includes(config.country)) {
|
||||
entry.countries.push(config.country);
|
||||
}
|
||||
entry.reasons[config.country] = reason;
|
||||
|
||||
// Collect reason for this country
|
||||
entry.reasons[config.country] = rawReason;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user