From 249fba5390e289cd6eb8de448d622a9cb648f175 Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Tue, 3 Mar 2026 15:08:24 +0100 Subject: [PATCH] fix(buybox): rewrite processBuyBoxExcel with ASIN regex detection Replace fragile header/column-index approach with pattern-based ASIN detection: - Scan all cells for Amazon ASIN pattern (B[0-9A-Z]{9}) to auto-detect the ASIN column regardless of headers or position - Detect country from sheet name with flexible matching (no hardcoded sheet name list) - Detect reason column from header keywords, no hardcoded column indices - Remove all hardcoded fallback indices (col 13, 18, 9, etc.) that caused empty results Co-Authored-By: Claude Sonnet 4.6 --- services/dataProcessor.ts | 223 +++++++++++++++----------------------- 1 file changed, 89 insertions(+), 134 deletions(-) diff --git a/services/dataProcessor.ts b/services/dataProcessor.ts index 78af947..b275d87 100644 --- a/services/dataProcessor.ts +++ b/services/dataProcessor.ts @@ -2513,160 +2513,115 @@ export const processStockExcel = async (fileOrBuffer: File | ArrayBuffer): Promi } }; -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' }, -]; - export const processBuyBoxExcel = async (fileOrBuffer: File | ArrayBuffer): Promise }>> => { try { - const arrayBuffer = fileOrBuffer instanceof File - ? await fileOrBuffer.arrayBuffer() - : fileOrBuffer; + const arrayBuffer = fileOrBuffer instanceof File ? await fileOrBuffer.arrayBuffer() : fileOrBuffer; const workbook = XLSX.read(arrayBuffer, { type: 'array' }); - - // Map: ASIN -> { countries: [], reasons: {} } const buyBoxMap = new Map }>(); - // Log available sheets for debugging + // Amazon ASIN pattern: B followed by exactly 9 alphanumeric chars + const ASIN_REGEX = /^B[0-9A-Z]{9}$/; + const RESOLVED = new Set(['fixed', 'ok', 'hecho', 'solucionado', 'corrected', 'resolved', 'resuelto', 'done', 'listo']); + console.log('[BuyBox] Available sheets:', workbook.SheetNames.join(', ')); - for (const config of BB_SHEET_CONFIG) { - // Find sheet case-insensitively and with flexible separators (BB_ES, BB-ES, BB ES, ES BB) - const sheetName = workbook.SheetNames.find(name => { - const n = name.toUpperCase().replace(/[-_ ]/g, ''); - const target = config.sheet.toUpperCase().replace(/[-_ ]/g, ''); - 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'))); - }); + // Detect country code from sheet name (flexible matching) + const detectCountry = (name: string): string | null => { + const n = name.toUpperCase().replace(/[_\- ]/g, ''); + if (n.includes('FRANCE') || n.includes('FRANC') || n.includes('BBFR') || n === 'FR') return 'FR'; + if (n.includes('UNITEDKINGDOM') || n.includes('REINOUNIDO') || n.includes('BBUK') || n === 'UK' || n === 'GB') return 'UK'; + if (n.includes('GERMANY') || n.includes('DEUTSCH') || n.includes('ALEMAN') || n.includes('BBDE') || n === 'DE') return 'DE'; + if (n.includes('ITALY') || n.includes('ITALIA') || n.includes('BBIT') || n === 'IT') return 'IT'; + if (n.includes('SPAIN') || n.includes('ESPAÑA') || n.includes('ESPANA') || n.includes('BBES') || n === 'ES') return 'ES'; + return null; + }; - if (!sheetName) { - console.warn(`[BuyBox] Sheet matching ${config.sheet} not found, skipping...`); + for (const sheetName of workbook.SheetNames) { + const country = detectCountry(sheetName); + if (!country) { + console.log(`[BuyBox] Sheet "${sheetName}" — no country detected, skipping`); continue; } const worksheet = workbook.Sheets[sheetName]; - const jsonData: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1 }); - if (jsonData.length === 0) continue; + const rows: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: '' }); + if (!rows.length) continue; - // 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; - } - } - - let finalAsinIdx = headers.findIndex(h => { - const s = String(h || '').toUpperCase().replace(/[^A-Z]/g, ''); - return s === 'ASIN' || s.includes('AMAZONASIN') || s.includes('CHILDASIN'); - }); - - if (finalAsinIdx === -1) { - finalAsinIdx = headers.findIndex(h => { - const s = String(h || '').toUpperCase().replace(/[^A-Z]/g, ''); - return s.includes('IDENTIFIER') || s.includes('PRODUCTID') || s.includes('SKU'); - }); - } - - if (finalAsinIdx === -1) finalAsinIdx = 1; // Default to Col B if not found - - // Dynamically find the "Issue Type" or "Reason" column - let reasonIdx = headers.findIndex(h => { - const s = String(h || '').toUpperCase(); - 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'); - }); - - // 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'; - }); - } - - // Fallback to previous hardcoded indices if header search fails or for specific known sheet structures - if (reasonIdx === -1 || (config.country === 'FR' && reasonIdx !== 18) || (config.country === 'UK' && reasonIdx !== 9) || (config.country === 'DE' && reasonIdx !== 13) || (config.country === 'ES' && reasonIdx !== 13)) { - if (config.country === 'FR') reasonIdx = 18; // Force Column S for FR (Index 18) - 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) - else if (config.country === 'ES') reasonIdx = 13; // Force Column N for ES (Index 13) - else if (reasonIdx === -1) { - if (config.country === 'IT') reasonIdx = 12; - else reasonIdx = 13; - } - } - - // Also force ASIN column for DE if not correctly detected - if (config.country === 'DE') finalAsinIdx = 1; // Force Column B for Germany (Index 1) - if (config.country === 'ES') { - console.log(`[BuyBox Debug] ES Headers found:`, headers); - } - - // Skip header row and all rows above it - for (let i = headerRowIdx + 1; i < jsonData.length; i++) { - const row = jsonData[i]; - if (!row || row.length <= Math.max(finalAsinIdx, reasonIdx)) continue; - - const rawAsin = String(row[finalAsinIdx] || '').trim().toUpperCase(); - if (!rawAsin || rawAsin.length < 5) continue; - - const rawReason = String(row[reasonIdx] || '').trim() || 'BB Lost'; - - // Debug specific ASIN reported by user - const isTargetAsin = rawAsin === 'B0D3874WSD' || rawAsin === 'B095K8948Z'; - if (isTargetAsin) { - console.log(`[BuyBox Debug] Found ASIN ${rawAsin} in ${config.country}. Row data at reasonIdx (${reasonIdx}): "${row[reasonIdx]}", Processed Reason: "${rawReason}"`); - } - - // Only consider as BB lost if not marked as resolved - const lowReason = rawReason.toLowerCase(); - if (lowReason === 'fixed' || lowReason === 'ok' || lowReason === 'hecho' || lowReason === 'solucionado' || lowReason === 'corrected') { - if (isTargetAsin) { - console.log(`[BuyBox Debug] SKIPPING ASIN ${rawAsin} in ${config.country} because reason matches resolved status ("${rawReason}")`); + // Detect ASIN column by scanning first 100 rows for cells matching ASIN pattern + const asinHits: number[] = []; + const scanLimit = Math.min(rows.length, 100); + for (let r = 0; r < scanLimit; r++) { + const row = rows[r] || []; + for (let c = 0; c < row.length; c++) { + if (ASIN_REGEX.test(String(row[c] || '').trim().toUpperCase())) { + asinHits[c] = (asinHits[c] || 0) + 1; } - continue; } - - let entry = buyBoxMap.get(rawAsin); - if (!entry) { - entry = { countries: [], reasons: {} }; - buyBoxMap.set(rawAsin, entry); - } - - if (!entry.countries.includes(config.country)) { - entry.countries.push(config.country); - } - - // Collect reason for this country - entry.reasons[config.country] = rawReason; } + + // Column with most ASIN-pattern matches wins + let asinColIdx = -1; + let maxHits = 0; + asinHits.forEach((count, idx) => { + if (count > maxHits) { maxHits = count; asinColIdx = idx; } + }); + + // Fallback: look for a header cell containing "ASIN" + if (asinColIdx === -1) { + for (let r = 0; r < Math.min(rows.length, 15); r++) { + const idx = (rows[r] || []).findIndex((cell: any) => + String(cell || '').toUpperCase().includes('ASIN')); + if (idx !== -1) { asinColIdx = idx; break; } + } + } + + if (asinColIdx === -1) { + console.warn(`[BuyBox] Sheet "${sheetName}" (${country}): ASIN column not found, skipping`); + continue; + } + + // Detect reason column from header row keywords + let reasonColIdx = -1; + for (let r = 0; r < Math.min(rows.length, 15); r++) { + const idx = (rows[r] || []).findIndex((cell: any) => { + const s = String(cell || '').toUpperCase(); + return s.includes('ISSUE') || s.includes('REASON') || s.includes('STATUS') || + s.includes('MOTIVO') || s.includes('CAUSA') || s.includes('ESTADO') || + s.includes('COMMENT') || s.includes('OBSERV') || s.includes('SITUAC') || + s.includes('LBB') || s.includes('PROBLEMA') || s.includes('NOTE') || + s.includes('JUSTIF') || s.includes('DETALL'); + }); + if (idx !== -1) { reasonColIdx = idx; break; } + } + + console.log(`[BuyBox] Sheet "${sheetName}" → ${country}: asinCol=${asinColIdx} (${maxHits} ASINs found), reasonCol=${reasonColIdx}`); + + let count = 0; + for (const row of rows) { + if (!row || row.length <= asinColIdx) continue; + const rawAsin = String(row[asinColIdx] || '').trim().toUpperCase(); + if (!ASIN_REGEX.test(rawAsin)) continue; + + const rawReason = reasonColIdx !== -1 && row.length > reasonColIdx + ? String(row[reasonColIdx] || '').trim() + : ''; + if (RESOLVED.has(rawReason.toLowerCase())) continue; + + const reason = rawReason || 'BB Lost'; + let entry = buyBoxMap.get(rawAsin); + if (!entry) { entry = { countries: [], reasons: {} }; buyBoxMap.set(rawAsin, entry); } + if (!entry.countries.includes(country)) entry.countries.push(country); + entry.reasons[country] = reason; + count++; + } + + console.log(`[BuyBox] Sheet "${sheetName}": ${count} BB lost entries for ${country}`); } - console.log(`[BuyBox] Processed ${buyBoxMap.size} total ASINs with BB lost across all countries`); + console.log(`[BuyBox] Total: ${buyBoxMap.size} ASINs with BB issues`); return buyBoxMap; } catch (error) { - console.error("Error processing Buy Box Excel:", error); + console.error('[BuyBox] Error processing Excel:', error); throw error; } };