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:
158
src/supplier/supplier-export.ts
Normal file
158
src/supplier/supplier-export.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user