fix(parser): prevent regex collision where /sale/i matched ACOS column instead of attributed sales

The generic regex /sale/i in ads column detection was matching "Advertising Cost
of Sales" (the ACOS % column) before reaching "7 day total sales" (the actual
attributed sales amount). This caused attributedSales30d to receive the ACOS
percentage instead of the real sales value, making experiment ACOS = 0 or 100%.

- Replace /sale/i with specific regexes: /\d+\s*day.*sale/i, /total\s+sale/i, etc.
- Replace /cost/i with regexes that exclude "cost of sales" columns
- Replace /unit/i with specific regexes for unit columns
- Add diagnostic logging for ads Excel column headers and first-row values
- Revert ACOS 100% fallback (root cause now fixed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Vidal Wolf
2026-02-25 16:54:56 +01:00
co-authored by Claude Opus 4.6
parent 8b774ae7ba
commit 5dfcdd71c0
2 changed files with 18 additions and 9 deletions
+16 -6
View File
@@ -448,15 +448,15 @@ export const processAdsCSV = (file: File): Promise<AdsRecord[]> => {
const countryRaw = getColumnValue(row, ['country', 'marketplace', 'portfolio', 'customer', 'kunde']); const countryRaw = getColumnValue(row, ['country', 'marketplace', 'portfolio', 'customer', 'kunde']);
const weekRaw = getColumnValue(row, ['week', 'woche', 'semana']); const weekRaw = getColumnValue(row, ['week', 'woche', 'semana']);
const asin = getColumnValue(row, ['asin']); const asin = getColumnValue(row, ['asin']);
const costRaw = getColumnValue(row, ['cost', 'spend', 'ausgaben', 'gasto', 'coste', /ad\s*spend/i, /cost/i]); const costRaw = getColumnValue(row, ['cost', 'spend', 'ausgaben', 'gasto', 'coste', /ad\s*spend/i, /^cost$/i, /^spend$/i, /(?<!of\s)cost(?!\s*of)/i]);
const clicksRaw = getColumnValue(row, ['clicks', 'klicks', 'clics', /click/i]); const clicksRaw = getColumnValue(row, ['clicks', 'klicks', 'clics', /click/i]);
const impressionsRaw = getColumnValue(row, ['impressions', 'impresiones', 'imp', /impression/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 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 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 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]); const conversionsRaw = getColumnValue(row, ['conversions', 'konversionen', 'orders', 'pedidos', 'total orders', /order/i, /conversion/i]);
const unitsRaw = getColumnValue(row, ['units', 'einheiten', 'unidades', 'units sold', 'total units', /unit/i, /einheit/i, /unidad/i]); const unitsRaw = getColumnValue(row, ['units', 'einheiten', 'unidades', 'units sold', 'total units', /\d+\s*day.*unit/i, /total\s+unit/i, /units?\s*sold/i, /^units?$/i, /einheit/i, /unidad/i]);
const salesRaw = getColumnValue(row, ['sales', 'umsatz', 'ventas', 'ad sales', 'total sales', 'sales (30d)', /sale/i, /umsatz/i, /ventas/i]); const salesRaw = getColumnValue(row, ['sales', 'umsatz', 'ventas', 'ad sales', 'total sales', 'sales (30d)', /\d+\s*day.*sale/i, /total\s+sale/i, /attributed.*sale/i, /ad\s+sale/i, /^sales$/i, /umsatz/i, /ventas/i]);
if (!asin || !countryRaw || weekRaw === undefined || weekRaw === '') continue; if (!asin || !countryRaw || weekRaw === undefined || weekRaw === '') continue;
@@ -514,6 +514,11 @@ export const processAdsExcel = async (fileOrBuffer: File | ArrayBuffer): Promise
console.log(`Processing sheet ${sheetName}: ${jsonData.length} rows`); console.log(`Processing sheet ${sheetName}: ${jsonData.length} rows`);
// 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(' | ')}`);
}
// No need to skip index 0 since headers are mapped as keys automatically // No need to skip index 0 since headers are mapped as keys automatically
for (let i = 0; i < jsonData.length; i++) { for (let i = 0; i < jsonData.length; i++) {
const row = jsonData[i]; const row = jsonData[i];
@@ -522,15 +527,15 @@ export const processAdsExcel = async (fileOrBuffer: File | ArrayBuffer): Promise
const countryRaw = getColumnValue(row, ['country', 'marketplace', 'portfolio', 'customer', 'kunde']); const countryRaw = getColumnValue(row, ['country', 'marketplace', 'portfolio', 'customer', 'kunde']);
const weekRaw = getColumnValue(row, ['week', 'woche', 'semana']); const weekRaw = getColumnValue(row, ['week', 'woche', 'semana']);
const asin = getColumnValue(row, ['asin']); const asin = getColumnValue(row, ['asin']);
const costRaw = getColumnValue(row, ['cost', 'spend', 'ausgaben', 'gasto', 'coste', /ad\s*spend/i, /cost/i]); const costRaw = getColumnValue(row, ['cost', 'spend', 'ausgaben', 'gasto', 'coste', /ad\s*spend/i, /^cost$/i, /^spend$/i, /(?<!of\s)cost(?!\s*of)/i]);
const clicksRaw = getColumnValue(row, ['clicks', 'klicks', 'clics', /click/i]); const clicksRaw = getColumnValue(row, ['clicks', 'klicks', 'clics', /click/i]);
const impressionsRaw = getColumnValue(row, ['impressions', 'impresiones', 'imp', /impression/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 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 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 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]); const conversionsRaw = getColumnValue(row, ['conversions', 'konversionen', 'orders', 'pedidos', 'total orders', /order/i, /conversion/i]);
const unitsRaw = getColumnValue(row, ['units', 'einheiten', 'unidades', 'units sold', 'total units', /unit/i, /einheit/i, /unidad/i]); const unitsRaw = getColumnValue(row, ['units', 'einheiten', 'unidades', 'units sold', 'total units', /\d+\s*day.*unit/i, /total\s+unit/i, /units?\s*sold/i, /^units?$/i, /einheit/i, /unidad/i]);
const salesRaw = getColumnValue(row, ['sales', 'umsatz', 'ventas', 'ad sales', 'total sales', 'sales (30d)', /sale/i, /umsatz/i, /ventas/i]); const salesRaw = getColumnValue(row, ['sales', 'umsatz', 'ventas', 'ad sales', 'total sales', 'sales (30d)', /\d+\s*day.*sale/i, /total\s+sale/i, /attributed.*sale/i, /ad\s+sale/i, /^sales$/i, /umsatz/i, /ventas/i]);
// Skip if missing essential data // Skip if missing essential data
if (!asin || !countryRaw || weekRaw === undefined || weekRaw === '') continue; if (!asin || !countryRaw || weekRaw === undefined || weekRaw === '') continue;
@@ -538,6 +543,11 @@ export const processAdsExcel = async (fileOrBuffer: File | ArrayBuffer): Promise
const weekNum = parseInt(String(weekRaw)); const weekNum = parseInt(String(weekRaw));
if (isNaN(weekNum) || weekNum < 1 || weekNum > 53) continue; if (isNaN(weekNum) || weekNum < 1 || weekNum > 53) continue;
// 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}"`);
}
allData.push({ allData.push({
country: mapCountryToMarketplace(String(countryRaw)), country: mapCountryToMarketplace(String(countryRaw)),
year, year,
+2 -3
View File
@@ -110,7 +110,7 @@ function getMetricValue(w: ComputedWeeklyMetrics, metric: string): number {
case 'ctr': return w.ctr; case 'ctr': return w.ctr;
case 'roas': return w.roas; case 'roas': return w.roas;
case 'revenue': return w.revenue; case 'revenue': return w.revenue;
case 'acos': return w.cost > 0 ? (w.adRevenue > 0 ? (w.cost / w.adRevenue) * 100 : 100) : 0; case 'acos': return w.cost > 0 && w.adRevenue > 0 ? (w.cost / w.adRevenue) * 100 : 0;
default: return w.units; default: return w.units;
} }
} }
@@ -173,8 +173,7 @@ function avgMetric(data: ComputedWeeklyMetrics[], metric: string, durationWeeks:
if (metric === 'acos') { if (metric === 'acos') {
const totalAdRevenue = data.reduce((s, w) => s + w.adRevenue, 0); const totalAdRevenue = data.reduce((s, w) => s + w.adRevenue, 0);
const totalCost = data.reduce((s, w) => s + w.cost, 0); const totalCost = data.reduce((s, w) => s + w.cost, 0);
if (totalCost === 0) return 0; // No ad spend → ACOS is 0 return totalAdRevenue > 0 && totalCost > 0 ? (totalCost / totalAdRevenue) * 100 : 0;
return totalAdRevenue > 0 ? (totalCost / totalAdRevenue) * 100 : 100; // Cost but no ad revenue → 100% ACOS
} }
// For absolute quantities (units, revenue, sessions), we sum them and divide by the duration // For absolute quantities (units, revenue, sessions), we sum them and divide by the duration