import * as XLSX from "xlsx"; import type { ProductRecord } from "./types.ts"; const ASIN_REGEX = /^B[0-9A-Z]{9}$/; const COLUMN_CANDIDATES = { asin: ["asin"], name: ["name", "product name", "title", "product title"], cost: ["unit cost", "cost", "unitcost", "unit_cost", "price", "buy cost"], brand: ["brand"], category: ["category"], amazonRank: ["amazon rank", "amazonrank", "sales rank", "bsr"], avgPrice90: [ "90 day average", "90-day average", "avg price 90d", "avg 90 day", "90d average", ], sellingPrice: ["selling price", "sale price", "sell price"], fbaNet: ["fba net", "fbanet", "fba_net"], grossProfit: ["gross profit $", "gross profit", "grossprofit"], grossProfitPct: ["gross profit %", "gross profit pct", "grossprofitpct"], netProfit: ["net profit", "netprofit"], roi: ["roi", "return on investment"], moq: ["moq", "min order qty", "minimum order quantity"], moqCost: ["moq cost", "moqcost", "moq_cost"], totalQty: ["total qty avail", "totalqtyavail", "qty available", "quantity"], link: ["link", "url", "source"], asinLink: ["asin link", "amazon link", "asin url"], sourceUrl: ["source url", "supplier url", "source link"], supplier: ["supplier", "vendor"], promoCouponCode: [ "promo/coupon code", "promo coupon code", "coupon code", "promo code", ], notes: ["notes", "note"], leadDate: ["date", "lead date"], } as const; type ColumnKey = keyof typeof COLUMN_CANDIDATES; type ColumnMap = Record; export function readProducts(filePath: string): ProductRecord[] { const workbook = XLSX.readFile(filePath); const sheetName = workbook.SheetNames[0]; if (!sheetName) throw new Error("No sheets found in file"); const sheet = workbook.Sheets[sheetName]!; const rows = XLSX.utils.sheet_to_json>(sheet); if (rows.length === 0) throw new Error("File contains no data rows"); const headers = Object.keys(rows[0]!); const columns = detectColumns(headers); const asinColumn = columns.asin; if (!asinColumn) throw new Error( `No ASIN column found. Available columns: ${headers.join(", ")}`, ); logColumnDetection(headers, columns); const knownCols = getKnownColumns(columns); const products: ProductRecord[] = []; for (const row of rows) { const asin = parseAsin(row[asinColumn]); if (!asin) continue; const sourceUrl = getOptionalString(row, columns.sourceUrl); const asinLink = getOptionalString(row, columns.asinLink); const link = sourceUrl ?? asinLink ?? getOptionalString(row, columns.link); const extra = getExtraFields(row, headers, knownCols); const netProfitFromSheet = getOptionalNumber(row, columns.netProfit); const roiFromSheet = getOptionalNumber(row, columns.roi); products.push({ asin, name: getOptionalString(row, columns.name) ?? "", unitCost: getOptionalNumber(row, columns.cost) ?? 0, brand: getOptionalString(row, columns.brand), category: getOptionalString(row, columns.category), amazonRank: getOptionalNumber(row, columns.amazonRank), avgPrice90FromSheet: getOptionalNumber(row, columns.avgPrice90), sellingPriceFromSheet: getOptionalNumber(row, columns.sellingPrice), fbaNet: getOptionalNumber(row, columns.fbaNet), grossProfit: getOptionalNumber(row, columns.grossProfit) ?? netProfitFromSheet, grossProfitPct: getOptionalNumber(row, columns.grossProfitPct) ?? roiFromSheet, netProfitFromSheet, roiFromSheet, moq: getOptionalNumber(row, columns.moq), moqCost: getOptionalNumber(row, columns.moqCost), totalQtyAvail: getOptionalNumber(row, columns.totalQty), link, asinLink, sourceUrl, supplier: getOptionalString(row, columns.supplier), promoCouponCode: getOptionalString(row, columns.promoCouponCode), notes: getOptionalString(row, columns.notes), leadDate: getOptionalString(row, columns.leadDate), ...extra, }); } console.log(`Read ${products.length} valid products from ${filePath}`); return products; } function detectColumns(headers: string[]): ColumnMap { const columns = {} as ColumnMap; for (const key of Object.keys(COLUMN_CANDIDATES) as ColumnKey[]) { columns[key] = findColumn(headers, [...COLUMN_CANDIDATES[key]]); } return columns; } function logColumnDetection(headers: string[], columns: ColumnMap): void { console.log(`Found columns: ${headers.join(", ")}`); console.log( `Detected columns -> ASIN: ${columns.asin ?? "n/a"}, Name: ${columns.name ?? "n/a"}, Cost: ${columns.cost ?? "n/a"}, 90d Avg: ${columns.avgPrice90 ?? "n/a"}, Selling Price: ${columns.sellingPrice ?? "n/a"}, Net Profit: ${columns.netProfit ?? columns.grossProfit ?? "n/a"}, ROI: ${columns.roi ?? columns.grossProfitPct ?? "n/a"}, Source URL: ${columns.sourceUrl ?? "n/a"}, ASIN Link: ${columns.asinLink ?? "n/a"}`, ); } function getKnownColumns(columns: ColumnMap): Set { return new Set(Object.values(columns).filter((column): column is string => !!column)); } function parseAsin(value: unknown): string | undefined { const asin = String(value ?? "") .trim() .toUpperCase(); if (!asin || !ASIN_REGEX.test(asin)) { console.warn(`Skipping invalid ASIN: "${asin}"`); return undefined; } return asin; } function getOptionalString( row: Record, column: string | undefined, ): string | undefined { if (!column) return undefined; return normalizeOptionalString(row[column]); } function getOptionalNumber( row: Record, column: string | undefined, ): number | undefined { if (!column) return undefined; return parseOptionalNumber(row[column]); } function getExtraFields( row: Record, headers: string[], knownCols: Set, ): Record { const extra: Record = {}; for (const header of headers) { if (!knownCols.has(header)) extra[header] = row[header]; } return extra; } function findColumn( headers: string[], candidates: string[], ): string | undefined { const normalizedCandidates = new Set(candidates.map(normalizeHeader)); for (const header of headers) { if (normalizedCandidates.has(normalizeHeader(header))) { return header; } } return undefined; } function normalizeHeader(value: string): string { return value .toLowerCase() .trim() .replace(/%/g, " pct ") .replace(/\$/g, " usd ") .replace(/[^a-z0-9]/g, ""); } function normalizeOptionalString(value: unknown): string | undefined { if (value == null) return undefined; const s = String(value).trim(); return s.length > 0 ? s : undefined; } function parseOptionalNumber(value: unknown): number | undefined { if (value == null || value === "") return undefined; const cleaned = String(value).trim().replace(/[$,%]/g, "").replace(/,/g, ""); const parsed = Number(cleaned); return Number.isFinite(parsed) ? parsed : undefined; }