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
+89 -134
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> }>> => {
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<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(', '));
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;
}
};