- Updated `SupplierAnalysisResult` to include a `product` field and modified related tests. - Refactored `addRowsSheet` to accommodate changes in the product structure. - Enhanced UPC file analysis to utilize a new `toSupplierInputRecord` function for cleaner record creation. - Introduced new types for supplier input records and product observations. - Updated frontend components to handle new product details and analysis history. - Improved database writing functions to streamline run completion and error handling. - Added new API endpoints for product details and adjusted routing in the frontend.
160 lines
4.9 KiB
TypeScript
160 lines
4.9 KiB
TypeScript
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: { name: "", unitCost: 0 },
|
|
product: null,
|
|
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);
|
|
}
|