feat: add supplier scoring and UPC file analysis functionality

- Implemented supplier scoring logic in `supplier-scoring.ts` with functions to compute demand score, competition penalty, and overall supplier product score.
- Created unit tests for supplier scoring in `supplier-scoring.test.ts` to validate scoring logic against various scenarios.
- Developed UPC file analysis tool in `upc-file-analysis.ts` to process UPCs in batches, fetch product data from Keepa and SP-API, and generate supplier results.
- Added UPC input reading functionality in `upc-file-reader.ts` to handle XLSX and XLS files, including validation for UPC formats.
- Introduced a command-line tool in `upc-lookup.ts` for looking up UPCs and displaying detailed results or mappings to ASINs.
- Enhanced error handling and logging throughout the new modules for better traceability and user feedback.
This commit is contained in:
Victor Noguera
2026-05-25 00:53:47 -04:00
parent b982edd160
commit c006d87c54
36 changed files with 1905 additions and 113 deletions

View File

@@ -0,0 +1,158 @@
import ExcelJS from "exceljs";
import { dirname } from "node:path";
import { mkdirSync } from "node:fs";
import type {
KeepaUpcLookupStatus,
SupplierAnalysisResult,
SupplierVerdict,
} from "../types.ts";
export type SupplierExportSummary = {
processedRows: number;
resolvedRows: number;
eligibleRows: number;
verdictCounts: Record<SupplierVerdict, number>;
unresolvedByStatus: Record<KeepaUpcLookupStatus, number>;
};
function pct(value: number | null): number | "" {
return value == null ? "" : Math.round(value * 10_000) / 100;
}
function rowForResult(result: SupplierAnalysisResult) {
const category =
result.record.category ?? result.keepa?.categoryTree?.join(" > ") ?? "";
const canSell =
result.spApi?.canSell == null ? "" : result.spApi.canSell ? "yes" : "no";
return {
UPC: result.upc,
ASIN: result.lookup.asin ?? "",
Name: result.record.name,
Brand: result.record.brand ?? "",
Category: category,
"Unit Cost": result.record.unitCost || "",
"Sale Price": result.score.salePrice ?? "",
"FBA Fee": result.score.fbaFee ?? "",
Profit: result.score.profit ?? "",
"Margin %": pct(result.score.margin),
"ROI %": pct(result.score.roi),
"BSR Current": result.keepa?.salesRank ?? "",
"BSR 90d": result.keepa?.salesRankAvg90 ?? "",
"Rank Drops 30d": result.keepa?.salesRankDrops30 ?? "",
"Rank Drops 90d": result.keepa?.salesRankDrops90 ?? "",
"Monthly Sold": result.keepa?.monthlySold ?? "",
"Seller Count": result.keepa?.sellerCount ?? "",
"Amazon Share 90d %": result.keepa?.amazonBuyboxSharePct90d ?? "",
"Can Sell": canSell,
Sellability: result.spApi?.sellabilityStatus ?? "",
Score: result.score.score,
Verdict: result.score.verdict,
Reason: result.score.reason,
"Lookup Status": result.lookup.status,
"Candidate ASINs": result.lookup.candidateAsins.join(","),
"Lookup Reason": result.lookup.reason ?? "",
};
}
function addRowsSheet(
workbook: ExcelJS.Workbook,
name: string,
rows: ReturnType<typeof rowForResult>[],
): void {
const sheet = workbook.addWorksheet(name);
const headers = rows[0] ? Object.keys(rows[0]) : Object.keys(rowForResult({
upc: "",
record: { asin: "", name: "", unitCost: 0 },
lookup: {
requestedUpc: "",
normalizedUpc: "",
status: "not_found",
asin: null,
candidateAsins: [],
keepaData: null,
},
keepa: null,
spApi: null,
score: {
salePrice: null,
fbaFee: null,
profit: null,
margin: null,
roi: null,
demandScore: 0,
competitionPenalty: 1,
score: 0,
verdict: "SKIP",
reason: "",
},
fetchedAt: "",
}));
sheet.columns = headers.map((header) => ({
header,
key: header,
width: Math.min(Math.max(header.length + 4, 12), 28),
}));
sheet.addRows(rows);
sheet.views = [{ state: "frozen", ySplit: 1 }];
sheet.autoFilter = {
from: { row: 1, column: 1 },
to: { row: 1, column: headers.length },
};
sheet.getRow(1).font = { bold: true };
}
function addSummarySheet(
workbook: ExcelJS.Workbook,
summary: SupplierExportSummary,
): void {
const sheet = workbook.addWorksheet("Summary");
sheet.columns = [
{ header: "Metric", key: "Metric", width: 28 },
{ header: "Value", key: "Value", width: 18 },
];
sheet.addRows([
{ Metric: "Processed Rows", Value: summary.processedRows },
{ Metric: "Resolved Rows", Value: summary.resolvedRows },
{ Metric: "Eligible Rows", Value: summary.eligibleRows },
{ Metric: "BUY", Value: summary.verdictCounts.BUY },
{ Metric: "WATCH", Value: summary.verdictCounts.WATCH },
{ Metric: "SKIP", Value: summary.verdictCounts.SKIP },
{ Metric: "Unresolved invalid_upc", Value: summary.unresolvedByStatus.invalid_upc },
{ Metric: "Unresolved not_found", Value: summary.unresolvedByStatus.not_found },
{ Metric: "Unresolved multiple_asins", Value: summary.unresolvedByStatus.multiple_asins },
{ Metric: "Unresolved request_failed", Value: summary.unresolvedByStatus.request_failed },
]);
sheet.getRow(1).font = { bold: true };
}
export async function writeSupplierWorkbook(
outputFile: string,
results: SupplierAnalysisResult[],
summary: SupplierExportSummary,
): Promise<void> {
const outputDir = dirname(outputFile);
if (outputDir && outputDir !== ".") {
mkdirSync(outputDir, { recursive: true });
}
const workbook = new ExcelJS.Workbook();
workbook.creator = "asin-check";
workbook.created = new Date();
const ranked = results
.filter((result) => result.score.verdict !== "SKIP")
.sort((a, b) => b.score.score - a.score.score)
.map(rowForResult);
const skipped = results
.filter((result) => result.score.verdict === "SKIP")
.map(rowForResult);
addRowsSheet(workbook, "Ranked Leads", ranked);
addRowsSheet(workbook, "Skipped", skipped);
addSummarySheet(workbook, summary);
await workbook.xlsx.writeFile(outputFile);
}