210 lines
6.9 KiB
TypeScript
210 lines
6.9 KiB
TypeScript
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<ColumnKey, string | undefined>;
|
|
|
|
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<Record<string, unknown>>(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<string> {
|
|
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<string, unknown>,
|
|
column: string | undefined,
|
|
): string | undefined {
|
|
if (!column) return undefined;
|
|
return normalizeOptionalString(row[column]);
|
|
}
|
|
|
|
function getOptionalNumber(
|
|
row: Record<string, unknown>,
|
|
column: string | undefined,
|
|
): number | undefined {
|
|
if (!column) return undefined;
|
|
return parseOptionalNumber(row[column]);
|
|
}
|
|
|
|
function getExtraFields(
|
|
row: Record<string, unknown>,
|
|
headers: string[],
|
|
knownCols: Set<string>,
|
|
): Record<string, unknown> {
|
|
const extra: Record<string, unknown> = {};
|
|
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;
|
|
}
|