feat: Implement supplier export functionality with workbook generation
- Add `writeSupplierWorkbook` function to create Excel workbooks for supplier analysis results. - Introduce `SupplierExportSummary` type for summarizing export data. - Create tests for `writeSupplierWorkbook` to ensure correct sheet creation and data population. - Implement supplier scoring logic in `supplier-scoring.ts` to evaluate product profitability and demand. - Add tests for supplier scoring to validate scoring logic and verdict determination. - Enhance UPC file analysis to integrate supplier scoring and export results to Excel. - Update database writing logic to accommodate new supplier analysis results. - Refactor types to include supplier-specific data structures and scoring metrics. - Ensure proper cleanup of temporary files after tests.
This commit is contained in:
17
.gitignore
vendored
17
.gitignore
vendored
@@ -6,6 +6,11 @@ out
|
|||||||
dist
|
dist
|
||||||
*.tgz
|
*.tgz
|
||||||
|
|
||||||
|
# local data directories
|
||||||
|
input/*
|
||||||
|
output/*
|
||||||
|
db/*
|
||||||
|
|
||||||
# code coverage
|
# code coverage
|
||||||
coverage
|
coverage
|
||||||
*.lcov
|
*.lcov
|
||||||
@@ -32,18 +37,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
|
|
||||||
# Finder (MacOS) folder config
|
# Finder (MacOS) folder config
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.xlsx
|
|
||||||
|
|
||||||
results.db
|
|
||||||
|
|
||||||
results.db-shm
|
|
||||||
|
|
||||||
results.db-wal
|
|
||||||
|
|
||||||
output/
|
|
||||||
|
|
||||||
temp_output/
|
temp_output/
|
||||||
|
|
||||||
dist-server/
|
dist-server/
|
||||||
|
|
||||||
*.xls
|
|
||||||
|
|||||||
24
CLAUDE.md
24
CLAUDE.md
@@ -19,9 +19,15 @@ Default to using Bun instead of Node.js.
|
|||||||
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
||||||
- Bun.$`ls` instead of execa.
|
- Bun.$`ls` instead of execa.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Use `bun test` to run tests.
|
Use `bun test` to run tests.
|
||||||
|
|
||||||
|
For this project, also use TypeScript's local compiler for type-checking:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./node_modules/.bin/tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
```ts#index.test.ts
|
```ts#index.test.ts
|
||||||
import { test, expect } from "bun:test";
|
import { test, expect } from "bun:test";
|
||||||
@@ -103,4 +109,14 @@ Then, run index.ts
|
|||||||
bun --hot ./index.ts
|
bun --hot ./index.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
|
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
|
||||||
|
|
||||||
|
## asin-check Project Notes
|
||||||
|
|
||||||
|
- Keep the existing ASIN lead-list and category flows compatible with their current LLM-based FBA/FBM/SKIP analysis.
|
||||||
|
- The supplier UPC workflow is deterministic and runs through `bun run upc-file --input input/supplier.xlsx --out output/supplier_ranked.xlsx`.
|
||||||
|
- Keep supplier spreadsheets in `input/`, generated workbooks in `output/`, and SQLite files in `db/`; folder contents are ignored by git.
|
||||||
|
- Supplier UPC files should resolve UPC/EAN values through SP-API catalog lookup first, with Keepa UPC lookup only as fallback for no-match or request-failure cases.
|
||||||
|
- The supplier pipeline should not call LM Studio. It should enrich with Keepa + SP-API sellability/fees, score BUY/WATCH/SKIP numerically, write an Excel workbook, and persist rows to SQLite.
|
||||||
|
- Supplier workbook output should keep the `Ranked Leads`, `Skipped`, and `Summary` sheets.
|
||||||
|
- When changing UPC supplier behavior, cover SP-API UPC parsing, deterministic scoring, and workbook export with `bun test`.
|
||||||
|
|||||||
35
README.md
35
README.md
@@ -21,21 +21,21 @@ cp .env.example .env
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run src/index.ts <input.csv|xlsx> [--out results.csv]
|
bun run src/index.ts input/<input.csv|xlsx> [--out output/results.xlsx]
|
||||||
```
|
```
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run src/index.ts leads.xlsx
|
bun run src/index.ts input/leads.xlsx
|
||||||
bun run src/index.ts leads.csv --out results.xlsx
|
bun run src/index.ts input/leads.csv --out output/results.xlsx
|
||||||
```
|
```
|
||||||
|
|
||||||
Large-file behavior:
|
Large-file behavior:
|
||||||
|
|
||||||
- If the input has more than 50 products, processing is done in chunks of 50.
|
- If the input has more than 50 products, processing is done in chunks of 50.
|
||||||
- Each chunk is analyzed and written to a numbered output file, for example: `results_part_001.xlsx`, `results_part_002.xlsx`, ...
|
- Each chunk is analyzed and written to a numbered output file under `output/`, for example: `output/results_part_001.xlsx`, `output/results_part_002.xlsx`, ...
|
||||||
- If `--out` is omitted for large files, the base output name defaults to `<input>_results.xlsx` and chunk files are still written with numbered suffixes.
|
- If `--out` is omitted for large files, the base output name defaults to `output/<input>_results.xlsx` and chunk files are still written with numbered suffixes.
|
||||||
|
|
||||||
Quick SP-API connectivity tests:
|
Quick SP-API connectivity tests:
|
||||||
|
|
||||||
@@ -130,27 +130,36 @@ curl -X POST "http://localhost:3000/api/upc/lookup" \
|
|||||||
|
|
||||||
## Large UPC File Analysis (XLS/XLSX)
|
## Large UPC File Analysis (XLS/XLSX)
|
||||||
|
|
||||||
For very large Excel files that contain UPC values, use the dedicated UPC-file process. It runs in batches:
|
For supplier price lists that contain UPC/EAN values and unit cost, use the
|
||||||
|
dedicated UPC-file process. It runs in batches and produces a deterministic
|
||||||
|
ranked sourcing workbook:
|
||||||
|
|
||||||
1. Reads UPC rows in batches (`.xlsx` uses streaming reader, `.xls` uses fallback row-window parsing).
|
1. Reads UPC rows in batches (`.xlsx` uses streaming reader, `.xls` uses fallback row-window parsing).
|
||||||
2. Resolves UPCs to ASINs with Keepa.
|
2. Resolves UPCs to ASINs with SP-API catalog lookup first, then falls back to Keepa for no-match/request-failure cases.
|
||||||
3. Runs the same sellability + Keepa/SP-API enrichment + LLM verdict pipeline as lead analysis.
|
3. Enriches resolved ASINs with Keepa demand/competition data and SP-API sellability + FBA fees.
|
||||||
4. Persists output into existing `runs` + `results` tables, so it appears in current reporting APIs/UI.
|
4. Scores products with deterministic BUY/WATCH/SKIP logic; this path does not call LM Studio.
|
||||||
|
5. Writes a ranked Excel workbook and persists rows into the existing `runs` + `results` tables.
|
||||||
|
|
||||||
CLI usage:
|
CLI usage:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run upc-file --input huge-upcs.xlsx
|
bun run upc-file --input input/huge-upcs.xlsx
|
||||||
bun run upc-file --input huge-upcs.xls --input-batch-size 500 --upc-lookup-batch-size 100 --max-rows 10000
|
bun run upc-file --input input/supplier.xlsx --out output/supplier_ranked.xlsx
|
||||||
|
bun run upc-file --input input/huge-upcs.xls --input-batch-size 500 --upc-lookup-batch-size 100 --max-rows 10000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Workbook output includes `Ranked Leads`, `Skipped`, and `Summary` sheets with
|
||||||
|
UPC, ASIN, cost, sale price, FBA fee, profit, margin, ROI, BSR, rank drops,
|
||||||
|
monthly sold, seller count, Amazon Buy Box share, sellability, score, verdict,
|
||||||
|
and reason columns.
|
||||||
|
|
||||||
API usage (when `bun run start:web` is running):
|
API usage (when `bun run start:web` is running):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST "http://localhost:3000/api/process/upc-file" \
|
curl -X POST "http://localhost:3000/api/process/upc-file" \
|
||||||
-H "content-type: application/json" \
|
-H "content-type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"inputFile": "/absolute/path/to/huge-upcs.xlsx",
|
"inputFile": "/absolute/path/to/input/huge-upcs.xlsx",
|
||||||
"inputBatchSize": 300,
|
"inputBatchSize": 300,
|
||||||
"upcLookupBatchSize": 100
|
"upcLookupBatchSize": 100
|
||||||
}'
|
}'
|
||||||
@@ -222,7 +231,7 @@ Numeric parsing accepts plain numbers as well as formatted values like `$12.50`,
|
|||||||
|
|
||||||
## Persistent Storage with SQLite
|
## Persistent Storage with SQLite
|
||||||
|
|
||||||
Results from each run are now stored in a SQLite database named `results.db` in the project root. The SQLite implementation details are handled in `src/database.ts`. This allows you to:
|
Results from each run are now stored in a SQLite database named `db/results.db` by default. The SQLite implementation details are handled in `src/database.ts`. This allows you to:
|
||||||
|
|
||||||
- Revisit past analysis results.
|
- Revisit past analysis results.
|
||||||
- Query and analyze historical data.
|
- Query and analyze historical data.
|
||||||
|
|||||||
6
bun.lock
6
bun.lock
@@ -16,9 +16,7 @@
|
|||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
},
|
"typescript": "^6.0.3",
|
||||||
"peerDependencies": {
|
|
||||||
"typescript": "^5",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -269,7 +267,7 @@
|
|||||||
|
|
||||||
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
|
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3"
|
"@types/react-dom": "^19.2.3",
|
||||||
},
|
"typescript": "^6.0.3"
|
||||||
"peerDependencies": {
|
|
||||||
"typescript": "^5"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"amazon-sp-api": "^1.2.1",
|
"amazon-sp-api": "^1.2.1",
|
||||||
|
|||||||
@@ -1192,7 +1192,7 @@ export async function main(): Promise<void> {
|
|||||||
|
|
||||||
mkdirSync(args.outputDir, { recursive: true });
|
mkdirSync(args.outputDir, { recursive: true });
|
||||||
const DB_PATH =
|
const DB_PATH =
|
||||||
process.env.RESULTS_DB_PATH || path.join(process.cwd(), "results.db");
|
process.env.RESULTS_DB_PATH || path.join(process.cwd(), "db", "results.db");
|
||||||
initDb(DB_PATH);
|
initDb(DB_PATH);
|
||||||
const db = getDb(DB_PATH);
|
const db = getDb(DB_PATH);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { getDb } from "./database.ts";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
async function checkDb() {
|
async function checkDb() {
|
||||||
const DB_PATH = path.join(process.cwd(), "temp_output", "analysis.sqlite");
|
const DB_PATH =
|
||||||
|
process.env.RESULTS_DB_PATH || path.join(process.cwd(), "db", "results.db");
|
||||||
const db = getDb(DB_PATH);
|
const db = getDb(DB_PATH);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { Database } from "bun:sqlite";
|
import { Database } from "bun:sqlite";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
export { Database } from "bun:sqlite";
|
export { Database } from "bun:sqlite";
|
||||||
|
|
||||||
let db: Database | null = null;
|
let db: Database | null = null;
|
||||||
|
|
||||||
export function getDb(dbPath: string): Database {
|
export function getDb(dbPath: string): Database {
|
||||||
if (!db) {
|
if (!db) {
|
||||||
|
const dbDir = dirname(dbPath);
|
||||||
|
if (dbDir && dbDir !== ".") {
|
||||||
|
mkdirSync(dbDir, { recursive: true });
|
||||||
|
}
|
||||||
db = new Database(dbPath);
|
db = new Database(dbPath);
|
||||||
db.run("PRAGMA journal_mode = WAL;"); // Enable WAL mode for better performance
|
db.run("PRAGMA journal_mode = WAL;"); // Enable WAL mode for better performance
|
||||||
db.run("PRAGMA foreign_keys = ON;"); // Enforce foreign key constraints
|
db.run("PRAGMA foreign_keys = ON;"); // Enforce foreign key constraints
|
||||||
@@ -183,6 +189,15 @@ function ensureResultsTableColumns(database: Database): void {
|
|||||||
{ name: "lead_date", type: "TEXT" },
|
{ name: "lead_date", type: "TEXT" },
|
||||||
{ name: "amazon_is_seller", type: "INTEGER" },
|
{ name: "amazon_is_seller", type: "INTEGER" },
|
||||||
{ name: "amazon_buybox_share_pct_90d", type: "REAL" },
|
{ name: "amazon_buybox_share_pct_90d", type: "REAL" },
|
||||||
|
{ name: "upc", type: "TEXT" },
|
||||||
|
{ name: "supplier_score", type: "REAL" },
|
||||||
|
{ name: "supplier_profit", type: "REAL" },
|
||||||
|
{ name: "supplier_margin", type: "REAL" },
|
||||||
|
{ name: "supplier_roi", type: "REAL" },
|
||||||
|
{ name: "supplier_reason", type: "TEXT" },
|
||||||
|
{ name: "upc_lookup_status", type: "TEXT" },
|
||||||
|
{ name: "upc_lookup_reason", type: "TEXT" },
|
||||||
|
{ name: "candidate_asins", type: "TEXT" },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const column of requiredColumns) {
|
for (const column of requiredColumns) {
|
||||||
@@ -243,9 +258,18 @@ export function initDb(dbPath: string): void {
|
|||||||
promo_coupon_code TEXT,
|
promo_coupon_code TEXT,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
lead_date TEXT,
|
lead_date TEXT,
|
||||||
|
upc TEXT,
|
||||||
fba_fee REAL,
|
fba_fee REAL,
|
||||||
fbm_fee REAL,
|
fbm_fee REAL,
|
||||||
referral_percent REAL,
|
referral_percent REAL,
|
||||||
|
supplier_score REAL,
|
||||||
|
supplier_profit REAL,
|
||||||
|
supplier_margin REAL,
|
||||||
|
supplier_roi REAL,
|
||||||
|
supplier_reason TEXT,
|
||||||
|
upc_lookup_status TEXT,
|
||||||
|
upc_lookup_reason TEXT,
|
||||||
|
candidate_asins TEXT,
|
||||||
can_sell TEXT,
|
can_sell TEXT,
|
||||||
sellability_status TEXT,
|
sellability_status TEXT,
|
||||||
sellability_reason TEXT,
|
sellability_reason TEXT,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { AnalysisResult } from "./types.ts";
|
import type { AnalysisResult } from "./types.ts";
|
||||||
|
|
||||||
const DB_PATH = "./results.db";
|
const DB_PATH = process.env.RESULTS_DB_PATH || path.join("db", "results.db");
|
||||||
const INPUT_BATCH_SIZE = 50;
|
const INPUT_BATCH_SIZE = 50;
|
||||||
|
|
||||||
function parseSellabilityArg(args: string[]): SellabilityFilter {
|
function parseSellabilityArg(args: string[]): SellabilityFilter {
|
||||||
@@ -59,7 +59,7 @@ function resolveBaseOutputPath(inputFile: string, outputFile?: string): string {
|
|||||||
if (outputFile) return outputFile;
|
if (outputFile) return outputFile;
|
||||||
|
|
||||||
const parsedInput = path.parse(inputFile);
|
const parsedInput = path.parse(inputFile);
|
||||||
return path.join(parsedInput.dir, `${parsedInput.name}_results.xlsx`);
|
return path.join("output", `${parsedInput.name}_results.xlsx`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
|||||||
17
src/keepa.ts
17
src/keepa.ts
@@ -423,14 +423,15 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
|
|||||||
salesRankDrops90,
|
salesRankDrops90,
|
||||||
sellerCount: stats?.current?.[11] ?? null,
|
sellerCount: stats?.current?.[11] ?? null,
|
||||||
amazonIsSeller,
|
amazonIsSeller,
|
||||||
amazonBuyboxSharePct90d,
|
amazonBuyboxSharePct90d,
|
||||||
buyBoxSeller: product.buyBoxSellerId ?? null,
|
buyBoxSeller: product.buyBoxSellerId ?? null,
|
||||||
buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null,
|
buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null,
|
||||||
monthlySold,
|
buyBoxAvg90: stats?.avg?.[18] != null ? stats.avg[18] / 100 : null,
|
||||||
categoryTree:
|
monthlySold,
|
||||||
product.categoryTree?.map((c: { name: string }) => c.name) ?? [],
|
categoryTree:
|
||||||
};
|
product.categoryTree?.map((c: { name: string }) => c.name) ?? [],
|
||||||
}
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function resolveAmazonIsSeller(
|
function resolveAmazonIsSeller(
|
||||||
product: Record<string, any>,
|
product: Record<string, any>,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import path from "node:path";
|
|||||||
import { rmSync, mkdirSync } from "node:fs";
|
import { rmSync, mkdirSync } from "node:fs";
|
||||||
|
|
||||||
const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
|
const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
|
||||||
return new Map(
|
return new Map<string, any>(
|
||||||
asins.map((asin) => {
|
asins.map((asin) => {
|
||||||
if (asin === "B000000003") {
|
if (asin === "B000000003") {
|
||||||
return [
|
return [
|
||||||
@@ -69,21 +69,7 @@ const DB_TEST_PATH = path.join(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let db: Database;
|
let db: Database;
|
||||||
let processCategory: (
|
let processCategory: any;
|
||||||
db: Database,
|
|
||||||
runId: number,
|
|
||||||
category: any,
|
|
||||||
perCategoryTop: number,
|
|
||||||
categoryCandidatePool: number,
|
|
||||||
minMonthlySold: number,
|
|
||||||
maxMonthlySold: number,
|
|
||||||
minPrice: number,
|
|
||||||
maxPrice: number,
|
|
||||||
minSellerCount: number,
|
|
||||||
maxSellerCount: number,
|
|
||||||
minAmazonBuyboxSharePct: number,
|
|
||||||
maxAmazonBuyboxSharePct: number,
|
|
||||||
) => Promise<any>;
|
|
||||||
let insertCategoryRunSummary: (
|
let insertCategoryRunSummary: (
|
||||||
db: Database,
|
db: Database,
|
||||||
summary: any,
|
summary: any,
|
||||||
|
|||||||
@@ -1920,7 +1920,8 @@ export async function main(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
mkdirSync(args.outputDir, { recursive: true });
|
mkdirSync(args.outputDir, { recursive: true });
|
||||||
const DB_PATH =
|
const DB_PATH =
|
||||||
process.env.RESULTS_DB_PATH || path.join(process.cwd(), "results.db");
|
process.env.RESULTS_DB_PATH ||
|
||||||
|
path.join(process.cwd(), "db", "results.db");
|
||||||
initDb(DB_PATH);
|
initDb(DB_PATH);
|
||||||
const db = getDb(DB_PATH);
|
const db = getDb(DB_PATH);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import index from "./web/index.html";
|
import index from "./web/index.html";
|
||||||
|
import path from "node:path";
|
||||||
import { getDb, initDb } from "./database.ts";
|
import { getDb, initDb } from "./database.ts";
|
||||||
import {
|
import {
|
||||||
fetchKeepaDataBatch,
|
fetchKeepaDataBatch,
|
||||||
@@ -52,7 +53,7 @@ type ProductListRecord = {
|
|||||||
fetched_at: string;
|
fetched_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DB_PATH = process.env.RESULTS_DB_PATH || "./results.db";
|
const DB_PATH = process.env.RESULTS_DB_PATH || path.join("db", "results.db");
|
||||||
const DEFAULT_PAGE_SIZE = 25;
|
const DEFAULT_PAGE_SIZE = 25;
|
||||||
const MAX_PAGE_SIZE = 200;
|
const MAX_PAGE_SIZE = 200;
|
||||||
const ASIN_PATTERN = /^[A-Z0-9]{10}$/;
|
const ASIN_PATTERN = /^[A-Z0-9]{10}$/;
|
||||||
|
|||||||
46
src/sp-api.test.ts
Normal file
46
src/sp-api.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { parseCatalogUpcLookupResponse } from "./sp-api.ts";
|
||||||
|
|
||||||
|
test("parseCatalogUpcLookupResponse resolves one ASIN", () => {
|
||||||
|
const detail = parseCatalogUpcLookupResponse("012345678901", {
|
||||||
|
items: [{ asin: "b000found1" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(detail.status).toBe("found");
|
||||||
|
expect(detail.asin).toBe("B000FOUND1");
|
||||||
|
expect(detail.candidateAsins).toEqual(["B000FOUND1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseCatalogUpcLookupResponse marks no match", () => {
|
||||||
|
const detail = parseCatalogUpcLookupResponse("012345678901", {
|
||||||
|
payload: { items: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(detail.status).toBe("not_found");
|
||||||
|
expect(detail.asin).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseCatalogUpcLookupResponse marks multiple ASINs", () => {
|
||||||
|
const detail = parseCatalogUpcLookupResponse("012345678901", {
|
||||||
|
payload: {
|
||||||
|
items: [{ asin: "B000000001" }, { asin: "B000000002" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(detail.status).toBe("multiple_asins");
|
||||||
|
expect(detail.candidateAsins).toEqual(["B000000001", "B000000002"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseCatalogUpcLookupResponse marks invalid UPCs", () => {
|
||||||
|
const detail = parseCatalogUpcLookupResponse("123", { items: [] });
|
||||||
|
|
||||||
|
expect(detail.status).toBe("invalid_upc");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseCatalogUpcLookupResponse marks malformed response as failed", () => {
|
||||||
|
const detail = parseCatalogUpcLookupResponse("012345678901", {
|
||||||
|
unexpected: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(detail.status).toBe("request_failed");
|
||||||
|
});
|
||||||
235
src/sp-api.ts
235
src/sp-api.ts
@@ -1,6 +1,11 @@
|
|||||||
import { SellingPartner } from "amazon-sp-api";
|
import { SellingPartner } from "amazon-sp-api";
|
||||||
import { config } from "./config.ts";
|
import { config } from "./config.ts";
|
||||||
import type { SpApiData, SellabilityInfo } from "./types.ts";
|
import type {
|
||||||
|
KeepaUpcLookupStatus,
|
||||||
|
SpApiData,
|
||||||
|
SellabilityInfo,
|
||||||
|
UpcLookupDetail,
|
||||||
|
} from "./types.ts";
|
||||||
|
|
||||||
type RegionCode = "na" | "eu" | "fe";
|
type RegionCode = "na" | "eu" | "fe";
|
||||||
|
|
||||||
@@ -118,10 +123,11 @@ function round2(value: number): number {
|
|||||||
return Math.round(value * 100) / 100;
|
return Math.round(value * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SELLABILITY_CONCURRENCY = 5;
|
const SELLABILITY_CONCURRENCY = 5;
|
||||||
const PRICING_CONCURRENCY = 5;
|
const PRICING_CONCURRENCY = 5;
|
||||||
|
const UPC_PATTERN = /^\d{12,14}$/;
|
||||||
|
|
||||||
function parseSellabilityResponse(response: any): SellabilityInfo {
|
function parseSellabilityResponse(response: any): SellabilityInfo {
|
||||||
const restrictions = Array.isArray(response?.restrictions)
|
const restrictions = Array.isArray(response?.restrictions)
|
||||||
? response.restrictions
|
? response.restrictions
|
||||||
: Array.isArray(response?.payload?.restrictions)
|
: Array.isArray(response?.payload?.restrictions)
|
||||||
@@ -171,7 +177,102 @@ function parseSellabilityResponse(response: any): SellabilityInfo {
|
|||||||
[...reasonCodes, ...reasonMessages].join(" | ") ||
|
[...reasonCodes, ...reasonMessages].join(" | ") ||
|
||||||
"Listing restrictions reported",
|
"Listing restrictions reported",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildUpcLookupDetail(
|
||||||
|
upc: string,
|
||||||
|
status: KeepaUpcLookupStatus,
|
||||||
|
reason: string,
|
||||||
|
candidateAsins: string[] = [],
|
||||||
|
): UpcLookupDetail {
|
||||||
|
const asin = status === "found" ? candidateAsins[0] ?? null : null;
|
||||||
|
return {
|
||||||
|
requestedUpc: upc,
|
||||||
|
normalizedUpc: upc,
|
||||||
|
status,
|
||||||
|
asin,
|
||||||
|
candidateAsins,
|
||||||
|
keepaData: null,
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectCatalogItems(response: any): any[] | null {
|
||||||
|
const candidates = [
|
||||||
|
response?.items,
|
||||||
|
response?.payload?.items,
|
||||||
|
response?.payload,
|
||||||
|
response?.Items,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (Array.isArray(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCatalogAsin(item: any): string | null {
|
||||||
|
const raw =
|
||||||
|
item?.asin ??
|
||||||
|
item?.ASIN ??
|
||||||
|
item?.identifiers?.marketplaceASIN?.asin ??
|
||||||
|
item?.Identifiers?.MarketplaceASIN?.ASIN;
|
||||||
|
if (typeof raw !== "string") return null;
|
||||||
|
const asin = raw.trim().toUpperCase();
|
||||||
|
return asin ? asin : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCatalogUpcLookupResponse(
|
||||||
|
upc: string,
|
||||||
|
response: unknown,
|
||||||
|
): UpcLookupDetail {
|
||||||
|
const normalizedUpc = upc.trim();
|
||||||
|
if (!UPC_PATTERN.test(normalizedUpc)) {
|
||||||
|
return buildUpcLookupDetail(
|
||||||
|
normalizedUpc,
|
||||||
|
"invalid_upc",
|
||||||
|
"UPC must be 12, 13, or 14 digits",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = collectCatalogItems(response);
|
||||||
|
if (!items) {
|
||||||
|
return buildUpcLookupDetail(
|
||||||
|
normalizedUpc,
|
||||||
|
"request_failed",
|
||||||
|
"Unexpected catalog response shape",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateAsins = Array.from(
|
||||||
|
new Set(items.map(extractCatalogAsin).filter((asin): asin is string => !!asin)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (candidateAsins.length === 0) {
|
||||||
|
return buildUpcLookupDetail(
|
||||||
|
normalizedUpc,
|
||||||
|
"not_found",
|
||||||
|
"No SP-API catalog item matched this UPC",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidateAsins.length > 1) {
|
||||||
|
return buildUpcLookupDetail(
|
||||||
|
normalizedUpc,
|
||||||
|
"multiple_asins",
|
||||||
|
`UPC matched multiple ASINs (${candidateAsins.length})`,
|
||||||
|
candidateAsins,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildUpcLookupDetail(
|
||||||
|
normalizedUpc,
|
||||||
|
"found",
|
||||||
|
"Matched by SP-API catalog",
|
||||||
|
candidateAsins,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchSellabilityInternal(
|
async function fetchSellabilityInternal(
|
||||||
spClient: SellingPartner,
|
spClient: SellingPartner,
|
||||||
@@ -502,9 +603,9 @@ export async function fetchSellability(asin: string): Promise<SellabilityInfo> {
|
|||||||
return fetchSellabilityInternal(spClient, asin);
|
return fetchSellabilityInternal(spClient, asin);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSellabilityBatch(
|
export async function fetchSellabilityBatch(
|
||||||
asins: string[],
|
asins: string[],
|
||||||
): Promise<Map<string, SellabilityInfo>> {
|
): Promise<Map<string, SellabilityInfo>> {
|
||||||
const results = new Map<string, SellabilityInfo>();
|
const results = new Map<string, SellabilityInfo>();
|
||||||
const spClient = getSpClient();
|
const spClient = getSpClient();
|
||||||
|
|
||||||
@@ -540,14 +641,74 @@ export async function fetchSellabilityBatch(
|
|||||||
() => next(),
|
() => next(),
|
||||||
);
|
);
|
||||||
await Promise.all(workers);
|
await Promise.all(workers);
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSpApiPricingAndFees(
|
export async function lookupSpApiUpc(upc: string): Promise<UpcLookupDetail> {
|
||||||
asin: string,
|
const normalizedUpc = upc.trim();
|
||||||
sellability: SellabilityInfo,
|
if (!UPC_PATTERN.test(normalizedUpc)) {
|
||||||
): Promise<SpApiData> {
|
return buildUpcLookupDetail(
|
||||||
|
normalizedUpc,
|
||||||
|
"invalid_upc",
|
||||||
|
"UPC must be 12, 13, or 14 digits",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const spClient = getSpClient();
|
||||||
|
if (!spClient) {
|
||||||
|
return buildUpcLookupDetail(
|
||||||
|
normalizedUpc,
|
||||||
|
"request_failed",
|
||||||
|
"SP-API credentials not configured",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await spClient.callAPI({
|
||||||
|
operation: "searchCatalogItems",
|
||||||
|
endpoint: "catalogItems",
|
||||||
|
query: {
|
||||||
|
marketplaceIds: [config.spApiMarketplaceId],
|
||||||
|
identifiers: [normalizedUpc],
|
||||||
|
identifiersType: "UPC",
|
||||||
|
includedData: ["identifiers", "summaries"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return parseCatalogUpcLookupResponse(normalizedUpc, response);
|
||||||
|
} catch (err) {
|
||||||
|
return buildUpcLookupDetail(
|
||||||
|
normalizedUpc,
|
||||||
|
"request_failed",
|
||||||
|
`SP-API catalog lookup failed: ${extractErrorMessage(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lookupSpApiUpcs(
|
||||||
|
upcs: string[],
|
||||||
|
): Promise<Map<string, UpcLookupDetail>> {
|
||||||
|
const results = new Map<string, UpcLookupDetail>();
|
||||||
|
const uniqueUpcs = Array.from(new Set(upcs.map((upc) => upc.trim())));
|
||||||
|
|
||||||
|
let completed = 0;
|
||||||
|
for (const upc of uniqueUpcs) {
|
||||||
|
const detail = await lookupSpApiUpc(upc);
|
||||||
|
results.set(upc, detail);
|
||||||
|
completed++;
|
||||||
|
if (completed % 10 === 0 || completed === uniqueUpcs.length) {
|
||||||
|
console.log(` [sp-api:catalog] ${completed}/${uniqueUpcs.length} UPCs checked`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSpApiPricingAndFees(
|
||||||
|
asin: string,
|
||||||
|
sellability: SellabilityInfo,
|
||||||
|
priceOverride?: number | null,
|
||||||
|
): Promise<SpApiData> {
|
||||||
const fallback: SpApiData = {
|
const fallback: SpApiData = {
|
||||||
fbaFee: 5.0,
|
fbaFee: 5.0,
|
||||||
fbmFee: 1.5,
|
fbmFee: 1.5,
|
||||||
@@ -561,22 +722,28 @@ export async function fetchSpApiPricingAndFees(
|
|||||||
console.log(` [sp-api:fallback] ${asin} reason=missing_credentials`);
|
console.log(` [sp-api:fallback] ${asin} reason=missing_credentials`);
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pricing = (await spClient.callAPI({
|
let estimatedSalePrice =
|
||||||
operation: "getItemOffers",
|
typeof priceOverride === "number" && Number.isFinite(priceOverride)
|
||||||
endpoint: "productPricing",
|
? priceOverride
|
||||||
path: { Asin: asin },
|
: 0;
|
||||||
query: {
|
if (estimatedSalePrice <= 0) {
|
||||||
MarketplaceId: config.spApiMarketplaceId,
|
const pricing = (await spClient.callAPI({
|
||||||
ItemCondition: "New",
|
operation: "getItemOffers",
|
||||||
},
|
endpoint: "productPricing",
|
||||||
})) as any;
|
path: { Asin: asin },
|
||||||
|
query: {
|
||||||
const estimatedSalePrice = extractEstimatedSalePrice(pricing);
|
MarketplaceId: config.spApiMarketplaceId,
|
||||||
if (!Number.isFinite(estimatedSalePrice) || estimatedSalePrice <= 0) {
|
ItemCondition: "New",
|
||||||
console.log(
|
},
|
||||||
` [sp-api:fallback] ${asin} reason=no_valid_price_from_offers`,
|
})) as any;
|
||||||
|
|
||||||
|
estimatedSalePrice = extractEstimatedSalePrice(pricing);
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(estimatedSalePrice) || estimatedSalePrice <= 0) {
|
||||||
|
console.log(
|
||||||
|
` [sp-api:fallback] ${asin} reason=no_valid_price_from_offers`,
|
||||||
);
|
);
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|||||||
134
src/supplier-export.test.ts
Normal file
134
src/supplier-export.test.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { afterEach, expect, test } from "bun:test";
|
||||||
|
import path from "node:path";
|
||||||
|
import { rmSync } from "node:fs";
|
||||||
|
import ExcelJS from "exceljs";
|
||||||
|
import { writeSupplierWorkbook } from "./supplier-export.ts";
|
||||||
|
import type { SupplierAnalysisResult } from "./types.ts";
|
||||||
|
|
||||||
|
const OUTPUT_FILE = path.join("/private/tmp", "asin-check-supplier-export-test.xlsx");
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(OUTPUT_FILE, { force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function result(overrides: Partial<SupplierAnalysisResult> = {}): SupplierAnalysisResult {
|
||||||
|
return {
|
||||||
|
upc: "012345678901",
|
||||||
|
rowNumber: 2,
|
||||||
|
record: {
|
||||||
|
asin: "B000000001",
|
||||||
|
name: "Test Product",
|
||||||
|
unitCost: 10,
|
||||||
|
brand: "Brand",
|
||||||
|
category: "Grocery",
|
||||||
|
},
|
||||||
|
lookup: {
|
||||||
|
requestedUpc: "012345678901",
|
||||||
|
normalizedUpc: "012345678901",
|
||||||
|
status: "found",
|
||||||
|
asin: "B000000001",
|
||||||
|
candidateAsins: ["B000000001"],
|
||||||
|
keepaData: null,
|
||||||
|
},
|
||||||
|
keepa: {
|
||||||
|
currentPrice: 30,
|
||||||
|
avgPrice90: 29,
|
||||||
|
minPrice90: 25,
|
||||||
|
maxPrice90: 35,
|
||||||
|
salesRank: 1000,
|
||||||
|
salesRankAvg90: 1200,
|
||||||
|
salesRankDrops30: 60,
|
||||||
|
salesRankDrops90: 180,
|
||||||
|
sellerCount: 4,
|
||||||
|
amazonIsSeller: false,
|
||||||
|
amazonBuyboxSharePct90d: 0,
|
||||||
|
buyBoxSeller: "SELLER",
|
||||||
|
buyBoxPrice: 30,
|
||||||
|
buyBoxAvg90: 29,
|
||||||
|
monthlySold: 300,
|
||||||
|
categoryTree: ["Grocery"],
|
||||||
|
},
|
||||||
|
spApi: {
|
||||||
|
fbaFee: 5,
|
||||||
|
fbmFee: 3,
|
||||||
|
referralFeePercent: 15,
|
||||||
|
estimatedSalePrice: 30,
|
||||||
|
canSell: true,
|
||||||
|
sellabilityStatus: "available",
|
||||||
|
sellabilityReason: "ok",
|
||||||
|
},
|
||||||
|
score: {
|
||||||
|
salePrice: 30,
|
||||||
|
fbaFee: 5,
|
||||||
|
profit: 15,
|
||||||
|
margin: 0.5,
|
||||||
|
roi: 1.5,
|
||||||
|
demandScore: 1,
|
||||||
|
competitionPenalty: 1,
|
||||||
|
score: 70,
|
||||||
|
verdict: "BUY",
|
||||||
|
reason: "Profitable with demand",
|
||||||
|
},
|
||||||
|
fetchedAt: "2026-05-19T00:00:00.000Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("writeSupplierWorkbook writes ranked, skipped, and summary sheets", async () => {
|
||||||
|
await writeSupplierWorkbook(
|
||||||
|
OUTPUT_FILE,
|
||||||
|
[
|
||||||
|
result(),
|
||||||
|
result({
|
||||||
|
upc: "111111111111",
|
||||||
|
record: { asin: "111111111111", name: "Missing", unitCost: 0 },
|
||||||
|
lookup: {
|
||||||
|
requestedUpc: "111111111111",
|
||||||
|
normalizedUpc: "111111111111",
|
||||||
|
status: "not_found",
|
||||||
|
asin: null,
|
||||||
|
candidateAsins: [],
|
||||||
|
keepaData: null,
|
||||||
|
reason: "No match",
|
||||||
|
},
|
||||||
|
keepa: null,
|
||||||
|
spApi: null,
|
||||||
|
score: {
|
||||||
|
salePrice: null,
|
||||||
|
fbaFee: null,
|
||||||
|
profit: null,
|
||||||
|
margin: null,
|
||||||
|
roi: null,
|
||||||
|
demandScore: 0,
|
||||||
|
competitionPenalty: 1,
|
||||||
|
score: 0,
|
||||||
|
verdict: "SKIP",
|
||||||
|
reason: "No match",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
processedRows: 2,
|
||||||
|
resolvedRows: 1,
|
||||||
|
eligibleRows: 1,
|
||||||
|
verdictCounts: { BUY: 1, WATCH: 0, SKIP: 1 },
|
||||||
|
unresolvedByStatus: {
|
||||||
|
found: 1,
|
||||||
|
invalid_upc: 0,
|
||||||
|
not_found: 1,
|
||||||
|
multiple_asins: 0,
|
||||||
|
request_failed: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
await workbook.xlsx.readFile(OUTPUT_FILE);
|
||||||
|
|
||||||
|
expect(workbook.getWorksheet("Ranked Leads")).toBeDefined();
|
||||||
|
expect(workbook.getWorksheet("Skipped")).toBeDefined();
|
||||||
|
expect(workbook.getWorksheet("Summary")).toBeDefined();
|
||||||
|
expect(workbook.getWorksheet("Ranked Leads")?.getCell("A1").value).toBe("UPC");
|
||||||
|
expect(workbook.getWorksheet("Ranked Leads")?.getCell("B2").value).toBe("B000000001");
|
||||||
|
expect(workbook.getWorksheet("Skipped")?.getCell("A2").value).toBe("111111111111");
|
||||||
|
});
|
||||||
158
src/supplier-export.ts
Normal file
158
src/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);
|
||||||
|
}
|
||||||
97
src/supplier-scoring.test.ts
Normal file
97
src/supplier-scoring.test.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { scoreSupplierProduct } from "./supplier-scoring.ts";
|
||||||
|
import type { KeepaData, ProductRecord, SpApiData } from "./types.ts";
|
||||||
|
|
||||||
|
function record(overrides: Partial<ProductRecord> = {}): ProductRecord {
|
||||||
|
return {
|
||||||
|
asin: "B000000001",
|
||||||
|
name: "Test Product",
|
||||||
|
unitCost: 10,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepa(overrides: Partial<KeepaData> = {}): KeepaData {
|
||||||
|
return {
|
||||||
|
currentPrice: 30,
|
||||||
|
avgPrice90: 29,
|
||||||
|
minPrice90: 25,
|
||||||
|
maxPrice90: 35,
|
||||||
|
salesRank: 8_000,
|
||||||
|
salesRankAvg90: 10_000,
|
||||||
|
salesRankDrops30: 80,
|
||||||
|
salesRankDrops90: 220,
|
||||||
|
sellerCount: 4,
|
||||||
|
amazonIsSeller: false,
|
||||||
|
amazonBuyboxSharePct90d: 0,
|
||||||
|
buyBoxSeller: "SELLER",
|
||||||
|
buyBoxPrice: 30,
|
||||||
|
buyBoxAvg90: 29,
|
||||||
|
monthlySold: 350,
|
||||||
|
categoryTree: ["Grocery"],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function spApi(overrides: Partial<SpApiData> = {}): SpApiData {
|
||||||
|
return {
|
||||||
|
fbaFee: 5,
|
||||||
|
fbmFee: 3,
|
||||||
|
referralFeePercent: 15,
|
||||||
|
estimatedSalePrice: 30,
|
||||||
|
canSell: true,
|
||||||
|
sellabilityStatus: "available",
|
||||||
|
sellabilityReason: "ok",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("profitable high-demand product ranks above competitive product", () => {
|
||||||
|
const strong = scoreSupplierProduct(record(), keepa(), spApi());
|
||||||
|
const competitive = scoreSupplierProduct(
|
||||||
|
record(),
|
||||||
|
keepa({
|
||||||
|
sellerCount: 35,
|
||||||
|
amazonIsSeller: true,
|
||||||
|
amazonBuyboxSharePct90d: 90,
|
||||||
|
}),
|
||||||
|
spApi(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(strong.verdict).toBe("BUY");
|
||||||
|
expect(strong.score).toBeGreaterThan(competitive.score);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing cost skips", () => {
|
||||||
|
const score = scoreSupplierProduct(record({ unitCost: 0 }), keepa(), spApi());
|
||||||
|
|
||||||
|
expect(score.verdict).toBe("SKIP");
|
||||||
|
expect(score.reason).toContain("unit cost");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restricted ASIN skips", () => {
|
||||||
|
const score = scoreSupplierProduct(
|
||||||
|
record(),
|
||||||
|
keepa(),
|
||||||
|
spApi({ canSell: false, sellabilityStatus: "restricted" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(score.verdict).toBe("SKIP");
|
||||||
|
expect(score.reason).toContain("restricted");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing price skips", () => {
|
||||||
|
const score = scoreSupplierProduct(
|
||||||
|
record(),
|
||||||
|
keepa({
|
||||||
|
currentPrice: null,
|
||||||
|
avgPrice90: null,
|
||||||
|
buyBoxPrice: null,
|
||||||
|
buyBoxAvg90: null,
|
||||||
|
}),
|
||||||
|
spApi({ estimatedSalePrice: 0 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(score.verdict).toBe("SKIP");
|
||||||
|
expect(score.reason).toContain("price");
|
||||||
|
});
|
||||||
224
src/supplier-scoring.ts
Normal file
224
src/supplier-scoring.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import type {
|
||||||
|
KeepaData,
|
||||||
|
ProductRecord,
|
||||||
|
SpApiData,
|
||||||
|
SupplierScore,
|
||||||
|
} from "./types.ts";
|
||||||
|
|
||||||
|
function round2(value: number): number {
|
||||||
|
return Math.round(value * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSupplierSalePrice(
|
||||||
|
keepa: KeepaData | null,
|
||||||
|
spApi: SpApiData | null,
|
||||||
|
): number | null {
|
||||||
|
const candidates = [
|
||||||
|
keepa?.buyBoxPrice,
|
||||||
|
keepa?.buyBoxAvg90,
|
||||||
|
keepa?.currentPrice,
|
||||||
|
keepa?.avgPrice90,
|
||||||
|
spApi?.estimatedSalePrice,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (typeof candidate === "number" && Number.isFinite(candidate) && candidate > 0) {
|
||||||
|
return round2(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeDemandScore(keepa: KeepaData | null): number {
|
||||||
|
if (!keepa) return 0;
|
||||||
|
|
||||||
|
const monthlySold = keepa.monthlySold ?? 0;
|
||||||
|
const rankDrops30 = keepa.salesRankDrops30 ?? 0;
|
||||||
|
const rankDrops90 = keepa.salesRankDrops90 ?? 0;
|
||||||
|
const velocityScore = clamp(
|
||||||
|
Math.max(monthlySold / 300, rankDrops30 / 60, rankDrops90 / 180),
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rankCandidates = [keepa.salesRank, keepa.salesRankAvg90].filter(
|
||||||
|
(value): value is number =>
|
||||||
|
typeof value === "number" && Number.isFinite(value) && value > 0,
|
||||||
|
);
|
||||||
|
const bestRank = rankCandidates.length > 0 ? Math.min(...rankCandidates) : null;
|
||||||
|
const rankScore =
|
||||||
|
bestRank == null
|
||||||
|
? 0
|
||||||
|
: bestRank <= 10_000
|
||||||
|
? 1
|
||||||
|
: bestRank <= 50_000
|
||||||
|
? 0.8
|
||||||
|
: bestRank <= 100_000
|
||||||
|
? 0.55
|
||||||
|
: bestRank <= 250_000
|
||||||
|
? 0.3
|
||||||
|
: 0.1;
|
||||||
|
|
||||||
|
return round2(clamp(velocityScore * 0.65 + rankScore * 0.35, 0, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeCompetitionPenalty(keepa: KeepaData | null): number {
|
||||||
|
if (!keepa) return 1;
|
||||||
|
|
||||||
|
const sellerCount = keepa.sellerCount ?? 0;
|
||||||
|
const sellerPenalty =
|
||||||
|
sellerCount <= 3
|
||||||
|
? 0.85
|
||||||
|
: sellerCount <= 8
|
||||||
|
? 1
|
||||||
|
: sellerCount <= 15
|
||||||
|
? 1.25
|
||||||
|
: sellerCount <= 30
|
||||||
|
? 1.6
|
||||||
|
: 2;
|
||||||
|
|
||||||
|
const amazonShare = keepa.amazonBuyboxSharePct90d ?? 0;
|
||||||
|
const amazonPenalty =
|
||||||
|
keepa.amazonIsSeller === true
|
||||||
|
? 1.35
|
||||||
|
: amazonShare >= 75
|
||||||
|
? 1.45
|
||||||
|
: amazonShare >= 35
|
||||||
|
? 1.2
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
return round2(clamp(sellerPenalty * amazonPenalty, 0.75, 2.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreSupplierProduct(
|
||||||
|
record: ProductRecord,
|
||||||
|
keepa: KeepaData | null,
|
||||||
|
spApi: SpApiData | null,
|
||||||
|
): SupplierScore {
|
||||||
|
const salePrice = resolveSupplierSalePrice(keepa, spApi);
|
||||||
|
const fbaFee = spApi?.fbaFee ?? null;
|
||||||
|
const demandScore = computeDemandScore(keepa);
|
||||||
|
const competitionPenalty = computeCompetitionPenalty(keepa);
|
||||||
|
|
||||||
|
if (spApi && spApi.sellabilityStatus !== "available") {
|
||||||
|
return {
|
||||||
|
salePrice,
|
||||||
|
fbaFee,
|
||||||
|
profit: null,
|
||||||
|
margin: null,
|
||||||
|
roi: null,
|
||||||
|
demandScore,
|
||||||
|
competitionPenalty,
|
||||||
|
score: 0,
|
||||||
|
verdict: "SKIP",
|
||||||
|
reason: `Not sellable: ${spApi.sellabilityStatus}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!salePrice) {
|
||||||
|
return {
|
||||||
|
salePrice,
|
||||||
|
fbaFee,
|
||||||
|
profit: null,
|
||||||
|
margin: null,
|
||||||
|
roi: null,
|
||||||
|
demandScore,
|
||||||
|
competitionPenalty,
|
||||||
|
score: 0,
|
||||||
|
verdict: "SKIP",
|
||||||
|
reason: "Missing sale price",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!record.unitCost || record.unitCost <= 0) {
|
||||||
|
return {
|
||||||
|
salePrice,
|
||||||
|
fbaFee,
|
||||||
|
profit: null,
|
||||||
|
margin: null,
|
||||||
|
roi: null,
|
||||||
|
demandScore,
|
||||||
|
competitionPenalty,
|
||||||
|
score: 0,
|
||||||
|
verdict: "SKIP",
|
||||||
|
reason: "Missing or invalid unit cost",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fbaFee == null || fbaFee < 0) {
|
||||||
|
return {
|
||||||
|
salePrice,
|
||||||
|
fbaFee,
|
||||||
|
profit: null,
|
||||||
|
margin: null,
|
||||||
|
roi: null,
|
||||||
|
demandScore,
|
||||||
|
competitionPenalty,
|
||||||
|
score: 0,
|
||||||
|
verdict: "SKIP",
|
||||||
|
reason: "Missing FBA fee",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const profit = round2(salePrice - record.unitCost - fbaFee);
|
||||||
|
const margin = round2(profit / salePrice);
|
||||||
|
const roi = round2(profit / record.unitCost);
|
||||||
|
|
||||||
|
if (profit <= 0 || margin <= 0 || roi <= 0) {
|
||||||
|
return {
|
||||||
|
salePrice,
|
||||||
|
fbaFee,
|
||||||
|
profit,
|
||||||
|
margin,
|
||||||
|
roi,
|
||||||
|
demandScore,
|
||||||
|
competitionPenalty,
|
||||||
|
score: 0,
|
||||||
|
verdict: "SKIP",
|
||||||
|
reason: "Non-positive profit",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (demandScore < 0.15) {
|
||||||
|
return {
|
||||||
|
salePrice,
|
||||||
|
fbaFee,
|
||||||
|
profit,
|
||||||
|
margin,
|
||||||
|
roi,
|
||||||
|
demandScore,
|
||||||
|
competitionPenalty,
|
||||||
|
score: 0,
|
||||||
|
verdict: "SKIP",
|
||||||
|
reason: "Weak demand signals",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawScore =
|
||||||
|
((margin * 0.55 + clamp(roi, 0, 2) * 0.45) * demandScore * 100) /
|
||||||
|
competitionPenalty;
|
||||||
|
const score = round2(clamp(rawScore, 0, 100));
|
||||||
|
const verdict = score >= 18 && margin >= 0.18 && roi >= 0.3 ? "BUY" : "WATCH";
|
||||||
|
const reason =
|
||||||
|
verdict === "BUY"
|
||||||
|
? "Profitable with demand"
|
||||||
|
: "Viable but needs review";
|
||||||
|
|
||||||
|
return {
|
||||||
|
salePrice,
|
||||||
|
fbaFee,
|
||||||
|
profit,
|
||||||
|
margin,
|
||||||
|
roi,
|
||||||
|
demandScore,
|
||||||
|
competitionPenalty,
|
||||||
|
score,
|
||||||
|
verdict,
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import path from "node:path";
|
|||||||
import { rmSync, mkdirSync } from "node:fs";
|
import { rmSync, mkdirSync } from "node:fs";
|
||||||
|
|
||||||
const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
|
const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
|
||||||
return new Map(
|
return new Map<string, any>(
|
||||||
asins.map((asin) => {
|
asins.map((asin) => {
|
||||||
if (asin === "B000000003") {
|
if (asin === "B000000003") {
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -1286,7 +1286,7 @@ export async function main(): Promise<void> {
|
|||||||
|
|
||||||
mkdirSync(args.outputDir, { recursive: true });
|
mkdirSync(args.outputDir, { recursive: true });
|
||||||
const DB_PATH =
|
const DB_PATH =
|
||||||
process.env.RESULTS_DB_PATH || path.join(process.cwd(), "results.db");
|
process.env.RESULTS_DB_PATH || path.join(process.cwd(), "db", "results.db");
|
||||||
initDb(DB_PATH);
|
initDb(DB_PATH);
|
||||||
const db = getDb(DB_PATH);
|
const db = getDb(DB_PATH);
|
||||||
|
|
||||||
|
|||||||
71
src/types.ts
71
src/types.ts
@@ -26,23 +26,24 @@ export interface ProductRecord {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KeepaData {
|
export interface KeepaData {
|
||||||
currentPrice: number | null;
|
currentPrice: number | null;
|
||||||
avgPrice90: number | null;
|
avgPrice90: number | null;
|
||||||
minPrice90: number | null;
|
minPrice90: number | null;
|
||||||
maxPrice90: number | null;
|
maxPrice90: number | null;
|
||||||
salesRank: number | null;
|
salesRank: number | null;
|
||||||
salesRankAvg90: number | null;
|
salesRankAvg90: number | null;
|
||||||
salesRankDrops30: number | null;
|
salesRankDrops30: number | null;
|
||||||
salesRankDrops90: number | null;
|
salesRankDrops90: number | null;
|
||||||
sellerCount: number | null;
|
sellerCount: number | null;
|
||||||
amazonIsSeller: boolean | null;
|
amazonIsSeller: boolean | null;
|
||||||
amazonBuyboxSharePct90d: number | null;
|
amazonBuyboxSharePct90d: number | null;
|
||||||
buyBoxSeller: string | null;
|
buyBoxSeller: string | null;
|
||||||
buyBoxPrice: number | null;
|
buyBoxPrice: number | null;
|
||||||
monthlySold: number | null;
|
buyBoxAvg90?: number | null;
|
||||||
categoryTree: string[];
|
monthlySold: number | null;
|
||||||
}
|
categoryTree: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export type KeepaUpcLookupStatus =
|
export type KeepaUpcLookupStatus =
|
||||||
| "found"
|
| "found"
|
||||||
@@ -51,15 +52,17 @@ export type KeepaUpcLookupStatus =
|
|||||||
| "multiple_asins"
|
| "multiple_asins"
|
||||||
| "request_failed";
|
| "request_failed";
|
||||||
|
|
||||||
export interface KeepaUpcLookupDetail {
|
export interface KeepaUpcLookupDetail {
|
||||||
requestedUpc: string;
|
requestedUpc: string;
|
||||||
normalizedUpc: string;
|
normalizedUpc: string;
|
||||||
status: KeepaUpcLookupStatus;
|
status: KeepaUpcLookupStatus;
|
||||||
asin: string | null;
|
asin: string | null;
|
||||||
candidateAsins: string[];
|
candidateAsins: string[];
|
||||||
keepaData: KeepaData | null;
|
keepaData: KeepaData | null;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UpcLookupDetail = KeepaUpcLookupDetail;
|
||||||
|
|
||||||
export type SellabilityInfo = {
|
export type SellabilityInfo = {
|
||||||
canSell: boolean | null;
|
canSell: boolean | null;
|
||||||
@@ -88,10 +91,36 @@ export interface LlmVerdict {
|
|||||||
reasoning: string;
|
reasoning: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AnalysisResult {
|
export interface AnalysisResult {
|
||||||
product: EnrichedProduct;
|
product: EnrichedProduct;
|
||||||
verdict: LlmVerdict;
|
verdict: LlmVerdict;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SupplierVerdict = "BUY" | "WATCH" | "SKIP";
|
||||||
|
|
||||||
|
export interface SupplierScore {
|
||||||
|
salePrice: number | null;
|
||||||
|
fbaFee: number | null;
|
||||||
|
profit: number | null;
|
||||||
|
margin: number | null;
|
||||||
|
roi: number | null;
|
||||||
|
demandScore: number;
|
||||||
|
competitionPenalty: number;
|
||||||
|
score: number;
|
||||||
|
verdict: SupplierVerdict;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupplierAnalysisResult {
|
||||||
|
upc: string;
|
||||||
|
rowNumber?: number;
|
||||||
|
record: ProductRecord;
|
||||||
|
lookup: UpcLookupDetail;
|
||||||
|
keepa: KeepaData | null;
|
||||||
|
spApi: SpApiData | null;
|
||||||
|
score: SupplierScore;
|
||||||
|
fetchedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CategoryRunSummaryDb {
|
export interface CategoryRunSummaryDb {
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
|
|||||||
@@ -1,28 +1,40 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { lookupKeepaUpcs } from "./keepa.ts";
|
import { fetchKeepaDataBatch, lookupKeepaUpcs } from "./keepa.ts";
|
||||||
import { processProductChunk, chunkArray } from "./analysis-pipeline.ts";
|
import {
|
||||||
|
fetchSellabilityBatch,
|
||||||
|
fetchSpApiPricingAndFees,
|
||||||
|
lookupSpApiUpcs,
|
||||||
|
} from "./sp-api.ts";
|
||||||
import {
|
import {
|
||||||
processUpcFileInBatches,
|
processUpcFileInBatches,
|
||||||
type UpcInputRow,
|
type UpcInputRow,
|
||||||
} from "./upc-file-reader.ts";
|
} from "./upc-file-reader.ts";
|
||||||
import {
|
import {
|
||||||
appendResultsToRun,
|
appendSupplierResultsToRun,
|
||||||
printResults,
|
|
||||||
refreshRunCountsInDb,
|
refreshRunCountsInDb,
|
||||||
startRunInDb,
|
startRunInDb,
|
||||||
type RunCounts,
|
type RunCounts,
|
||||||
} from "./writer.ts";
|
} from "./writer.ts";
|
||||||
import { initDb, closeDb } from "./database.ts";
|
import { initDb, closeDb } from "./database.ts";
|
||||||
import { connectCache, disconnectCache } from "./cache.ts";
|
import { connectCache, disconnectCache } from "./cache.ts";
|
||||||
|
import { scoreSupplierProduct, resolveSupplierSalePrice } from "./supplier-scoring.ts";
|
||||||
|
import {
|
||||||
|
writeSupplierWorkbook,
|
||||||
|
type SupplierExportSummary,
|
||||||
|
} from "./supplier-export.ts";
|
||||||
import type {
|
import type {
|
||||||
KeepaUpcLookupDetail,
|
KeepaUpcLookupDetail,
|
||||||
KeepaUpcLookupStatus,
|
KeepaUpcLookupStatus,
|
||||||
ProductRecord,
|
ProductRecord,
|
||||||
|
SupplierAnalysisResult,
|
||||||
|
SupplierScore,
|
||||||
|
UpcLookupDetail,
|
||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
|
|
||||||
const DB_PATH = process.env.RESULTS_DB_PATH || "./results.db";
|
const DB_PATH = process.env.RESULTS_DB_PATH || path.join("db", "results.db");
|
||||||
const DEFAULT_INPUT_BATCH_SIZE = 200;
|
const DEFAULT_INPUT_BATCH_SIZE = 200;
|
||||||
const DEFAULT_UPC_LOOKUP_BATCH_SIZE = 100;
|
const DEFAULT_UPC_LOOKUP_BATCH_SIZE = 100;
|
||||||
|
const DEFAULT_PRICING_CONCURRENCY = 5;
|
||||||
|
|
||||||
export type UpcFileAnalysisOptions = {
|
export type UpcFileAnalysisOptions = {
|
||||||
inputFile: string;
|
inputFile: string;
|
||||||
@@ -55,7 +67,7 @@ export type UpcFileAnalysisSummary = {
|
|||||||
function printUsage(): void {
|
function printUsage(): void {
|
||||||
console.log("Usage:");
|
console.log("Usage:");
|
||||||
console.log(
|
console.log(
|
||||||
" bun run src/upc-file-analysis.ts --input <file.xls|file.xlsx> [--out output.xlsx] [--input-batch-size 200] [--upc-lookup-batch-size 100] [--max-rows 1000]",
|
" bun run src/upc-file-analysis.ts --input input/<file.xls|file.xlsx> [--out output/results.xlsx] [--input-batch-size 200] [--upc-lookup-batch-size 100] [--max-rows 1000]",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +158,7 @@ function parseArgs(argv: string[]): UpcFileAnalysisOptions {
|
|||||||
|
|
||||||
function resolveDefaultOutputPath(inputFile: string): string {
|
function resolveDefaultOutputPath(inputFile: string): string {
|
||||||
const parsedInput = path.parse(inputFile);
|
const parsedInput = path.parse(inputFile);
|
||||||
return path.join(parsedInput.dir, `${parsedInput.name}_upc_results.xlsx`);
|
return path.join("output", `${parsedInput.name}_upc_results.xlsx`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createStatusCounter(): Record<KeepaUpcLookupStatus, number> {
|
function createStatusCounter(): Record<KeepaUpcLookupStatus, number> {
|
||||||
@@ -159,15 +171,38 @@ function createStatusCounter(): Record<KeepaUpcLookupStatus, number> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chunkArray<T>(items: T[], chunkSize: number): T[][] {
|
||||||
|
const chunks: T[][] = [];
|
||||||
|
for (let i = 0; i < items.length; i += chunkSize) {
|
||||||
|
chunks.push(items.slice(i, i + chunkSize));
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skippedScore(reason: string): SupplierScore {
|
||||||
|
return {
|
||||||
|
salePrice: null,
|
||||||
|
fbaFee: null,
|
||||||
|
profit: null,
|
||||||
|
margin: null,
|
||||||
|
roi: null,
|
||||||
|
demandScore: 0,
|
||||||
|
competitionPenalty: 1,
|
||||||
|
score: 0,
|
||||||
|
verdict: "SKIP",
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function lookupUpcsWithChunking(
|
async function lookupUpcsWithChunking(
|
||||||
rows: UpcInputRow[],
|
rows: UpcInputRow[],
|
||||||
lookupBatchSize: number,
|
lookupBatchSize: number,
|
||||||
runCache: Map<string, KeepaUpcLookupDetail>,
|
runCache: Map<string, KeepaUpcLookupDetail>,
|
||||||
): Promise<Map<string, KeepaUpcLookupDetail>> {
|
): Promise<Map<string, UpcLookupDetail>> {
|
||||||
const uniqueUpcs = Array.from(new Set(rows.map((row) => row.upc)));
|
const uniqueUpcs = Array.from(new Set(rows.map((row) => row.upc)));
|
||||||
const missingUpcs = uniqueUpcs.filter((upc) => !runCache.has(upc));
|
const missingUpcs = uniqueUpcs.filter((upc) => !runCache.has(upc));
|
||||||
const chunks = chunkArray(missingUpcs, lookupBatchSize);
|
const chunks = chunkArray(missingUpcs, lookupBatchSize);
|
||||||
const details = new Map<string, KeepaUpcLookupDetail>();
|
const details = new Map<string, UpcLookupDetail>();
|
||||||
|
|
||||||
const cacheHits = uniqueUpcs.length - missingUpcs.length;
|
const cacheHits = uniqueUpcs.length - missingUpcs.length;
|
||||||
if (cacheHits > 0) {
|
if (cacheHits > 0) {
|
||||||
@@ -187,10 +222,31 @@ async function lookupUpcsWithChunking(
|
|||||||
for (let i = 0; i < chunks.length; i++) {
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
const chunk = chunks[i]!;
|
const chunk = chunks[i]!;
|
||||||
console.log(
|
console.log(
|
||||||
` Keepa UPC lookup chunk ${i + 1}/${chunks.length} (${chunk.length} UPCs)...`,
|
` SP-API UPC lookup chunk ${i + 1}/${chunks.length} (${chunk.length} UPCs)...`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const chunkDetails = await lookupKeepaUpcs(chunk);
|
const spDetails = await lookupSpApiUpcs(chunk);
|
||||||
|
const fallbackUpcs = Array.from(spDetails.values())
|
||||||
|
.filter(
|
||||||
|
(detail) =>
|
||||||
|
detail.status === "not_found" || detail.status === "request_failed",
|
||||||
|
)
|
||||||
|
.map((detail) => detail.normalizedUpc);
|
||||||
|
const fallbackDetails =
|
||||||
|
fallbackUpcs.length > 0 ? await lookupKeepaUpcs(fallbackUpcs) : new Map();
|
||||||
|
|
||||||
|
const chunkDetails = new Map<string, UpcLookupDetail>();
|
||||||
|
for (const upc of chunk) {
|
||||||
|
const spDetail = spDetails.get(upc);
|
||||||
|
const fallbackDetail = fallbackDetails.get(upc);
|
||||||
|
chunkDetails.set(
|
||||||
|
upc,
|
||||||
|
fallbackDetail && fallbackDetail.status !== "request_failed"
|
||||||
|
? fallbackDetail
|
||||||
|
: spDetail!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
for (const [upc, detail] of chunkDetails.entries()) {
|
for (const [upc, detail] of chunkDetails.entries()) {
|
||||||
runCache.set(upc, detail);
|
runCache.set(upc, detail);
|
||||||
}
|
}
|
||||||
@@ -208,7 +264,7 @@ async function lookupUpcsWithChunking(
|
|||||||
|
|
||||||
function toProductRecord(
|
function toProductRecord(
|
||||||
row: UpcInputRow,
|
row: UpcInputRow,
|
||||||
detail: KeepaUpcLookupDetail,
|
detail: UpcLookupDetail,
|
||||||
): ProductRecord {
|
): ProductRecord {
|
||||||
const keepaCategory = detail.keepaData?.categoryTree?.[0];
|
const keepaCategory = detail.keepaData?.categoryTree?.[0];
|
||||||
|
|
||||||
@@ -221,6 +277,65 @@ function toProductRecord(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchFeesForProducts(
|
||||||
|
products: ProductRecord[],
|
||||||
|
keepaResults: Map<string, NonNullable<SupplierAnalysisResult["keepa"]>>,
|
||||||
|
sellabilityMap: Awaited<ReturnType<typeof fetchSellabilityBatch>>,
|
||||||
|
): Promise<Map<string, NonNullable<SupplierAnalysisResult["spApi"]>>> {
|
||||||
|
const spApiResults = new Map<string, NonNullable<SupplierAnalysisResult["spApi"]>>();
|
||||||
|
const queue = [...products];
|
||||||
|
let completed = 0;
|
||||||
|
|
||||||
|
async function next(): Promise<void> {
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const product = queue.shift();
|
||||||
|
if (!product) return;
|
||||||
|
const sellability =
|
||||||
|
sellabilityMap.get(product.asin) ?? {
|
||||||
|
canSell: null,
|
||||||
|
sellabilityStatus: "unknown" as const,
|
||||||
|
sellabilityReason: "Sellability check returned no result",
|
||||||
|
};
|
||||||
|
const price = resolveSupplierSalePrice(
|
||||||
|
keepaResults.get(product.asin) ?? null,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const spApi = await fetchSpApiPricingAndFees(product.asin, sellability, price);
|
||||||
|
spApiResults.set(product.asin, spApi);
|
||||||
|
completed++;
|
||||||
|
if (completed % 10 === 0 || completed === products.length) {
|
||||||
|
console.log(` [fees] ${completed}/${products.length} fetched`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workers = Array.from(
|
||||||
|
{ length: Math.min(DEFAULT_PRICING_CONCURRENCY, products.length || 1) },
|
||||||
|
() => next(),
|
||||||
|
);
|
||||||
|
await Promise.all(workers);
|
||||||
|
return spApiResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeSupplierResults(
|
||||||
|
results: SupplierAnalysisResult[],
|
||||||
|
unresolvedByStatus: Record<KeepaUpcLookupStatus, number>,
|
||||||
|
): SupplierExportSummary {
|
||||||
|
return {
|
||||||
|
processedRows: results.length,
|
||||||
|
resolvedRows: results.filter((result) => result.lookup.status === "found").length,
|
||||||
|
eligibleRows: results.filter(
|
||||||
|
(result) => result.spApi?.sellabilityStatus === "available",
|
||||||
|
).length,
|
||||||
|
verdictCounts: {
|
||||||
|
BUY: results.filter((result) => result.score.verdict === "BUY").length,
|
||||||
|
WATCH: results.filter((result) => result.score.verdict === "WATCH").length,
|
||||||
|
SKIP: results.filter((result) => result.score.verdict === "SKIP").length,
|
||||||
|
},
|
||||||
|
unresolvedByStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function runUpcFileAnalysis(
|
export async function runUpcFileAnalysis(
|
||||||
options: UpcFileAnalysisOptions,
|
options: UpcFileAnalysisOptions,
|
||||||
): Promise<UpcFileAnalysisSummary> {
|
): Promise<UpcFileAnalysisSummary> {
|
||||||
@@ -245,7 +360,7 @@ export async function runUpcFileAnalysis(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const unresolvedByStatus = createStatusCounter();
|
const unresolvedByStatus = createStatusCounter();
|
||||||
const printableSample = [];
|
const allResults: SupplierAnalysisResult[] = [];
|
||||||
const upcLookupCache = new Map<string, KeepaUpcLookupDetail>();
|
const upcLookupCache = new Map<string, KeepaUpcLookupDetail>();
|
||||||
let processedRows = 0;
|
let processedRows = 0;
|
||||||
let matchedRows = 0;
|
let matchedRows = 0;
|
||||||
@@ -267,7 +382,11 @@ export async function runUpcFileAnalysis(
|
|||||||
upcLookupCache,
|
upcLookupCache,
|
||||||
);
|
);
|
||||||
|
|
||||||
const matchedProducts: ProductRecord[] = [];
|
const matchedEntries: Array<{
|
||||||
|
row: UpcInputRow;
|
||||||
|
detail: UpcLookupDetail;
|
||||||
|
product: ProductRecord;
|
||||||
|
}> = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const detail = detailMap.get(row.upc);
|
const detail = detailMap.get(row.upc);
|
||||||
if (!detail) {
|
if (!detail) {
|
||||||
@@ -279,25 +398,91 @@ export async function runUpcFileAnalysis(
|
|||||||
|
|
||||||
if (detail.status === "found" && detail.asin) {
|
if (detail.status === "found" && detail.asin) {
|
||||||
matchedRows += 1;
|
matchedRows += 1;
|
||||||
matchedProducts.push(toProductRecord(row, detail));
|
matchedEntries.push({
|
||||||
|
row,
|
||||||
|
detail,
|
||||||
|
product: toProductRecord(row, detail),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const matchedProducts = matchedEntries.map((entry) => entry.product);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Batch ${batchNumber}: ${matchedProducts.length}/${rows.length} rows resolved to single ASINs`,
|
`Batch ${batchNumber}: ${matchedProducts.length}/${rows.length} rows resolved to single ASINs`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (matchedProducts.length === 0) {
|
const batchResults: SupplierAnalysisResult[] = [];
|
||||||
return;
|
for (const row of rows) {
|
||||||
|
const detail = detailMap.get(row.upc);
|
||||||
|
if (!detail || detail.status === "found") continue;
|
||||||
|
|
||||||
|
batchResults.push({
|
||||||
|
upc: row.upc,
|
||||||
|
rowNumber: row.rowNumber,
|
||||||
|
record: {
|
||||||
|
asin: detail?.asin ?? row.upc,
|
||||||
|
name: row.name ?? row.upc,
|
||||||
|
unitCost: row.unitCost ?? 0,
|
||||||
|
brand: row.brand,
|
||||||
|
category: row.category,
|
||||||
|
},
|
||||||
|
lookup:
|
||||||
|
detail ??
|
||||||
|
({
|
||||||
|
requestedUpc: row.upc,
|
||||||
|
normalizedUpc: row.upc,
|
||||||
|
status: "request_failed",
|
||||||
|
asin: null,
|
||||||
|
candidateAsins: [],
|
||||||
|
keepaData: null,
|
||||||
|
reason: "UPC lookup returned no result",
|
||||||
|
} satisfies UpcLookupDetail),
|
||||||
|
keepa: null,
|
||||||
|
spApi: null,
|
||||||
|
score: skippedScore(detail?.reason ?? "UPC unresolved"),
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const analyzed = await processProductChunk(matchedProducts);
|
if (matchedProducts.length > 0) {
|
||||||
appendResultsToRun(dbPath, runId, analyzed);
|
console.log(`Fetching ${matchedProducts.length} ASINs from Keepa...`);
|
||||||
|
const keepaResults = await fetchKeepaDataBatch(
|
||||||
|
matchedProducts.map((product) => product.asin),
|
||||||
|
);
|
||||||
|
|
||||||
if (printableSample.length < 200) {
|
console.log(`Checking sellability for ${matchedProducts.length} ASINs...`);
|
||||||
const remaining = 200 - printableSample.length;
|
const sellabilityMap = await fetchSellabilityBatch(
|
||||||
printableSample.push(...analyzed.slice(0, remaining));
|
matchedProducts.map((product) => product.asin),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Fetching fees for ${matchedProducts.length} ASINs...`);
|
||||||
|
const spApiResults = await fetchFeesForProducts(
|
||||||
|
matchedProducts,
|
||||||
|
keepaResults,
|
||||||
|
sellabilityMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const entry of matchedEntries) {
|
||||||
|
const keepa =
|
||||||
|
keepaResults.get(entry.product.asin) ??
|
||||||
|
entry.detail.keepaData ??
|
||||||
|
null;
|
||||||
|
const spApi = spApiResults.get(entry.product.asin) ?? null;
|
||||||
|
batchResults.push({
|
||||||
|
upc: entry.detail.normalizedUpc,
|
||||||
|
rowNumber: entry.row.rowNumber,
|
||||||
|
record: entry.product,
|
||||||
|
lookup: entry.detail,
|
||||||
|
keepa,
|
||||||
|
spApi,
|
||||||
|
score: scoreSupplierProduct(entry.product, keepa, spApi),
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appendSupplierResultsToRun(dbPath, runId, batchResults);
|
||||||
|
allResults.push(...batchResults);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
batchSize: inputBatchSize,
|
batchSize: inputBatchSize,
|
||||||
@@ -307,17 +492,34 @@ export async function runUpcFileAnalysis(
|
|||||||
|
|
||||||
const runCounts = refreshRunCountsInDb(dbPath, runId);
|
const runCounts = refreshRunCountsInDb(dbPath, runId);
|
||||||
|
|
||||||
if (printableSample.length > 0) {
|
const exportSummary = summarizeSupplierResults(allResults, unresolvedByStatus);
|
||||||
printResults(printableSample);
|
await writeSupplierWorkbook(outputFile, allResults, exportSummary);
|
||||||
if (runCounts.totalProducts > printableSample.length) {
|
|
||||||
console.log(
|
if (allResults.length > 0) {
|
||||||
`Printed ${printableSample.length} sampled results out of ${runCounts.totalProducts} analyzed products.`,
|
const ranked = allResults
|
||||||
);
|
.filter((result) => result.score.verdict !== "SKIP")
|
||||||
}
|
.sort((a, b) => b.score.score - a.score.score)
|
||||||
|
.slice(0, 25)
|
||||||
|
.map((result) => ({
|
||||||
|
UPC: result.upc,
|
||||||
|
ASIN: result.lookup.asin ?? "",
|
||||||
|
Name: result.record.name.slice(0, 40),
|
||||||
|
Cost: result.record.unitCost,
|
||||||
|
Price: result.score.salePrice ?? "",
|
||||||
|
Profit: result.score.profit ?? "",
|
||||||
|
ROI: result.score.roi == null ? "" : `${Math.round(result.score.roi * 100)}%`,
|
||||||
|
Score: result.score.score,
|
||||||
|
Verdict: result.score.verdict,
|
||||||
|
Reason: result.score.reason,
|
||||||
|
}));
|
||||||
|
console.log("\n=== Top Supplier Leads ===\n");
|
||||||
|
console.table(ranked);
|
||||||
} else {
|
} else {
|
||||||
console.log("No products were eligible for analysis after UPC mapping.");
|
console.log("No supplier rows were analyzed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`Ranked workbook written: ${outputFile}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
runId,
|
runId,
|
||||||
dbPath,
|
dbPath,
|
||||||
|
|||||||
@@ -123,6 +123,9 @@ async function processXlsxStreaming(
|
|||||||
}
|
}
|
||||||
|
|
||||||
seenRows += 1;
|
seenRows += 1;
|
||||||
|
if (!columns) {
|
||||||
|
throw new Error("UPC reader columns were not initialized.");
|
||||||
|
}
|
||||||
const parsed = parseUpcInputRow(values, columns, row.number);
|
const parsed = parseUpcInputRow(values, columns, row.number);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
skippedMissingUpc += 1;
|
skippedMissingUpc += 1;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getDb } from "./database.ts";
|
import { getDb } from "./database.ts";
|
||||||
import type { AnalysisResult } from "./types.ts";
|
import type { AnalysisResult, SupplierAnalysisResult } from "./types.ts";
|
||||||
|
|
||||||
export type RunCounts = {
|
export type RunCounts = {
|
||||||
totalProducts: number;
|
totalProducts: number;
|
||||||
@@ -222,6 +222,83 @@ export function appendResultsToRun(
|
|||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function appendSupplierResultsToRun(
|
||||||
|
dbPath: string,
|
||||||
|
runId: number,
|
||||||
|
results: SupplierAnalysisResult[],
|
||||||
|
): void {
|
||||||
|
if (results.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const database = getDb(dbPath);
|
||||||
|
const insertResult = database.prepare(
|
||||||
|
`INSERT INTO results (
|
||||||
|
run_id, asin, product_name, brand, category, unit_cost, current_price,
|
||||||
|
avg_price_90d, sales_rank, rank_avg_90d, sellers,
|
||||||
|
amazon_is_seller, amazon_buybox_share_pct_90d, monthly_sold,
|
||||||
|
rank_drops_30d, rank_drops_90d, upc, fba_fee, fbm_fee,
|
||||||
|
referral_percent, supplier_score, supplier_profit, supplier_margin,
|
||||||
|
supplier_roi, supplier_reason, upc_lookup_status, upc_lookup_reason,
|
||||||
|
candidate_asins, can_sell, sellability_status, sellability_reason,
|
||||||
|
verdict, confidence, reasoning, fetched_at
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||||
|
)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
database.transaction(() => {
|
||||||
|
for (const result of results) {
|
||||||
|
const keepa = result.keepa;
|
||||||
|
const spApi = result.spApi;
|
||||||
|
const asin = result.lookup.asin ?? result.record.asin ?? result.upc;
|
||||||
|
const category =
|
||||||
|
result.record.category ?? keepa?.categoryTree?.join(" > ") ?? null;
|
||||||
|
const canSell =
|
||||||
|
spApi?.canSell == null ? null : spApi.canSell ? "yes" : "no";
|
||||||
|
|
||||||
|
insertResult.run(
|
||||||
|
runId,
|
||||||
|
asin,
|
||||||
|
result.record.name,
|
||||||
|
result.record.brand ?? null,
|
||||||
|
category,
|
||||||
|
result.record.unitCost || null,
|
||||||
|
result.score.salePrice,
|
||||||
|
keepa?.avgPrice90 ?? null,
|
||||||
|
keepa?.salesRank ?? null,
|
||||||
|
keepa?.salesRankAvg90 ?? null,
|
||||||
|
keepa?.sellerCount ?? null,
|
||||||
|
keepa?.amazonIsSeller == null ? null : keepa.amazonIsSeller ? 1 : 0,
|
||||||
|
keepa?.amazonBuyboxSharePct90d ?? null,
|
||||||
|
keepa?.monthlySold ?? null,
|
||||||
|
keepa?.salesRankDrops30 ?? null,
|
||||||
|
keepa?.salesRankDrops90 ?? null,
|
||||||
|
result.upc,
|
||||||
|
result.score.fbaFee,
|
||||||
|
spApi?.fbmFee ?? null,
|
||||||
|
spApi?.referralFeePercent ?? null,
|
||||||
|
result.score.score,
|
||||||
|
result.score.profit,
|
||||||
|
result.score.margin,
|
||||||
|
result.score.roi,
|
||||||
|
result.score.reason,
|
||||||
|
result.lookup.status,
|
||||||
|
result.lookup.reason ?? null,
|
||||||
|
result.lookup.candidateAsins.join(","),
|
||||||
|
canSell,
|
||||||
|
spApi?.sellabilityStatus ?? null,
|
||||||
|
spApi?.sellabilityReason ?? null,
|
||||||
|
result.score.verdict,
|
||||||
|
Math.round(result.score.score),
|
||||||
|
result.score.reason,
|
||||||
|
result.fetchedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
export function refreshRunCountsInDb(dbPath: string, runId: number): RunCounts {
|
export function refreshRunCountsInDb(dbPath: string, runId: number): RunCounts {
|
||||||
const database = getDb(dbPath);
|
const database = getDb(dbPath);
|
||||||
const stats = database
|
const stats = database
|
||||||
|
|||||||
Reference in New Issue
Block a user