mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 13:15:24 +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);
|
const keyParts = new Array(dimensions.length);
|
||||||
for (let i = 0; i < dimensions.length; i++) {
|
for (let i = 0; i < dimensions.length; i++) {
|
||||||
const dim = dimensions[i];
|
const dim = dimensions[i];
|
||||||
if (dim === 'customer') keyParts[i] = String(record.customer || record.marketplace || '');
|
let val = '';
|
||||||
else keyParts[i] = String(record[dim] || '');
|
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('||');
|
const key = keyParts.join('||');
|
||||||
|
|
||||||
@@ -1578,7 +1586,9 @@ export const pivotWeeklySalesData = (data: CombinedKPIs[]): {
|
|||||||
const weekKey = getWeekKey(record.year, record.week);
|
const weekKey = getWeekKey(record.year, record.week);
|
||||||
weekKeysSet.add(weekKey);
|
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;
|
if (!key) continue;
|
||||||
|
|
||||||
let row = map.get(key);
|
let row = map.get(key);
|
||||||
@@ -1643,7 +1653,7 @@ export const calculateForecastViewData = (
|
|||||||
): ProductForecastData[] => {
|
): ProductForecastData[] => {
|
||||||
// Use referenceData if provided (for global weights), otherwise fallback to rawData
|
// Use referenceData if provided (for global weights), otherwise fallback to rawData
|
||||||
const seasonalitySource = referenceData || 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);
|
const data2026 = seasonalitySource.filter(r => r.year === 2026);
|
||||||
|
|
||||||
// Calculate Seasonality weights for 2025
|
// Calculate Seasonality weights for 2025
|
||||||
@@ -1673,7 +1683,7 @@ export const calculateForecastViewData = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 1. Determine Global/Default Weights
|
// 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)
|
// 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
|
// We recreate a simple version of getWeights that doesn't return null for the global set
|
||||||
const getGlobalWeightsInner = (records: SalesRecord[]) => {
|
const getGlobalWeightsInner = (records: SalesRecord[]) => {
|
||||||
@@ -1701,7 +1711,7 @@ export const calculateForecastViewData = (
|
|||||||
return weights.map(w => w / total);
|
return weights.map(w => w / total);
|
||||||
};
|
};
|
||||||
|
|
||||||
const panEuWeights = getGlobalWeightsInner(panEuData2025);
|
const panEuWeights = getGlobalWeightsInner(panEuHistoricalData);
|
||||||
|
|
||||||
// Check if we are in UK-only mode
|
// Check if we are in UK-only mode
|
||||||
const isUkOnly = filters?.customer?.includes('Amazon UK') && filters.customer.length === 1;
|
const isUkOnly = filters?.customer?.includes('Amazon UK') && filters.customer.length === 1;
|
||||||
@@ -1729,15 +1739,15 @@ export const calculateForecastViewData = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const globalWeights = isUkOnly
|
const globalWeights = isUkOnly
|
||||||
? getHybridWeights(panEuData2025, data2025.filter(r => r.customer === 'Amazon UK'))
|
? getHybridWeights(panEuHistoricalData, historicalData.filter(r => r.customer === 'Amazon UK'))
|
||||||
: panEuWeights;
|
: panEuWeights;
|
||||||
|
|
||||||
// Map 2025 data by ASIN for quick access
|
// Map historical data by ASIN for quick access
|
||||||
const dataByAsin2025 = new Map<string, SalesRecord[]>();
|
const dataByAsinHistorical = new Map<string, SalesRecord[]>();
|
||||||
data2025.forEach(r => {
|
historicalData.forEach(r => {
|
||||||
const key = r.asin.trim().toUpperCase();
|
const key = r.asin.trim().toUpperCase();
|
||||||
if (!dataByAsin2025.has(key)) dataByAsin2025.set(key, []);
|
if (!dataByAsinHistorical.has(key)) dataByAsinHistorical.set(key, []);
|
||||||
dataByAsin2025.get(key)!.push(r);
|
dataByAsinHistorical.get(key)!.push(r);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Map 2026 actual sales by ASIN and Month
|
// Map 2026 actual sales by ASIN and Month
|
||||||
@@ -1756,32 +1766,23 @@ export const calculateForecastViewData = (
|
|||||||
const avgWeeklySales = velocityMap?.get(identifier) || 0;
|
const avgWeeklySales = velocityMap?.get(identifier) || 0;
|
||||||
|
|
||||||
// 2. Determine weights for this ASIN
|
// 2. Determine weights for this ASIN
|
||||||
const productRecords2025 = dataByAsin2025.get(identifier) || [];
|
const productHistoricalRecords = dataByAsinHistorical.get(identifier) || [];
|
||||||
let productWeights = globalWeights;
|
let productWeights = globalWeights;
|
||||||
|
|
||||||
if (productRecords2025.length > 0) {
|
if (productHistoricalRecords.length > 0) {
|
||||||
if (isUkOnly) {
|
const historyToUse = isUkOnly
|
||||||
const ukProd = productRecords2025.filter(r => r.customer === 'Amazon UK');
|
? productHistoricalRecords.filter(r => r.customer === 'Amazon UK')
|
||||||
const info = getWeightsInfo(ukProd);
|
: productHistoricalRecords;
|
||||||
if (info) {
|
|
||||||
if (info.monthsCount < 6) {
|
const info = getWeightsInfo(historyToUse);
|
||||||
// Smart Blending: 50% specific, 50% global
|
if (info) {
|
||||||
productWeights = info.weights.map((w, i) => (w * 0.5) + (globalWeights[i] * 0.5));
|
// Adaptive Blending (Bayesian Shrinkage):
|
||||||
} else {
|
// We blend local seasonality with global seasonality based on how many months of data we have.
|
||||||
productWeights = info.weights;
|
// 12 months = 85% local, 15% global (safety net)
|
||||||
}
|
// 6 months = 42.5% local, 57.5% global
|
||||||
}
|
// 0 months = 0% local, 100% global
|
||||||
} else {
|
const trustFactor = (info.monthsCount / 12) * 0.85;
|
||||||
const info = getWeightsInfo(productRecords2025);
|
productWeights = info.weights.map((w, i) => (w * trustFactor) + (globalWeights[i] * (1 - trustFactor)));
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1806,8 +1807,8 @@ export const calculateForecastViewData = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
const accuracy = totalForecastUnits > 0
|
const accuracy = totalForecastUnits > 0
|
||||||
? Math.min(100, Math.round((1 - Math.abs(totalActualUnits - totalForecastUnits) / totalForecastUnits) * 100))
|
? Math.max(0, Math.min(100, Math.round((1 - Math.abs(totalActualUnits - totalForecastUnits) / totalForecastUnits) * 100)))
|
||||||
: 0;
|
: (totalActualUnits === 0 ? 100 : 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
asin: identifier,
|
asin: identifier,
|
||||||
@@ -1839,31 +1840,48 @@ export const processVendorStockExcel = async (fileOrBuffer: File | ArrayBuffer):
|
|||||||
// Find header row (it contains "ASIN")
|
// Find header row (it contains "ASIN")
|
||||||
let headerRowIndex = -1;
|
let headerRowIndex = -1;
|
||||||
for (let i = 0; i < Math.min(jsonData.length, 20); i++) {
|
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;
|
headerRowIndex = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (headerRowIndex === -1) {
|
if (headerRowIndex === -1) {
|
||||||
console.warn("Could not find header row in Vendor Stock Excel");
|
// Fallback: look for ASIN in first row if not found in header scan
|
||||||
return vendorStockMap;
|
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
|
const headers: any[] = jsonData[headerRowIndex];
|
||||||
// A (0): ASIN
|
const asinIdx = headers.findIndex(h => String(h || '').toUpperCase() === 'ASIN');
|
||||||
// D (3): Marketplace (Country)
|
const marketplaceIdx = headers.findIndex(h => {
|
||||||
// P (15): Sellable on hands units
|
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++) {
|
for (let i = headerRowIndex + 1; i < jsonData.length; i++) {
|
||||||
const row = jsonData[i];
|
const row = jsonData[i];
|
||||||
if (!row || row.length < 16) continue;
|
if (!row || row.length <= Math.max(finalAsinIdx, finalMarketplaceIdx, finalStockIdx)) continue;
|
||||||
|
|
||||||
const asin = String(row[0] || '').trim().toUpperCase();
|
|
||||||
const marketplace = String(row[3] || '').trim().toLowerCase();
|
|
||||||
const stockValue = parseUnits(String(row[15] || '0'));
|
|
||||||
|
|
||||||
|
const asin = String(row[finalAsinIdx] || '').trim().toUpperCase();
|
||||||
if (!asin) continue;
|
if (!asin) continue;
|
||||||
|
|
||||||
|
const marketplace = String(row[finalMarketplaceIdx] || '').trim().toLowerCase();
|
||||||
|
const stockValue = parseUnits(String(row[finalStockIdx] || '0'));
|
||||||
|
|
||||||
if (!vendorStockMap.has(asin)) {
|
if (!vendorStockMap.has(asin)) {
|
||||||
vendorStockMap.set(asin, { eu: 0, uk: 0 });
|
vendorStockMap.set(asin, { eu: 0, uk: 0 });
|
||||||
}
|
}
|
||||||
@@ -1871,10 +1889,10 @@ export const processVendorStockExcel = async (fileOrBuffer: File | ArrayBuffer):
|
|||||||
const current = vendorStockMap.get(asin)!;
|
const current = vendorStockMap.get(asin)!;
|
||||||
|
|
||||||
// Map to UK or EU
|
// 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;
|
current.uk += stockValue;
|
||||||
} else {
|
} else if (marketplace) {
|
||||||
// Assume everything else is Pan-EU for now if it's not UK
|
// Assume everything else with a marketplace is Pan-EU (DE, IT, FR, ES)
|
||||||
current.eu += stockValue;
|
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 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)
|
// Skip header row (index 0)
|
||||||
for (let i = 1; i < jsonData.length; i++) {
|
for (let i = 1; i < jsonData.length; i++) {
|
||||||
const row = jsonData[i];
|
const row = jsonData[i];
|
||||||
const asin = String(row[1] || '').trim().toUpperCase(); // Col B = index 1
|
if (!row || row.length <= Math.max(finalAsinIdx, config.reasonCol)) continue;
|
||||||
if (!asin || asin.length < 5) continue;
|
|
||||||
|
const rawAsin = String(row[finalAsinIdx] || '').trim().toUpperCase();
|
||||||
|
if (!rawAsin || rawAsin.length < 5) continue;
|
||||||
|
|
||||||
const rawReason = String(row[config.reasonCol] || '').trim();
|
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
|
// Empty reason or "Fixed" status means no issue = they have the Buy Box
|
||||||
if (!rawReason || rawReason.toLowerCase() === 'fixed') continue;
|
if (!rawReason || rawReason.toLowerCase() === 'fixed') continue;
|
||||||
|
|
||||||
const reason = rawReason;
|
let entry = buyBoxMap.get(rawAsin);
|
||||||
|
if (!entry) {
|
||||||
if (!buyBoxMap.has(asin)) {
|
entry = { countries: [], reasons: {} };
|
||||||
buyBoxMap.set(asin, { countries: [], reasons: {} });
|
buyBoxMap.set(rawAsin, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
const entry = buyBoxMap.get(asin)!;
|
|
||||||
if (!entry.countries.includes(config.country)) {
|
if (!entry.countries.includes(config.country)) {
|
||||||
entry.countries.push(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