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 <noreply@anthropic.com>
This commit is contained in:
Christian Vidal Wolf
2026-03-03 15:08:24 +01:00
co-authored by Claude Sonnet 4.6
parent 036e7b9a0a
commit 249fba5390
+78 -123
View File
@@ -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<Map<string, { countries: string[]; reasons: Record<string, string> }>> => { export const processBuyBoxExcel = async (fileOrBuffer: File | ArrayBuffer): Promise<Map<string, { countries: string[]; reasons: Record<string, string> }>> => {
try { try {
const arrayBuffer = fileOrBuffer instanceof File const arrayBuffer = fileOrBuffer instanceof File ? await fileOrBuffer.arrayBuffer() : fileOrBuffer;
? await fileOrBuffer.arrayBuffer()
: fileOrBuffer;
const workbook = XLSX.read(arrayBuffer, { type: 'array' }); const workbook = XLSX.read(arrayBuffer, { type: 'array' });
// Map: ASIN -> { countries: [], reasons: {} }
const buyBoxMap = new Map<string, { countries: string[]; reasons: Record<string, string> }>(); const buyBoxMap = new Map<string, { countries: string[]; reasons: Record<string, string> }>();
// 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(', ')); console.log('[BuyBox] Available sheets:', workbook.SheetNames.join(', '));
for (const config of BB_SHEET_CONFIG) { // Detect country code from sheet name (flexible matching)
// Find sheet case-insensitively and with flexible separators (BB_ES, BB-ES, BB ES, ES BB) const detectCountry = (name: string): string | null => {
const sheetName = workbook.SheetNames.find(name => { const n = name.toUpperCase().replace(/[_\- ]/g, '');
const n = name.toUpperCase().replace(/[-_ ]/g, ''); if (n.includes('FRANCE') || n.includes('FRANC') || n.includes('BBFR') || n === 'FR') return 'FR';
const target = config.sheet.toUpperCase().replace(/[-_ ]/g, ''); if (n.includes('UNITEDKINGDOM') || n.includes('REINOUNIDO') || n.includes('BBUK') || n === 'UK' || n === 'GB') return 'UK';
const country = config.country.toUpperCase(); if (n.includes('GERMANY') || n.includes('DEUTSCH') || n.includes('ALEMAN') || n.includes('BBDE') || n === 'DE') return 'DE';
// Match if: if (n.includes('ITALY') || n.includes('ITALIA') || n.includes('BBIT') || n === 'IT') return 'IT';
// 1. Exact pattern (BB_ES) if (n.includes('SPAIN') || n.includes('ESPAÑA') || n.includes('ESPANA') || n.includes('BBES') || n === 'ES') return 'ES';
// 2. Just the country code (ES) return null;
// 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')));
});
if (!sheetName) { for (const sheetName of workbook.SheetNames) {
console.warn(`[BuyBox] Sheet matching ${config.sheet} not found, skipping...`); const country = detectCountry(sheetName);
if (!country) {
console.log(`[BuyBox] Sheet "${sheetName}" — no country detected, skipping`);
continue; continue;
} }
const worksheet = workbook.Sheets[sheetName]; const worksheet = workbook.Sheets[sheetName];
const jsonData: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1 }); const rows: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: '' });
if (jsonData.length === 0) continue; if (!rows.length) continue;
// Find the header row (first row with ASIN or similar) // Detect ASIN column by scanning first 100 rows for cells matching ASIN pattern
let headerRowIdx = 0; const asinHits: number[] = [];
let headers: any[] = jsonData[0] || []; const scanLimit = Math.min(rows.length, 100);
for (let r = 0; r < Math.min(jsonData.length, 10); r++) { for (let r = 0; r < scanLimit; r++) {
const rowData = jsonData[r]; const row = rows[r] || [];
if (!rowData) continue; for (let c = 0; c < row.length; c++) {
const isHeader = rowData.some(cell => { if (ASIN_REGEX.test(String(row[c] || '').trim().toUpperCase())) {
const s = String(cell || '').toUpperCase().trim(); asinHits[c] = (asinHits[c] || 0) + 1;
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 => { // Column with most ASIN-pattern matches wins
const s = String(h || '').toUpperCase().replace(/[^A-Z]/g, ''); let asinColIdx = -1;
return s === 'ASIN' || s.includes('AMAZONASIN') || s.includes('CHILDASIN'); let maxHits = 0;
asinHits.forEach((count, idx) => {
if (count > maxHits) { maxHits = count; asinColIdx = idx; }
}); });
if (finalAsinIdx === -1) { // Fallback: look for a header cell containing "ASIN"
finalAsinIdx = headers.findIndex(h => { if (asinColIdx === -1) {
const s = String(h || '').toUpperCase().replace(/[^A-Z]/g, ''); for (let r = 0; r < Math.min(rows.length, 15); r++) {
return s.includes('IDENTIFIER') || s.includes('PRODUCTID') || s.includes('SKU'); const idx = (rows[r] || []).findIndex((cell: any) =>
}); String(cell || '').toUpperCase().includes('ASIN'));
} if (idx !== -1) { asinColIdx = idx; break; }
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 (asinColIdx === -1) {
if (config.country === 'DE') finalAsinIdx = 1; // Force Column B for Germany (Index 1) console.warn(`[BuyBox] Sheet "${sheetName}" (${country}): ASIN column not found, skipping`);
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}")`);
}
continue; 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); let entry = buyBoxMap.get(rawAsin);
if (!entry) { if (!entry) { entry = { countries: [], reasons: {} }; buyBoxMap.set(rawAsin, entry); }
entry = { countries: [], reasons: {} }; if (!entry.countries.includes(country)) entry.countries.push(country);
buyBoxMap.set(rawAsin, entry); entry.reasons[country] = reason;
count++;
} }
if (!entry.countries.includes(config.country)) { console.log(`[BuyBox] Sheet "${sheetName}": ${count} BB lost entries for ${country}`);
entry.countries.push(config.country);
} }
// Collect reason for this country console.log(`[BuyBox] Total: ${buyBoxMap.size} ASINs with BB issues`);
entry.reasons[config.country] = rawReason;
}
}
console.log(`[BuyBox] Processed ${buyBoxMap.size} total ASINs with BB lost across all countries`);
return buyBoxMap; return buyBoxMap;
} catch (error) { } catch (error) {
console.error("Error processing Buy Box Excel:", error); console.error('[BuyBox] Error processing Excel:', error);
throw error; throw error;
} }
}; };