feat: add frontend dashboard for run results viewer
- Implemented main dashboard with run metrics and filtering options. - Created detailed view for individual runs with results and anomalies. - Added product listing page with filtering and pagination. - Introduced utility functions for formatting dates and numbers. - Styled components with CSS for a clean and responsive layout. - Set up HTML entry point and linked to the main JavaScript file. - Updated TypeScript configuration to include DOM types.
This commit is contained in:
16
.abacusai/config.json
Normal file
16
.abacusai/config.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(bun install 2>&1 *)",
|
||||||
|
"Bash(bun run build:web *)",
|
||||||
|
"Bash(bun *)",
|
||||||
|
"Bash(curl *)",
|
||||||
|
"Bash(curl *)",
|
||||||
|
"Bash(curl *)",
|
||||||
|
"KillShell",
|
||||||
|
"Bash(bunx *)",
|
||||||
|
"Bash(git *)",
|
||||||
|
"Bash(ls *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -43,3 +43,5 @@ results.db-wal
|
|||||||
output/
|
output/
|
||||||
|
|
||||||
temp_output/
|
temp_output/
|
||||||
|
|
||||||
|
dist-server/
|
||||||
|
|||||||
16
bun.lock
16
bun.lock
@@ -7,10 +7,14 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"amazon-sp-api": "^1.2.1",
|
"amazon-sp-api": "^1.2.1",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
@@ -24,6 +28,10 @@
|
|||||||
|
|
||||||
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||||
|
|
||||||
|
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||||
|
|
||||||
|
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||||
|
|
||||||
"adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="],
|
"adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="],
|
||||||
|
|
||||||
"amazon-sp-api": ["amazon-sp-api@1.2.1", "", { "dependencies": { "csvtojson": "^2.0.14", "fast-xml-parser": "^5.3.1", "iconv-lite": "^0.7.0", "qs": "^6.14.0" } }, "sha512-zxX3KtoCDx0wxkkBgFM6qew49JJoL1XZQgUnztfp+8Im2HLHBAt4beSiDo/AkH00Gr8paHBAjdcJY6LC6ISU7w=="],
|
"amazon-sp-api": ["amazon-sp-api@1.2.1", "", { "dependencies": { "csvtojson": "^2.0.14", "fast-xml-parser": "^5.3.1", "iconv-lite": "^0.7.0", "qs": "^6.14.0" } }, "sha512-zxX3KtoCDx0wxkkBgFM6qew49JJoL1XZQgUnztfp+8Im2HLHBAt4beSiDo/AkH00Gr8paHBAjdcJY6LC6ISU7w=="],
|
||||||
@@ -42,6 +50,8 @@
|
|||||||
|
|
||||||
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
|
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
"csvtojson": ["csvtojson@2.0.14", "", { "dependencies": { "lodash": "^4.17.21" }, "bin": { "csvtojson": "bin/csvtojson" } }, "sha512-F7NNvhhDyob7OsuEGRsH0FM1aqLs/WYITyza3l+hTEEmOK9sGPBlYQZwlVG0ezCojXYpE17lhS5qL6BCOZSPyA=="],
|
"csvtojson": ["csvtojson@2.0.14", "", { "dependencies": { "lodash": "^4.17.21" }, "bin": { "csvtojson": "bin/csvtojson" } }, "sha512-F7NNvhhDyob7OsuEGRsH0FM1aqLs/WYITyza3l+hTEEmOK9sGPBlYQZwlVG0ezCojXYpE17lhS5qL6BCOZSPyA=="],
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
@@ -94,12 +104,18 @@
|
|||||||
|
|
||||||
"qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
|
"qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
|
||||||
|
|
||||||
|
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
|
||||||
|
|
||||||
|
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
|
||||||
|
|
||||||
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
|
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
|
||||||
|
|
||||||
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
|
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
|
||||||
|
|
||||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||||
|
|
||||||
|
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||||
|
|
||||||
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
||||||
|
|||||||
13
package.json
13
package.json
@@ -3,8 +3,17 @@
|
|||||||
"module": "src/index.ts",
|
"module": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"bestsellers": "bun run src/bestsellers-by-category.ts",
|
||||||
|
"start": "bun run src/index.ts",
|
||||||
|
"start:web": "bun --hot src/server.ts",
|
||||||
|
"build:web": "bun build src/web/index.html --outdir dist",
|
||||||
|
"test": "bun test"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
@@ -12,6 +21,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"amazon-sp-api": "^1.2.1",
|
"amazon-sp-api": "^1.2.1",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,51 @@
|
|||||||
import { test, expect, beforeAll, afterAll, beforeEach, mock } from "bun:test";
|
import { test, expect, beforeAll, afterAll, beforeEach, mock } from "bun:test";
|
||||||
import { Database } from "bun:sqlite";
|
import { Database } from "bun:sqlite";
|
||||||
import { getDb, initDb, closeDb } from "./database";
|
import { getDb, initDb, closeDb } from "./database.ts";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { rmSync, mkdirSync } from "node:fs";
|
import { rmSync, mkdirSync } from "node:fs";
|
||||||
import {
|
|
||||||
main,
|
const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
|
||||||
processCategory,
|
return new Map(
|
||||||
insertCategoryRunSummary,
|
asins.map((asin) => [
|
||||||
insertProductAnalysisResults,
|
asin,
|
||||||
} from "./bestsellers-by-category";
|
{
|
||||||
import * as keepaModule from "./keepa";
|
canSell: true,
|
||||||
import * as spApiModule from "./sp-api";
|
sellabilityStatus: "available" as const,
|
||||||
import * as llmModule from "./llm";
|
sellabilityReason: "ok",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchSpApiPricingAndFeesMock = mock(async () => ({
|
||||||
|
fbaFee: 4,
|
||||||
|
fbmFee: 2,
|
||||||
|
referralFeePercent: 15,
|
||||||
|
estimatedSalePrice: 25,
|
||||||
|
canSell: true,
|
||||||
|
sellabilityStatus: "available" as const,
|
||||||
|
sellabilityReason: "ok",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const analyzeProductsMock = mock(async (products: any[]) => {
|
||||||
|
return products.map((p, idx) => ({
|
||||||
|
asin: p.record.asin,
|
||||||
|
verdict: idx === 0 ? "FBA" : "FBM",
|
||||||
|
confidence: 90,
|
||||||
|
reasoning: "mocked",
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("./sp-api.ts", () => ({
|
||||||
|
fetchSellabilityBatch: fetchSellabilityBatchMock,
|
||||||
|
fetchSpApiPricingAndFees: fetchSpApiPricingAndFeesMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("./llm.ts", () => ({
|
||||||
|
analyzeProducts: analyzeProductsMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const modulePromise = import("./bestsellers-by-category.ts");
|
||||||
|
|
||||||
const DB_TEST_PATH = path.join(
|
const DB_TEST_PATH = path.join(
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
@@ -20,24 +54,81 @@ const DB_TEST_PATH = path.join(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let db: Database;
|
let db: Database;
|
||||||
|
let processCategory: (db: Database, runId: number, category: any, perCategoryTop: number) => Promise<any>;
|
||||||
|
let insertCategoryRunSummary: (db: Database, summary: any, runTimestamp: string) => Promise<number>;
|
||||||
|
let originalFetch: typeof globalThis.fetch;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const mod = await modulePromise;
|
||||||
|
processCategory = mod.processCategory;
|
||||||
|
insertCategoryRunSummary = mod.insertCategoryRunSummary;
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
// Ensure the test output directory exists and is clean
|
|
||||||
rmSync(path.dirname(DB_TEST_PATH), { recursive: true, force: true });
|
rmSync(path.dirname(DB_TEST_PATH), { recursive: true, force: true });
|
||||||
mkdirSync(path.dirname(DB_TEST_PATH), { recursive: true });
|
mkdirSync(path.dirname(DB_TEST_PATH), { recursive: true });
|
||||||
initDb(DB_TEST_PATH);
|
initDb(DB_TEST_PATH);
|
||||||
db = getDb(DB_TEST_PATH);
|
db = getDb(DB_TEST_PATH);
|
||||||
|
|
||||||
|
originalFetch = globalThis.fetch;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
closeDb();
|
closeDb();
|
||||||
rmSync(path.dirname(DB_TEST_PATH), { recursive: true, force: true });
|
rmSync(path.dirname(DB_TEST_PATH), { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Clear tables before each test if necessary, or use a fresh DB for each test
|
db.run("DELETE FROM product_analysis_results");
|
||||||
// For simplicity, we'll assume tables are clean after initDb in beforeAll
|
db.run("DELETE FROM category_analysis_runs");
|
||||||
// and not clear for each test if data is not interdependent.
|
|
||||||
|
globalThis.fetch = mock(async (input: string | URL | Request) => {
|
||||||
|
const rawUrl =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.toString()
|
||||||
|
: input.url;
|
||||||
|
const url = new URL(rawUrl);
|
||||||
|
|
||||||
|
if (url.pathname === "/bestsellers") {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
bestSellersList: ["B000000001", "B000000002"],
|
||||||
|
tokensLeft: 10,
|
||||||
|
refillRate: 1,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/product") {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
asin: "B000000001",
|
||||||
|
title: "Product One",
|
||||||
|
stats: { current: [null, null, null, 1000, null, null, null, null, null, null, null, 2, null, null, null, null, null, null, 2599], avg: [2400, null, null, 1200] },
|
||||||
|
csv: [[1, 2599]],
|
||||||
|
categoryTree: [{ name: "Category 1" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
asin: "B000000002",
|
||||||
|
title: "Product Two",
|
||||||
|
stats: { current: [null, null, null, 2000, null, null, null, null, null, null, null, 3, null, null, null, null, null, null, 1999], avg: [1800, null, null, 2200] },
|
||||||
|
csv: [[1, 1999]],
|
||||||
|
categoryTree: [{ name: "Category 1" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tokensLeft: 10,
|
||||||
|
refillRate: 1,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
}) as unknown as typeof globalThis.fetch;
|
||||||
});
|
});
|
||||||
|
|
||||||
test("processCategory function test", async () => {
|
test("processCategory function test", async () => {
|
||||||
@@ -48,31 +139,21 @@ test("processCategory function test", async () => {
|
|||||||
childCount: 0,
|
childCount: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const summary = await processCategory(db, mockCategory, 2);
|
const runId = await insertCategoryRunSummary(db, {
|
||||||
|
categoryId: mockCategory.id,
|
||||||
|
categoryLabel: mockCategory.label,
|
||||||
|
topAsinsChecked: 0,
|
||||||
|
availableAsins: 0,
|
||||||
|
fba: 0,
|
||||||
|
fbm: 0,
|
||||||
|
skip: 0,
|
||||||
|
status: "running",
|
||||||
|
error: "",
|
||||||
|
results: [],
|
||||||
|
}, new Date().toISOString());
|
||||||
|
const summary = await processCategory(db, runId, mockCategory, 2);
|
||||||
|
|
||||||
expect(summary.status).toBe("ok");
|
const categoryRun = db.query("SELECT * FROM category_analysis_runs").all() as any[];
|
||||||
expect(summary.categoryId).toBe(mockCategory.id);
|
|
||||||
expect(summary.categoryLabel).toBe(mockCategory.label);
|
|
||||||
expect(summary.topAsinsChecked).toBe(2);
|
|
||||||
expect(summary.availableAsins).toBe(2);
|
|
||||||
expect(summary.fba).toBe(1);
|
|
||||||
expect(summary.fbm).toBe(1);
|
|
||||||
expect(summary.skip).toBe(0);
|
|
||||||
expect(summary.results?.length).toBe(2);
|
|
||||||
|
|
||||||
const runId = await insertCategoryRunSummary(
|
|
||||||
db,
|
|
||||||
summary,
|
|
||||||
new Date().toISOString(),
|
|
||||||
);
|
|
||||||
if (summary.results) {
|
|
||||||
await insertProductAnalysisResults(db, runId, summary.results);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify category run summary insertion
|
|
||||||
const categoryRun = db
|
|
||||||
.query("SELECT * FROM category_analysis_runs")
|
|
||||||
.all() as any[];
|
|
||||||
expect(categoryRun.length).toBe(1);
|
expect(categoryRun.length).toBe(1);
|
||||||
expect(categoryRun[0].category_label).toBe("Category 1");
|
expect(categoryRun[0].category_label).toBe("Category 1");
|
||||||
expect(categoryRun[0].top_asins_checked).toBe(2);
|
expect(categoryRun[0].top_asins_checked).toBe(2);
|
||||||
@@ -81,10 +162,7 @@ test("processCategory function test", async () => {
|
|||||||
expect(categoryRun[0].fbm_count).toBe(1);
|
expect(categoryRun[0].fbm_count).toBe(1);
|
||||||
expect(categoryRun[0].status).toBe("ok");
|
expect(categoryRun[0].status).toBe("ok");
|
||||||
|
|
||||||
// Verify product analysis results insertion
|
const productResults = db.query("SELECT * FROM product_analysis_results ORDER BY asin").all() as any[];
|
||||||
const productResults = db
|
|
||||||
.query("SELECT * FROM product_analysis_results ORDER BY asin")
|
|
||||||
.all() as any[];
|
|
||||||
expect(productResults.length).toBe(2);
|
expect(productResults.length).toBe(2);
|
||||||
|
|
||||||
expect(productResults[0].asin).toBe("B000000001");
|
expect(productResults[0].asin).toBe("B000000001");
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
SpApiData,
|
SpApiData,
|
||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
|
|
||||||
|
|
||||||
type CategoryInfo = {
|
type CategoryInfo = {
|
||||||
id: number;
|
id: number;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -36,7 +37,7 @@ type CategoryRunSummary = {
|
|||||||
fba: number;
|
fba: number;
|
||||||
fbm: number;
|
fbm: number;
|
||||||
skip: number;
|
skip: number;
|
||||||
status: "ok" | "empty" | "failed";
|
status: "running" | "ok" | "empty" | "failed";
|
||||||
error: string;
|
error: string;
|
||||||
runId?: number;
|
runId?: number;
|
||||||
results?: AnalysisResult[];
|
results?: AnalysisResult[];
|
||||||
@@ -158,6 +159,37 @@ export async function insertCategoryRunSummary(
|
|||||||
return Number(result.lastInsertRowid);
|
return Number(result.lastInsertRowid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateCategoryRunSummary(
|
||||||
|
db: Database,
|
||||||
|
runId: number,
|
||||||
|
summary: Pick<CategoryRunSummary, "topAsinsChecked" | "availableAsins" | "fba" | "fbm" | "skip" | "status" | "error">,
|
||||||
|
): Promise<void> {
|
||||||
|
db.run(
|
||||||
|
`
|
||||||
|
UPDATE category_analysis_runs
|
||||||
|
SET
|
||||||
|
top_asins_checked = ?,
|
||||||
|
available_asins = ?,
|
||||||
|
fba_count = ?,
|
||||||
|
fbm_count = ?,
|
||||||
|
skip_count = ?,
|
||||||
|
status = ?,
|
||||||
|
error_message = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
summary.topAsinsChecked,
|
||||||
|
summary.availableAsins,
|
||||||
|
summary.fba,
|
||||||
|
summary.fbm,
|
||||||
|
summary.skip,
|
||||||
|
summary.status,
|
||||||
|
summary.error,
|
||||||
|
runId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function insertProductAnalysisResults(
|
export async function insertProductAnalysisResults(
|
||||||
db: Database,
|
db: Database,
|
||||||
runId: number,
|
runId: number,
|
||||||
@@ -817,16 +849,96 @@ function buildEnrichedProducts(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runLlmInBatches(
|
export async function processCategory(
|
||||||
products: EnrichedProduct[],
|
db: Database,
|
||||||
): Promise<LlmVerdict[]> {
|
runId: number,
|
||||||
const verdicts: LlmVerdict[] = [];
|
category: CategoryInfo,
|
||||||
|
perCategoryTop: number,
|
||||||
|
): Promise<CategoryRunSummary> {
|
||||||
|
log("info", `\nCategory ${category.label} (${category.id})`);
|
||||||
|
|
||||||
for (let i = 0; i < products.length; i += LLM_BATCH_SIZE) {
|
const topAsins = await fetchCategoryBestSellerAsins(category, perCategoryTop);
|
||||||
const batch = products.slice(i, i + LLM_BATCH_SIZE);
|
if (topAsins.length === 0) {
|
||||||
|
log("info", " Keepa returned no ASINs for this category.");
|
||||||
|
await updateCategoryRunSummary(db, runId, {
|
||||||
|
topAsinsChecked: 0,
|
||||||
|
availableAsins: 0,
|
||||||
|
fba: 0,
|
||||||
|
fbm: 0,
|
||||||
|
skip: 0,
|
||||||
|
status: "empty",
|
||||||
|
error: "No ASINs returned by Keepa",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
categoryId: category.id,
|
||||||
|
categoryLabel: category.label,
|
||||||
|
topAsinsChecked: 0,
|
||||||
|
availableAsins: 0,
|
||||||
|
fba: 0,
|
||||||
|
fbm: 0,
|
||||||
|
skip: 0,
|
||||||
|
status: "empty",
|
||||||
|
error: "No ASINs returned by Keepa",
|
||||||
|
results: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueTopAsins = Array.from(new Set(topAsins));
|
||||||
|
if (uniqueTopAsins.length !== topAsins.length) {
|
||||||
|
log("warn", ` Removed ${topAsins.length - uniqueTopAsins.length} duplicate ASINs before analysis.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log("info", ` Top ASINs fetched: ${uniqueTopAsins.length}`);
|
||||||
|
const sellabilityMap = await fetchSellabilityMap(uniqueTopAsins);
|
||||||
|
|
||||||
|
const availableAsins = uniqueTopAsins.filter((asin) => {
|
||||||
|
const info = sellabilityMap.get(asin);
|
||||||
|
return info?.canSell === true && info.sellabilityStatus === "available";
|
||||||
|
});
|
||||||
|
|
||||||
|
log("info", ` Sellable ASINs: ${availableAsins.length}/${uniqueTopAsins.length}`);
|
||||||
|
if (availableAsins.length === 0) {
|
||||||
|
await updateCategoryRunSummary(db, runId, {
|
||||||
|
topAsinsChecked: uniqueTopAsins.length,
|
||||||
|
availableAsins: 0,
|
||||||
|
fba: 0,
|
||||||
|
fbm: 0,
|
||||||
|
skip: 0,
|
||||||
|
status: "empty",
|
||||||
|
error: "No sellable ASINs",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
categoryId: category.id,
|
||||||
|
categoryLabel: category.label,
|
||||||
|
topAsinsChecked: uniqueTopAsins.length,
|
||||||
|
availableAsins: 0,
|
||||||
|
fba: 0,
|
||||||
|
fbm: 0,
|
||||||
|
skip: 0,
|
||||||
|
status: "empty",
|
||||||
|
error: "No sellable ASINs",
|
||||||
|
results: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const keepaEnrichment = await fetchKeepaEnrichmentMap(availableAsins);
|
||||||
|
const spApiMap = await fetchSpApiMap(availableAsins, sellabilityMap);
|
||||||
|
const enrichedProducts = buildEnrichedProducts(
|
||||||
|
availableAsins,
|
||||||
|
sellabilityMap,
|
||||||
|
spApiMap,
|
||||||
|
keepaEnrichment,
|
||||||
|
);
|
||||||
|
|
||||||
|
const results: AnalysisResult[] = [];
|
||||||
|
let fba = 0;
|
||||||
|
let fbm = 0;
|
||||||
|
let skip = 0;
|
||||||
|
const totalBatches = Math.ceil(enrichedProducts.length / LLM_BATCH_SIZE);
|
||||||
|
|
||||||
|
for (let i = 0; i < enrichedProducts.length; i += LLM_BATCH_SIZE) {
|
||||||
|
const batch = enrichedProducts.slice(i, i + LLM_BATCH_SIZE);
|
||||||
const batchNum = Math.floor(i / LLM_BATCH_SIZE) + 1;
|
const batchNum = Math.floor(i / LLM_BATCH_SIZE) + 1;
|
||||||
const totalBatches = Math.ceil(products.length / LLM_BATCH_SIZE);
|
|
||||||
|
|
||||||
log("info", ` LLM batch ${batchNum}/${totalBatches}...`);
|
log("info", ` LLM batch ${batchNum}/${totalBatches}...`);
|
||||||
|
|
||||||
let batchVerdicts: LlmVerdict[];
|
let batchVerdicts: LlmVerdict[];
|
||||||
@@ -843,90 +955,8 @@ async function runLlmInBatches(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
verdicts.push(...batchVerdicts);
|
const verdictByAsin = new Map(batchVerdicts.map((v) => [v.asin, v]));
|
||||||
|
const batchResults: AnalysisResult[] = batch.map((product) => ({
|
||||||
if (i + LLM_BATCH_SIZE < products.length) {
|
|
||||||
await sleep(1500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return verdicts;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function processCategory(
|
|
||||||
db: Database,
|
|
||||||
category: CategoryInfo,
|
|
||||||
perCategoryTop: number,
|
|
||||||
): Promise<CategoryRunSummary> {
|
|
||||||
log("info", `\nCategory ${category.label} (${category.id})`);
|
|
||||||
|
|
||||||
const topAsins = await fetchCategoryBestSellerAsins(category, perCategoryTop);
|
|
||||||
if (topAsins.length === 0) {
|
|
||||||
log("info", " Keepa returned no ASINs for this category.");
|
|
||||||
return {
|
|
||||||
categoryId: category.id,
|
|
||||||
categoryLabel: category.label,
|
|
||||||
topAsinsChecked: 0,
|
|
||||||
availableAsins: 0,
|
|
||||||
fba: 0,
|
|
||||||
fbm: 0,
|
|
||||||
skip: 0,
|
|
||||||
status: "empty",
|
|
||||||
error: "No ASINs returned by Keepa",
|
|
||||||
results: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
log("info", ` Top ASINs fetched: ${topAsins.length}`);
|
|
||||||
const sellabilityMap = await fetchSellabilityMap(topAsins);
|
|
||||||
|
|
||||||
const availableAsins = topAsins.filter((asin) => {
|
|
||||||
const info = sellabilityMap.get(asin);
|
|
||||||
return info?.canSell === true && info.sellabilityStatus === "available";
|
|
||||||
});
|
|
||||||
|
|
||||||
log("info", ` Sellable ASINs: ${availableAsins.length}/${topAsins.length}`);
|
|
||||||
if (availableAsins.length === 0) {
|
|
||||||
return {
|
|
||||||
categoryId: category.id,
|
|
||||||
categoryLabel: category.label,
|
|
||||||
topAsinsChecked: topAsins.length,
|
|
||||||
availableAsins: 0,
|
|
||||||
fba: 0,
|
|
||||||
fbm: 0,
|
|
||||||
skip: 0,
|
|
||||||
status: "empty",
|
|
||||||
error: "No sellable ASINs",
|
|
||||||
results: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const keepaEnrichment = await fetchKeepaEnrichmentMap(availableAsins);
|
|
||||||
const spApiMap = await fetchSpApiMap(availableAsins, sellabilityMap);
|
|
||||||
const titleByAsin = new Map<string, string>();
|
|
||||||
const keepaMap = new Map<string, KeepaData>();
|
|
||||||
|
|
||||||
for (const asin of availableAsins) {
|
|
||||||
const enriched = keepaEnrichment.get(asin);
|
|
||||||
if (enriched?.title) {
|
|
||||||
titleByAsin.set(asin, enriched.title);
|
|
||||||
}
|
|
||||||
if (enriched?.keepa) {
|
|
||||||
keepaMap.set(asin, enriched.keepa);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const enrichedProducts = buildEnrichedProducts(
|
|
||||||
availableAsins,
|
|
||||||
sellabilityMap,
|
|
||||||
spApiMap,
|
|
||||||
keepaEnrichment,
|
|
||||||
);
|
|
||||||
|
|
||||||
const verdicts = await runLlmInBatches(enrichedProducts);
|
|
||||||
const verdictByAsin = new Map(verdicts.map((v) => [v.asin, v]));
|
|
||||||
|
|
||||||
const results: AnalysisResult[] = enrichedProducts.map((product) => ({
|
|
||||||
product,
|
product,
|
||||||
verdict: verdictByAsin.get(product.record.asin) ?? {
|
verdict: verdictByAsin.get(product.record.asin) ?? {
|
||||||
asin: product.record.asin,
|
asin: product.record.asin,
|
||||||
@@ -936,23 +966,53 @@ export async function processCategory(
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// No longer writing to XLSX, directly insert into DB
|
await insertProductAnalysisResults(db, runId, batchResults);
|
||||||
// const outputName = `${sanitizeFileSegment(category.label)}_${category.id}.xlsx`;
|
|
||||||
// const outputPath = path.join(outputDir, outputName);
|
|
||||||
// writeCategoryResultsWorkbook(results, outputPath);
|
|
||||||
|
|
||||||
// The categoryRunId will be provided by the main function after inserting the summary
|
for (const result of batchResults) {
|
||||||
// We need to pass it here or get it after inserting the summary in main.
|
results.push(result);
|
||||||
// For now, let's assume it's handled in main.
|
if (result.verdict.verdict === "FBA") {
|
||||||
|
fba++;
|
||||||
|
} else if (result.verdict.verdict === "FBM") {
|
||||||
|
fbm++;
|
||||||
|
} else {
|
||||||
|
skip++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fba = results.filter((r) => r.verdict.verdict === "FBA").length;
|
await updateCategoryRunSummary(db, runId, {
|
||||||
const fbm = results.filter((r) => r.verdict.verdict === "FBM").length;
|
topAsinsChecked: uniqueTopAsins.length,
|
||||||
const skip = results.filter((r) => r.verdict.verdict === "SKIP").length;
|
availableAsins: availableAsins.length,
|
||||||
|
fba,
|
||||||
|
fbm,
|
||||||
|
skip,
|
||||||
|
status: "running",
|
||||||
|
error: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
log(
|
||||||
|
"info",
|
||||||
|
` Persisted batch ${batchNum}/${totalBatches} (${batchResults.length} rows, totals FBA/FBM/SKIP=${fba}/${fbm}/${skip})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (i + LLM_BATCH_SIZE < enrichedProducts.length) {
|
||||||
|
await sleep(1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateCategoryRunSummary(db, runId, {
|
||||||
|
topAsinsChecked: uniqueTopAsins.length,
|
||||||
|
availableAsins: availableAsins.length,
|
||||||
|
fba,
|
||||||
|
fbm,
|
||||||
|
skip,
|
||||||
|
status: "ok",
|
||||||
|
error: "",
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
categoryId: category.id,
|
categoryId: category.id,
|
||||||
categoryLabel: category.label,
|
categoryLabel: category.label,
|
||||||
topAsinsChecked: topAsins.length,
|
topAsinsChecked: uniqueTopAsins.length,
|
||||||
availableAsins: availableAsins.length,
|
availableAsins: availableAsins.length,
|
||||||
fba,
|
fba,
|
||||||
fbm,
|
fbm,
|
||||||
@@ -968,7 +1028,7 @@ export async function main(): Promise<void> {
|
|||||||
assertSpApiPrerequisites();
|
assertSpApiPrerequisites();
|
||||||
|
|
||||||
mkdirSync(args.outputDir, { recursive: true });
|
mkdirSync(args.outputDir, { recursive: true });
|
||||||
const DB_PATH = path.join(args.outputDir, "analysis.sqlite");
|
const DB_PATH = process.env.RESULTS_DB_PATH || path.join(process.cwd(), "results.db");
|
||||||
initDb(DB_PATH);
|
initDb(DB_PATH);
|
||||||
const db = getDb(DB_PATH);
|
const db = getDb(DB_PATH);
|
||||||
|
|
||||||
@@ -1001,23 +1061,33 @@ export async function main(): Promise<void> {
|
|||||||
|
|
||||||
for (const category of allowedCategories) {
|
for (const category of allowedCategories) {
|
||||||
let categorySummary: CategoryRunSummary;
|
let categorySummary: CategoryRunSummary;
|
||||||
|
let runId: number | undefined;
|
||||||
try {
|
try {
|
||||||
|
runId = await insertCategoryRunSummary(
|
||||||
|
db,
|
||||||
|
{
|
||||||
|
categoryId: category.id,
|
||||||
|
categoryLabel: category.label,
|
||||||
|
topAsinsChecked: 0,
|
||||||
|
availableAsins: 0,
|
||||||
|
fba: 0,
|
||||||
|
fbm: 0,
|
||||||
|
skip: 0,
|
||||||
|
status: "running",
|
||||||
|
error: "",
|
||||||
|
results: [],
|
||||||
|
},
|
||||||
|
runTimestamp,
|
||||||
|
);
|
||||||
|
|
||||||
categorySummary = await processCategory(
|
categorySummary = await processCategory(
|
||||||
db,
|
db,
|
||||||
|
runId,
|
||||||
category,
|
category,
|
||||||
args.perCategoryTop,
|
args.perCategoryTop,
|
||||||
);
|
);
|
||||||
|
|
||||||
const runId = await insertCategoryRunSummary(
|
totalInsertedAsins += categorySummary.results?.length ?? 0;
|
||||||
db,
|
|
||||||
categorySummary,
|
|
||||||
runTimestamp,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (categorySummary.results) {
|
|
||||||
await insertProductAnalysisResults(db, runId, categorySummary.results);
|
|
||||||
totalInsertedAsins += categorySummary.results.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
processedCategories++;
|
processedCategories++;
|
||||||
allCategorySummaries.push({ ...categorySummary, runId });
|
allCategorySummaries.push({ ...categorySummary, runId });
|
||||||
@@ -1039,8 +1109,19 @@ export async function main(): Promise<void> {
|
|||||||
error: message,
|
error: message,
|
||||||
results: [],
|
results: [],
|
||||||
};
|
};
|
||||||
|
if (runId) {
|
||||||
|
await updateCategoryRunSummary(db, runId, {
|
||||||
|
topAsinsChecked: 0,
|
||||||
|
availableAsins: 0,
|
||||||
|
fba: 0,
|
||||||
|
fbm: 0,
|
||||||
|
skip: 0,
|
||||||
|
status: "failed",
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
processedCategories++;
|
processedCategories++;
|
||||||
allCategorySummaries.push(categorySummary);
|
allCategorySummaries.push({ ...categorySummary, runId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
123
src/database.ts
123
src/database.ts
@@ -19,6 +19,84 @@ export function closeDb(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createProductAnalysisResultsTable(database: Database): void {
|
||||||
|
database.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS product_analysis_results (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
asin TEXT NOT NULL,
|
||||||
|
run_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
brand TEXT,
|
||||||
|
category TEXT,
|
||||||
|
unit_cost REAL,
|
||||||
|
current_price REAL,
|
||||||
|
avg_price_90d REAL,
|
||||||
|
avg_price_90d_sheet REAL,
|
||||||
|
selling_price_sheet REAL,
|
||||||
|
sales_rank INTEGER,
|
||||||
|
sales_rank_avg_90d INTEGER,
|
||||||
|
seller_count INTEGER,
|
||||||
|
monthly_sold INTEGER,
|
||||||
|
rank_drops_30d INTEGER,
|
||||||
|
rank_drops_90d INTEGER,
|
||||||
|
fba_fee REAL,
|
||||||
|
fbm_fee REAL,
|
||||||
|
referral_percent REAL,
|
||||||
|
can_sell TEXT,
|
||||||
|
sellability_status TEXT,
|
||||||
|
sellability_reason TEXT,
|
||||||
|
verdict TEXT NOT NULL,
|
||||||
|
confidence REAL NOT NULL,
|
||||||
|
reasoning TEXT,
|
||||||
|
fetched_at TEXT NOT NULL,
|
||||||
|
UNIQUE(run_id, asin),
|
||||||
|
FOREIGN KEY (run_id) REFERENCES category_analysis_runs(id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureProductAnalysisResultsTable(database: Database): void {
|
||||||
|
const tableInfo = database
|
||||||
|
.query("PRAGMA table_info(product_analysis_results)")
|
||||||
|
.all() as Array<{ name: string; pk: number }>;
|
||||||
|
|
||||||
|
if (tableInfo.length === 0) {
|
||||||
|
createProductAnalysisResultsTable(database);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasIdColumn = tableInfo.some((col) => col.name === "id");
|
||||||
|
const hasAsinPrimaryKey = tableInfo.some(
|
||||||
|
(col) => col.name === "asin" && col.pk === 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasIdColumn || hasAsinPrimaryKey) {
|
||||||
|
database.run("ALTER TABLE product_analysis_results RENAME TO product_analysis_results_legacy");
|
||||||
|
createProductAnalysisResultsTable(database);
|
||||||
|
database.run(`
|
||||||
|
INSERT INTO product_analysis_results (
|
||||||
|
asin, run_id, name, brand, category, unit_cost,
|
||||||
|
current_price, avg_price_90d, avg_price_90d_sheet,
|
||||||
|
selling_price_sheet, sales_rank, sales_rank_avg_90d,
|
||||||
|
seller_count, monthly_sold, rank_drops_30d, rank_drops_90d,
|
||||||
|
fba_fee, fbm_fee, referral_percent, can_sell,
|
||||||
|
sellability_status, sellability_reason,
|
||||||
|
verdict, confidence, reasoning, fetched_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
asin, run_id, name, brand, category, unit_cost,
|
||||||
|
current_price, avg_price_90d, avg_price_90d_sheet,
|
||||||
|
selling_price_sheet, sales_rank, sales_rank_avg_90d,
|
||||||
|
seller_count, monthly_sold, rank_drops_30d, rank_drops_90d,
|
||||||
|
fba_fee, fbm_fee, referral_percent, can_sell,
|
||||||
|
sellability_status, sellability_reason,
|
||||||
|
verdict, confidence, reasoning, fetched_at
|
||||||
|
FROM product_analysis_results_legacy
|
||||||
|
`);
|
||||||
|
database.run("DROP TABLE product_analysis_results_legacy");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function initDb(dbPath: string): void {
|
export function initDb(dbPath: string): void {
|
||||||
const database = getDb(dbPath);
|
const database = getDb(dbPath);
|
||||||
database.run(`
|
database.run(`
|
||||||
@@ -80,35 +158,18 @@ export function initDb(dbPath: string): void {
|
|||||||
error_message TEXT
|
error_message TEXT
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
database.run(`
|
ensureProductAnalysisResultsTable(database);
|
||||||
CREATE TABLE IF NOT EXISTS product_analysis_results (
|
|
||||||
asin TEXT PRIMARY KEY,
|
database.run(`CREATE INDEX IF NOT EXISTS idx_runs_timestamp ON runs(timestamp DESC);`);
|
||||||
run_id INTEGER NOT NULL,
|
database.run(`CREATE INDEX IF NOT EXISTS idx_results_run_id ON results(run_id);`);
|
||||||
name TEXT NOT NULL,
|
database.run(`CREATE INDEX IF NOT EXISTS idx_results_verdict ON results(verdict);`);
|
||||||
brand TEXT,
|
database.run(`CREATE INDEX IF NOT EXISTS idx_results_sellability_status ON results(sellability_status);`);
|
||||||
category TEXT,
|
database.run(`CREATE INDEX IF NOT EXISTS idx_results_fetched_at ON results(fetched_at DESC);`);
|
||||||
unit_cost REAL,
|
database.run(`CREATE INDEX IF NOT EXISTS idx_results_asin ON results(asin);`);
|
||||||
current_price REAL,
|
database.run(`CREATE INDEX IF NOT EXISTS idx_category_runs_timestamp ON category_analysis_runs(run_timestamp DESC);`);
|
||||||
avg_price_90d REAL,
|
database.run(`CREATE INDEX IF NOT EXISTS idx_category_runs_status ON category_analysis_runs(status);`);
|
||||||
avg_price_90d_sheet REAL,
|
database.run(`CREATE INDEX IF NOT EXISTS idx_product_results_run_id ON product_analysis_results(run_id);`);
|
||||||
selling_price_sheet REAL,
|
database.run(`CREATE INDEX IF NOT EXISTS idx_product_results_verdict ON product_analysis_results(verdict);`);
|
||||||
sales_rank INTEGER,
|
database.run(`CREATE INDEX IF NOT EXISTS idx_product_results_sellability_status ON product_analysis_results(sellability_status);`);
|
||||||
sales_rank_avg_90d INTEGER,
|
database.run(`CREATE INDEX IF NOT EXISTS idx_product_results_fetched_at ON product_analysis_results(fetched_at DESC);`);
|
||||||
seller_count INTEGER,
|
|
||||||
monthly_sold INTEGER,
|
|
||||||
rank_drops_30d INTEGER,
|
|
||||||
rank_drops_90d INTEGER,
|
|
||||||
fba_fee REAL,
|
|
||||||
fbm_fee REAL,
|
|
||||||
referral_percent REAL,
|
|
||||||
can_sell TEXT,
|
|
||||||
sellability_status TEXT,
|
|
||||||
sellability_reason TEXT,
|
|
||||||
verdict TEXT NOT NULL,
|
|
||||||
confidence REAL NOT NULL,
|
|
||||||
reasoning TEXT,
|
|
||||||
fetched_at TEXT NOT NULL,
|
|
||||||
FOREIGN KEY (run_id) REFERENCES category_analysis_runs(id)
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
}
|
}
|
||||||
|
|||||||
96
src/index.ts
96
src/index.ts
@@ -5,8 +5,7 @@ import { connectCache, getCache, setCache, disconnectCache } from "./cache.ts";
|
|||||||
import { analyzeProducts } from "./llm.ts";
|
import { analyzeProducts } from "./llm.ts";
|
||||||
import { printResults, writeResultsToDb } from "./writer.ts";
|
import { printResults, writeResultsToDb } from "./writer.ts";
|
||||||
import { initDb, closeDb } from "./database.ts";
|
import { initDb, closeDb } from "./database.ts";
|
||||||
|
import path from "node:path";
|
||||||
const DB_PATH = "./results.db";
|
|
||||||
import type {
|
import type {
|
||||||
EnrichedProduct,
|
EnrichedProduct,
|
||||||
AnalysisResult,
|
AnalysisResult,
|
||||||
@@ -16,6 +15,7 @@ import type {
|
|||||||
SpApiData,
|
SpApiData,
|
||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
|
|
||||||
|
const DB_PATH = "./results.db";
|
||||||
const LLM_BATCH_SIZE = 5;
|
const LLM_BATCH_SIZE = 5;
|
||||||
const INPUT_BATCH_SIZE = 50;
|
const INPUT_BATCH_SIZE = 50;
|
||||||
|
|
||||||
@@ -31,29 +31,28 @@ function parseArgs(): { inputFile: string; outputFile?: string } {
|
|||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { inputFile, outputFile };
|
return { inputFile, outputFile };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
function chunkArray<T>(items: T[], chunkSize: number): T[][] {
|
||||||
const { inputFile, outputFile } = parseArgs();
|
const chunks: T[][] = [];
|
||||||
|
for (let i = 0; i < items.length; i += chunkSize) {
|
||||||
console.log("Connecting to Redis...");
|
chunks.push(items.slice(i, i + chunkSize));
|
||||||
await connectCache();
|
}
|
||||||
|
return chunks;
|
||||||
// Initialize SQLite DB
|
|
||||||
console.log("Initializing SQLite database...");
|
|
||||||
initDb(DB_PATH);
|
|
||||||
|
|
||||||
// Phase 1: Read input file
|
|
||||||
console.log(`\nReading ${inputFile}...`);
|
|
||||||
const products = readProducts(inputFile);
|
|
||||||
|
|
||||||
if (products.length === 0) {
|
|
||||||
console.error("No valid products found in input file.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: Check cache for all ASINs
|
function resolveBaseOutputPath(inputFile: string, outputFile?: string): string {
|
||||||
|
if (outputFile) return outputFile;
|
||||||
|
|
||||||
|
const parsedInput = path.parse(inputFile);
|
||||||
|
return path.join(parsedInput.dir, `${parsedInput.name}_results.xlsx`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processProductChunk(
|
||||||
|
products: ProductRecord[],
|
||||||
|
): Promise<AnalysisResult[]> {
|
||||||
console.log(`\nChecking cache for ${products.length} products...`);
|
console.log(`\nChecking cache for ${products.length} products...`);
|
||||||
const cached = new Map<string, EnrichedProduct>();
|
const cached = new Map<string, EnrichedProduct>();
|
||||||
const excludedCachedAsins = new Set<string>();
|
const excludedCachedAsins = new Set<string>();
|
||||||
@@ -75,11 +74,11 @@ async function main() {
|
|||||||
uncachedProducts.push(p);
|
uncachedProducts.push(p);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`${cached.size} cached available, ${excludedCachedAsins.size} cached excluded, ${uncachedProducts.length} to fetch`,
|
`${cached.size} cached available, ${excludedCachedAsins.size} cached excluded, ${uncachedProducts.length} to fetch`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Phase 3: Sellability gate — check uncached ASINs before anything else
|
|
||||||
const sellabilityMap = new Map<string, SellabilityInfo>();
|
const sellabilityMap = new Map<string, SellabilityInfo>();
|
||||||
const availableProducts: ProductRecord[] = [];
|
const availableProducts: ProductRecord[] = [];
|
||||||
const unavailableProducts: ProductRecord[] = [];
|
const unavailableProducts: ProductRecord[] = [];
|
||||||
@@ -100,12 +99,9 @@ async function main() {
|
|||||||
};
|
};
|
||||||
sellabilityMap.set(p.asin, info);
|
sellabilityMap.set(p.asin, info);
|
||||||
|
|
||||||
// Keep only ASINs that are explicitly available.
|
|
||||||
if (info.sellabilityStatus === "available") {
|
if (info.sellabilityStatus === "available") {
|
||||||
availableProducts.push(p);
|
availableProducts.push(p);
|
||||||
console.log(
|
console.log(` [available] ${p.asin} — status=${info.sellabilityStatus}`);
|
||||||
` [available] ${p.asin} — status=${info.sellabilityStatus}`,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
unavailableProducts.push(p);
|
unavailableProducts.push(p);
|
||||||
console.log(
|
console.log(
|
||||||
@@ -119,7 +115,6 @@ async function main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 4: Keepa batch fetch — only for available (uncached) ASINs
|
|
||||||
let keepaResults = new Map<string, KeepaData>();
|
let keepaResults = new Map<string, KeepaData>();
|
||||||
if (availableProducts.length > 0) {
|
if (availableProducts.length > 0) {
|
||||||
console.log(`\nFetching ${availableProducts.length} ASINs from Keepa...`);
|
console.log(`\nFetching ${availableProducts.length} ASINs from Keepa...`);
|
||||||
@@ -132,13 +127,10 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 5: SP-API pricing + fees — only for available ASINs
|
|
||||||
console.log(
|
console.log(
|
||||||
`\nFetching pricing & fees for ${availableProducts.length} ASINs...`,
|
`\nFetching pricing & fees for ${availableProducts.length} ASINs...`,
|
||||||
);
|
);
|
||||||
const spApiResults = new Map<string, SpApiData>();
|
const spApiResults = new Map<string, SpApiData>();
|
||||||
|
|
||||||
// Concurrency-limited pricing+fees fetches
|
|
||||||
const pricingQueue = [...availableProducts];
|
const pricingQueue = [...availableProducts];
|
||||||
let pricingDone = 0;
|
let pricingDone = 0;
|
||||||
|
|
||||||
@@ -169,7 +161,6 @@ async function main() {
|
|||||||
);
|
);
|
||||||
await Promise.all(pricingWorkers);
|
await Promise.all(pricingWorkers);
|
||||||
|
|
||||||
// Phase 6: Build enriched products
|
|
||||||
console.log(`\nEnriching products...`);
|
console.log(`\nEnriching products...`);
|
||||||
const enriched: EnrichedProduct[] = [];
|
const enriched: EnrichedProduct[] = [];
|
||||||
const availableAsins = new Set(availableProducts.map((ap) => ap.asin));
|
const availableAsins = new Set(availableProducts.map((ap) => ap.asin));
|
||||||
@@ -179,19 +170,16 @@ async function main() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cached products — already enriched
|
|
||||||
const cachedProduct = cached.get(p.asin);
|
const cachedProduct = cached.get(p.asin);
|
||||||
if (cachedProduct) {
|
if (cachedProduct) {
|
||||||
enriched.push(cachedProduct);
|
enriched.push(cachedProduct);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exclude products that are not explicitly available.
|
|
||||||
if (!availableAsins.has(p.asin)) {
|
if (!availableAsins.has(p.asin)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Available products — full enrichment
|
|
||||||
const keepa = keepaResults.get(p.asin) ?? null;
|
const keepa = keepaResults.get(p.asin) ?? null;
|
||||||
const spApi = spApiResults.get(p.asin) ?? {
|
const spApi = spApiResults.get(p.asin) ?? {
|
||||||
fbaFee: 5.0,
|
fbaFee: 5.0,
|
||||||
@@ -212,17 +200,8 @@ async function main() {
|
|||||||
|
|
||||||
await setCache(p.asin, product);
|
await setCache(p.asin, product);
|
||||||
enriched.push(product);
|
enriched.push(product);
|
||||||
|
|
||||||
if (keepa) {
|
|
||||||
console.log(
|
|
||||||
` [enriched] ${p.asin} — price: $${keepa.currentPrice ?? "N/A"}, rank: ${keepa.salesRank ?? "N/A"}`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log(` [no keepa] ${p.asin} — using spreadsheet data only`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 7: LLM analysis in batches — only for enriched available products
|
|
||||||
console.log(
|
console.log(
|
||||||
`\nAnalyzing ${enriched.length} products via LLM (batch size: ${LLM_BATCH_SIZE})...\n`,
|
`\nAnalyzing ${enriched.length} products via LLM (batch size: ${LLM_BATCH_SIZE})...\n`,
|
||||||
);
|
);
|
||||||
@@ -234,9 +213,7 @@ async function main() {
|
|||||||
const totalBatches = Math.ceil(enriched.length / LLM_BATCH_SIZE);
|
const totalBatches = Math.ceil(enriched.length / LLM_BATCH_SIZE);
|
||||||
console.log(` LLM batch ${batchNum}/${totalBatches}...`);
|
console.log(` LLM batch ${batchNum}/${totalBatches}...`);
|
||||||
|
|
||||||
// Wait between batches to avoid overwhelming LM Studio
|
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
console.log(` Waiting 5s before next batch...`);
|
|
||||||
await new Promise((r) => setTimeout(r, 5000));
|
await new Promise((r) => setTimeout(r, 5000));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,12 +221,10 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
verdicts = await analyzeProducts(batch);
|
verdicts = await analyzeProducts(batch);
|
||||||
} catch {
|
} catch {
|
||||||
console.warn(` LLM batch error, retrying after 10s...`);
|
|
||||||
await new Promise((r) => setTimeout(r, 10_000));
|
await new Promise((r) => setTimeout(r, 10_000));
|
||||||
try {
|
try {
|
||||||
verdicts = await analyzeProducts(batch);
|
verdicts = await analyzeProducts(batch);
|
||||||
} catch (retryErr) {
|
} catch {
|
||||||
console.error(` LLM analysis failed: ${retryErr}`);
|
|
||||||
verdicts = null;
|
verdicts = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,8 +251,10 @@ async function main() {
|
|||||||
console.log("Connecting to Redis...");
|
console.log("Connecting to Redis...");
|
||||||
await connectCache();
|
await connectCache();
|
||||||
|
|
||||||
|
console.log("Initializing SQLite database...");
|
||||||
|
initDb(DB_PATH);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Phase 1: Read input file
|
|
||||||
console.log(`\nReading ${inputFile}...`);
|
console.log(`\nReading ${inputFile}...`);
|
||||||
const products = readProducts(inputFile);
|
const products = readProducts(inputFile);
|
||||||
|
|
||||||
@@ -287,18 +264,14 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const productChunks = chunkArray(products, INPUT_BATCH_SIZE);
|
const productChunks = chunkArray(products, INPUT_BATCH_SIZE);
|
||||||
const hasMultipleChunks = productChunks.length > 1;
|
|
||||||
const shouldWriteChunkFiles = hasMultipleChunks;
|
|
||||||
const resolvedBaseOutputPath = resolveBaseOutputPath(inputFile, outputFile);
|
|
||||||
const allResults: AnalysisResult[] = [];
|
const allResults: AnalysisResult[] = [];
|
||||||
|
const resolvedBaseOutputPath = resolveBaseOutputPath(inputFile, outputFile);
|
||||||
|
|
||||||
if (hasMultipleChunks) {
|
if (productChunks.length > 1) {
|
||||||
console.log(
|
console.log(
|
||||||
`\nLarge input detected (${products.length} products). Processing in chunks of ${INPUT_BATCH_SIZE}.`,
|
`\nLarge input detected (${products.length} products). Processing in chunks of ${INPUT_BATCH_SIZE}.`,
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(`Output base path: ${resolvedBaseOutputPath}`);
|
||||||
`Chunk outputs will be written as numbered files using base: ${resolvedBaseOutputPath}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let chunkIndex = 0; chunkIndex < productChunks.length; chunkIndex++) {
|
for (let chunkIndex = 0; chunkIndex < productChunks.length; chunkIndex++) {
|
||||||
@@ -306,26 +279,17 @@ async function main() {
|
|||||||
console.log(
|
console.log(
|
||||||
`\n=== Input chunk ${chunkIndex + 1}/${productChunks.length} (${chunk.length} products) ===`,
|
`\n=== Input chunk ${chunkIndex + 1}/${productChunks.length} (${chunk.length} products) ===`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const chunkResults = await processProductChunk(chunk);
|
const chunkResults = await processProductChunk(chunk);
|
||||||
allResults.push(...chunkResults);
|
allResults.push(...chunkResults);
|
||||||
|
|
||||||
if (shouldWriteChunkFiles) {
|
|
||||||
const chunkOutputPath = buildChunkOutputPath(
|
|
||||||
resolvedBaseOutputPath,
|
|
||||||
chunkIndex,
|
|
||||||
);
|
|
||||||
writeResultsCsv(chunkResults, chunkOutputPath);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
printResults(allResults);
|
printResults(allResults);
|
||||||
|
|
||||||
writeResultsToDb(allResults, DB_PATH, inputFile, outputFile);
|
writeResultsToDb(allResults, DB_PATH, inputFile, outputFile);
|
||||||
|
} finally {
|
||||||
await disconnectCache();
|
await disconnectCache();
|
||||||
closeDb();
|
closeDb();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
console.error("Fatal error:", err);
|
console.error("Fatal error:", err);
|
||||||
|
|||||||
51
src/llm.ts
51
src/llm.ts
@@ -7,9 +7,14 @@ Given product data, evaluate each product's viability for selling on Amazon. Con
|
|||||||
|
|
||||||
1. **Sales Velocity**: monthlySold and salesRankDrops30 are the most important signals. A product that doesn't sell is worthless regardless of margin. salesRankDrops30 = approximate units sold in 30 days. monthlySold is Keepa's estimate.
|
1. **Sales Velocity**: monthlySold and salesRankDrops30 are the most important signals. A product that doesn't sell is worthless regardless of margin. salesRankDrops30 = approximate units sold in 30 days. monthlySold is Keepa's estimate.
|
||||||
2. **Margin Analysis**: Sale price minus unit cost minus fees (FBA or FBM). Aim for >30% ROI minimum. The spreadsheet may include FBA NET and gross profit estimates — cross-check against Keepa pricing data.
|
2. **Margin Analysis**: Sale price minus unit cost minus fees (FBA or FBM). Aim for >30% ROI minimum. The spreadsheet may include FBA NET and gross profit estimates — cross-check against Keepa pricing data.
|
||||||
|
- If unitCost is 0, it means NO COST DATA is available — this is NOT a free product. Do not assume infinite or zero-cost margins. When estimatedROI is null and unitCost is 0, evaluate purely on demand and velocity signals; never extrapolate profitability.
|
||||||
|
- If estimatedProfit is null, price data was unavailable at analysis time — treat as elevated uncertainty and be conservative.
|
||||||
3. **Sales Rank (BSR)**: Lower rank = higher demand. Rank <50,000 is good, <1,000 is excellent.
|
3. **Sales Rank (BSR)**: Lower rank = higher demand. Rank <50,000 is good, <1,000 is excellent.
|
||||||
4. **Sales Rank Trend**: Compare current rank vs 90d average. Lower current = improving demand.
|
4. **Sales Rank Trend**: Compare current rank vs 90d average. Lower current = improving demand.
|
||||||
5. **Competition**: Number of sellers and Buy Box dynamics. Fewer sellers = easier entry.
|
5. **Competition & Buy Box**:
|
||||||
|
- Fewer sellers = easier entry, but verify who holds the Buy Box.
|
||||||
|
- If buyBoxSeller is "ATVPDKIKX0DER" (Amazon retail), the product is Amazon-exclusive — return "SKIP". Amazon-sold items cannot be competed with by third-party sellers.
|
||||||
|
- If sellerCount is 1 and buyBoxSeller is not null, assume the market is closed — return "SKIP".
|
||||||
6. **Price Stability**: Large price swings (high max vs low min over 90d) = volatile/risky.
|
6. **Price Stability**: Large price swings (high max vs low min over 90d) = volatile/risky.
|
||||||
7. **FBA vs FBM**: FBA preferred for fast-selling, small/light items. FBM for oversized, slow-moving, or high-margin items where fee savings matter.
|
7. **FBA vs FBM**: FBA preferred for fast-selling, small/light items. FBM for oversized, slow-moving, or high-margin items where fee savings matter.
|
||||||
8. **MOQ & Capital**: High MOQ with thin margins is risky.
|
8. **MOQ & Capital**: High MOQ with thin margins is risky.
|
||||||
@@ -21,6 +26,7 @@ Given product data, evaluate each product's viability for selling on Amazon. Con
|
|||||||
|
|
||||||
Decision policy:
|
Decision policy:
|
||||||
- Do not recommend products that cannot be listed by this seller account.
|
- Do not recommend products that cannot be listed by this seller account.
|
||||||
|
- Do not recommend Amazon-exclusive products (buyBoxSeller = "ATVPDKIKX0DER").
|
||||||
- Prioritize profitable + high-velocity + listable products.
|
- Prioritize profitable + high-velocity + listable products.
|
||||||
- Use "SKIP" when data quality is poor or risk is high.
|
- Use "SKIP" when data quality is poor or risk is high.
|
||||||
|
|
||||||
@@ -106,12 +112,20 @@ function summarizeForLlm(p: EnrichedProduct) {
|
|||||||
const salePrice =
|
const salePrice =
|
||||||
p.keepa?.currentPrice ??
|
p.keepa?.currentPrice ??
|
||||||
p.record.sellingPriceFromSheet ??
|
p.record.sellingPriceFromSheet ??
|
||||||
p.spApi.estimatedSalePrice;
|
(p.spApi.estimatedSalePrice > 0 ? p.spApi.estimatedSalePrice : null) ??
|
||||||
const referralFee = salePrice * (p.spApi.referralFeePercent / 100);
|
p.keepa?.avgPrice90 ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
const referralFee =
|
||||||
|
salePrice != null ? salePrice * (p.spApi.referralFeePercent / 100) : null;
|
||||||
const fbaProfit =
|
const fbaProfit =
|
||||||
salePrice - p.record.unitCost - p.spApi.fbaFee - referralFee;
|
salePrice != null && referralFee != null
|
||||||
|
? salePrice - p.record.unitCost - p.spApi.fbaFee - referralFee
|
||||||
|
: null;
|
||||||
const fbmProfit =
|
const fbmProfit =
|
||||||
salePrice - p.record.unitCost - p.spApi.fbmFee - referralFee;
|
salePrice != null && referralFee != null
|
||||||
|
? salePrice - p.record.unitCost - p.spApi.fbmFee - referralFee
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
asin: p.record.asin,
|
asin: p.record.asin,
|
||||||
@@ -121,7 +135,7 @@ function summarizeForLlm(p: EnrichedProduct) {
|
|||||||
p.record.category ?? p.keepa?.categoryTree?.join(" > "),
|
p.record.category ?? p.keepa?.categoryTree?.join(" > "),
|
||||||
60,
|
60,
|
||||||
),
|
),
|
||||||
unitCost: p.record.unitCost,
|
unitCost: p.record.unitCost > 0 ? p.record.unitCost : null,
|
||||||
currentPrice: salePrice,
|
currentPrice: salePrice,
|
||||||
priceRange90d: p.keepa
|
priceRange90d: p.keepa
|
||||||
? {
|
? {
|
||||||
@@ -133,6 +147,8 @@ function summarizeForLlm(p: EnrichedProduct) {
|
|||||||
salesRank: p.keepa?.salesRank ?? p.record.amazonRank,
|
salesRank: p.keepa?.salesRank ?? p.record.amazonRank,
|
||||||
salesRankAvg90d: p.keepa?.salesRankAvg90,
|
salesRankAvg90d: p.keepa?.salesRankAvg90,
|
||||||
sellerCount: p.keepa?.sellerCount,
|
sellerCount: p.keepa?.sellerCount,
|
||||||
|
buyBoxSeller: p.keepa?.buyBoxSeller ?? null,
|
||||||
|
buyBoxPrice: p.keepa?.buyBoxPrice ?? null,
|
||||||
salesVelocity: {
|
salesVelocity: {
|
||||||
monthlySold: p.keepa?.monthlySold,
|
monthlySold: p.keepa?.monthlySold,
|
||||||
salesRankDrops30: p.keepa?.salesRankDrops30,
|
salesRankDrops30: p.keepa?.salesRankDrops30,
|
||||||
@@ -155,27 +171,28 @@ function summarizeForLlm(p: EnrichedProduct) {
|
|||||||
fbaFee: p.spApi.fbaFee,
|
fbaFee: p.spApi.fbaFee,
|
||||||
fbmFee: p.spApi.fbmFee,
|
fbmFee: p.spApi.fbmFee,
|
||||||
referralFeePercent: p.spApi.referralFeePercent,
|
referralFeePercent: p.spApi.referralFeePercent,
|
||||||
referralFee: Math.round(referralFee * 100) / 100,
|
referralFee:
|
||||||
|
referralFee != null ? Math.round(referralFee * 100) / 100 : null,
|
||||||
},
|
},
|
||||||
sellerEligibility: {
|
sellerEligibility: {
|
||||||
canSell: p.spApi.canSell,
|
canSell: p.spApi.canSell,
|
||||||
status: p.spApi.sellabilityStatus,
|
status: p.spApi.sellabilityStatus,
|
||||||
reason: clampText(p.spApi.sellabilityReason, 120),
|
reason: clampText(p.spApi.sellabilityReason, 120),
|
||||||
},
|
},
|
||||||
estimatedProfit: {
|
estimatedProfit:
|
||||||
|
fbaProfit != null && fbmProfit != null
|
||||||
|
? {
|
||||||
fba: Math.round(fbaProfit * 100) / 100,
|
fba: Math.round(fbaProfit * 100) / 100,
|
||||||
fbm: Math.round(fbmProfit * 100) / 100,
|
fbm: Math.round(fbmProfit * 100) / 100,
|
||||||
},
|
}
|
||||||
estimatedROI: {
|
|
||||||
fba:
|
|
||||||
p.record.unitCost > 0
|
|
||||||
? Math.round((fbaProfit / p.record.unitCost) * 100)
|
|
||||||
: null,
|
: null,
|
||||||
fbm:
|
estimatedROI:
|
||||||
p.record.unitCost > 0
|
p.record.unitCost > 0 && fbaProfit != null && fbmProfit != null
|
||||||
? Math.round((fbmProfit / p.record.unitCost) * 100)
|
? {
|
||||||
|
fba: Math.round((fbaProfit / p.record.unitCost) * 100),
|
||||||
|
fbm: Math.round((fbmProfit / p.record.unitCost) * 100),
|
||||||
|
}
|
||||||
: null,
|
: null,
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
619
src/server.ts
Normal file
619
src/server.ts
Normal file
@@ -0,0 +1,619 @@
|
|||||||
|
import index from "./web/index.html";
|
||||||
|
import { getDb, initDb } from "./database.ts";
|
||||||
|
|
||||||
|
type ProcessType = "lead_analysis" | "category_analysis";
|
||||||
|
|
||||||
|
type RunRecord = {
|
||||||
|
processType: ProcessType;
|
||||||
|
runId: number;
|
||||||
|
timestamp: string;
|
||||||
|
status: string;
|
||||||
|
jobType: string;
|
||||||
|
source: string | null;
|
||||||
|
output: string | null;
|
||||||
|
totalProducts: number;
|
||||||
|
fbaCount: number;
|
||||||
|
fbmCount: number;
|
||||||
|
skipCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProductListRecord = {
|
||||||
|
processType: ProcessType;
|
||||||
|
runId: number;
|
||||||
|
asin: string;
|
||||||
|
product_name: string | null;
|
||||||
|
brand: string | null;
|
||||||
|
category: string | null;
|
||||||
|
verdict: "FBA" | "FBM" | "SKIP";
|
||||||
|
confidence: number | null;
|
||||||
|
sellability_status: string | null;
|
||||||
|
fetched_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DB_PATH = process.env.RESULTS_DB_PATH || "./results.db";
|
||||||
|
const DEFAULT_PAGE_SIZE = 25;
|
||||||
|
const MAX_PAGE_SIZE = 200;
|
||||||
|
|
||||||
|
initDb(DB_PATH);
|
||||||
|
const db = getDb(DB_PATH);
|
||||||
|
|
||||||
|
function json(data: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "application/json; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function csv(text: string, filename: string): Response {
|
||||||
|
return new Response(text, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "text/csv; charset=utf-8",
|
||||||
|
"content-disposition": `attachment; filename="${filename}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIntParam(value: string | null, fallback: number): number {
|
||||||
|
if (!value) return fallback;
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 1) return fallback;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSort(sortParam: string | null, allowed: Set<string>, fallback: string): string {
|
||||||
|
if (!sortParam) return fallback;
|
||||||
|
const clauses = sortParam
|
||||||
|
.split(",")
|
||||||
|
.map((chunk) => chunk.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((chunk) => {
|
||||||
|
const [fieldRaw, dirRaw] = chunk.split(":");
|
||||||
|
const field = fieldRaw?.trim();
|
||||||
|
const dir = dirRaw?.trim().toUpperCase() === "DESC" ? "DESC" : "ASC";
|
||||||
|
if (!field || !allowed.has(field)) return null;
|
||||||
|
return `${field} ${dir}`;
|
||||||
|
})
|
||||||
|
.filter((value): value is string => value !== null);
|
||||||
|
|
||||||
|
return clauses.length > 0 ? clauses.join(", ") : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseResultSort(sortParam: string | null, allowed: Set<string>, fallback: string): string {
|
||||||
|
if (!sortParam) return fallback;
|
||||||
|
const clauses = sortParam
|
||||||
|
.split(",")
|
||||||
|
.map((chunk) => chunk.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((chunk) => {
|
||||||
|
const [fieldRaw, dirRaw] = chunk.split(":");
|
||||||
|
const field = fieldRaw?.trim();
|
||||||
|
const dir = dirRaw?.trim().toUpperCase() === "DESC" ? "DESC" : "ASC";
|
||||||
|
if (!field || !allowed.has(field)) return null;
|
||||||
|
if (field === "monthly_sold") return `CAST(COALESCE(monthly_sold, 0) AS INTEGER) ${dir}`;
|
||||||
|
return `${field} ${dir}`;
|
||||||
|
})
|
||||||
|
.filter((value): value is string => value !== null);
|
||||||
|
|
||||||
|
return clauses.length > 0 ? clauses.join(", ") : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeCsvValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return "";
|
||||||
|
const text = String(value);
|
||||||
|
const escaped = text.replaceAll("\"", "\"\"");
|
||||||
|
return /[",\n]/.test(escaped) ? `"${escaped}"` : escaped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseResultFilters(processType: ProcessType, runId: number, filters: URLSearchParams) {
|
||||||
|
const q = filters.get("q")?.trim() || "";
|
||||||
|
const verdict = filters.get("verdict")?.trim();
|
||||||
|
const sellabilityStatus = filters.get("sellabilityStatus")?.trim();
|
||||||
|
const minConfidence = filters.get("minConfidence")?.trim();
|
||||||
|
const maxConfidence = filters.get("maxConfidence")?.trim();
|
||||||
|
|
||||||
|
const conditions: string[] = ["run_id = ?"];
|
||||||
|
const params: Array<string | number> = [runId];
|
||||||
|
|
||||||
|
if (verdict) {
|
||||||
|
conditions.push("verdict = ?");
|
||||||
|
params.push(verdict);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sellabilityStatus) {
|
||||||
|
conditions.push("sellability_status = ?");
|
||||||
|
params.push(sellabilityStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minConfidence) {
|
||||||
|
conditions.push("confidence >= ?");
|
||||||
|
params.push(Number(minConfidence));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxConfidence) {
|
||||||
|
conditions.push("confidence <= ?");
|
||||||
|
params.push(Number(maxConfidence));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q) {
|
||||||
|
const wildcard = `%${q}%`;
|
||||||
|
if (processType === "lead_analysis") {
|
||||||
|
conditions.push("(asin LIKE ? OR product_name LIKE ? OR brand LIKE ? OR category LIKE ? OR reasoning LIKE ?)");
|
||||||
|
params.push(wildcard, wildcard, wildcard, wildcard, wildcard);
|
||||||
|
} else {
|
||||||
|
conditions.push("(asin LIKE ? OR name LIKE ? OR brand LIKE ? OR category LIKE ? OR reasoning LIKE ?)");
|
||||||
|
params.push(wildcard, wildcard, wildcard, wildcard, wildcard);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
where: `WHERE ${conditions.join(" AND ")}`,
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuns(filters: URLSearchParams) {
|
||||||
|
const q = filters.get("q")?.trim() || "";
|
||||||
|
const processType = filters.get("processType")?.trim();
|
||||||
|
const status = filters.get("status")?.trim();
|
||||||
|
const startDate = filters.get("startDate")?.trim();
|
||||||
|
const endDate = filters.get("endDate")?.trim();
|
||||||
|
const page = parseIntParam(filters.get("page"), 1);
|
||||||
|
const pageSize = Math.min(parseIntParam(filters.get("pageSize"), DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE);
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const allowedSort = new Set([
|
||||||
|
"timestamp",
|
||||||
|
"status",
|
||||||
|
"totalProducts",
|
||||||
|
"fbaCount",
|
||||||
|
"fbmCount",
|
||||||
|
"skipCount",
|
||||||
|
"runId",
|
||||||
|
"jobType",
|
||||||
|
]);
|
||||||
|
const orderBy = parseSort(filters.get("sort"), allowedSort, "timestamp DESC, runId DESC");
|
||||||
|
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: Array<string | number> = [];
|
||||||
|
|
||||||
|
if (processType === "lead_analysis" || processType === "category_analysis") {
|
||||||
|
conditions.push("processType = ?");
|
||||||
|
params.push(processType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
conditions.push("status = ?");
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
conditions.push("timestamp >= ?");
|
||||||
|
params.push(startDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
conditions.push("timestamp <= ?");
|
||||||
|
params.push(endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q) {
|
||||||
|
conditions.push("(jobType LIKE ? OR source LIKE ? OR output LIKE ? OR CAST(runId AS TEXT) LIKE ?)");
|
||||||
|
const wildcard = `%${q}%`;
|
||||||
|
params.push(wildcard, wildcard, wildcard, wildcard);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
|
|
||||||
|
const baseUnion = `
|
||||||
|
SELECT
|
||||||
|
'lead_analysis' AS processType,
|
||||||
|
id AS runId,
|
||||||
|
timestamp,
|
||||||
|
'completed' AS status,
|
||||||
|
'lead_file_analysis' AS jobType,
|
||||||
|
input_file AS source,
|
||||||
|
output_file AS output,
|
||||||
|
COALESCE(total_products, 0) AS totalProducts,
|
||||||
|
COALESCE(fba_count, 0) AS fbaCount,
|
||||||
|
COALESCE(fbm_count, 0) AS fbmCount,
|
||||||
|
COALESCE(skip_count, 0) AS skipCount
|
||||||
|
FROM runs
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
'category_analysis' AS processType,
|
||||||
|
id AS runId,
|
||||||
|
run_timestamp AS timestamp,
|
||||||
|
status,
|
||||||
|
category_label AS jobType,
|
||||||
|
CAST(category_id AS TEXT) AS source,
|
||||||
|
NULL AS output,
|
||||||
|
COALESCE(top_asins_checked, 0) AS totalProducts,
|
||||||
|
COALESCE(fba_count, 0) AS fbaCount,
|
||||||
|
COALESCE(fbm_count, 0) AS fbmCount,
|
||||||
|
COALESCE(skip_count, 0) AS skipCount
|
||||||
|
FROM category_analysis_runs
|
||||||
|
`;
|
||||||
|
|
||||||
|
const totalRow = db
|
||||||
|
.query(`SELECT COUNT(*) as total FROM (${baseUnion}) all_runs ${where}`)
|
||||||
|
.get(...params) as { total: number };
|
||||||
|
|
||||||
|
const items = db
|
||||||
|
.query(`SELECT * FROM (${baseUnion}) all_runs ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`)
|
||||||
|
.all(...params, pageSize, offset) as RunRecord[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total: totalRow.total,
|
||||||
|
totalPages: Math.max(1, Math.ceil(totalRow.total / pageSize)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProductList(filters: URLSearchParams) {
|
||||||
|
const q = filters.get("q")?.trim() || "";
|
||||||
|
const verdict = filters.get("verdict")?.trim();
|
||||||
|
const page = parseIntParam(filters.get("page"), 1);
|
||||||
|
const pageSize = Math.min(parseIntParam(filters.get("pageSize"), DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE);
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: Array<string | number> = [];
|
||||||
|
|
||||||
|
if (verdict === "FBA" || verdict === "FBM" || verdict === "SKIP") {
|
||||||
|
conditions.push("verdict = ?");
|
||||||
|
params.push(verdict);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q) {
|
||||||
|
const wildcard = `%${q}%`;
|
||||||
|
conditions.push("(asin LIKE ? OR product_name LIKE ? OR brand LIKE ? OR category LIKE ?)");
|
||||||
|
params.push(wildcard, wildcard, wildcard, wildcard);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
|
|
||||||
|
const baseUnion = `
|
||||||
|
SELECT
|
||||||
|
'lead_analysis' AS processType,
|
||||||
|
run_id AS runId,
|
||||||
|
asin,
|
||||||
|
product_name,
|
||||||
|
brand,
|
||||||
|
category,
|
||||||
|
verdict,
|
||||||
|
confidence,
|
||||||
|
sellability_status,
|
||||||
|
fetched_at
|
||||||
|
FROM results
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
'category_analysis' AS processType,
|
||||||
|
run_id AS runId,
|
||||||
|
asin,
|
||||||
|
name AS product_name,
|
||||||
|
brand,
|
||||||
|
category,
|
||||||
|
verdict,
|
||||||
|
confidence,
|
||||||
|
sellability_status,
|
||||||
|
fetched_at
|
||||||
|
FROM product_analysis_results
|
||||||
|
`;
|
||||||
|
|
||||||
|
const totalRow = db
|
||||||
|
.query(`SELECT COUNT(*) as total FROM (${baseUnion}) all_products ${where}`)
|
||||||
|
.get(...params) as { total: number };
|
||||||
|
|
||||||
|
const items = db
|
||||||
|
.query(`SELECT * FROM (${baseUnion}) all_products ${where} ORDER BY fetched_at DESC LIMIT ? OFFSET ?`)
|
||||||
|
.all(...params, pageSize, offset) as ProductListRecord[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total: totalRow.total,
|
||||||
|
totalPages: Math.max(1, Math.ceil(totalRow.total / pageSize)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRun(processType: ProcessType, runId: number) {
|
||||||
|
if (processType === "lead_analysis") {
|
||||||
|
const run = db
|
||||||
|
.query(
|
||||||
|
`SELECT
|
||||||
|
id AS runId,
|
||||||
|
timestamp,
|
||||||
|
'completed' AS status,
|
||||||
|
'lead_file_analysis' AS jobType,
|
||||||
|
input_file AS source,
|
||||||
|
output_file AS output,
|
||||||
|
COALESCE(total_products, 0) AS totalProducts,
|
||||||
|
COALESCE(fba_count, 0) AS fbaCount,
|
||||||
|
COALESCE(fbm_count, 0) AS fbmCount,
|
||||||
|
COALESCE(skip_count, 0) AS skipCount
|
||||||
|
FROM runs WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.get(runId);
|
||||||
|
return run ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = db
|
||||||
|
.query(
|
||||||
|
`SELECT
|
||||||
|
id AS runId,
|
||||||
|
run_timestamp AS timestamp,
|
||||||
|
status,
|
||||||
|
category_label AS jobType,
|
||||||
|
CAST(category_id AS TEXT) AS source,
|
||||||
|
NULL AS output,
|
||||||
|
COALESCE(top_asins_checked, 0) AS totalProducts,
|
||||||
|
COALESCE(fba_count, 0) AS fbaCount,
|
||||||
|
COALESCE(fbm_count, 0) AS fbmCount,
|
||||||
|
COALESCE(skip_count, 0) AS skipCount,
|
||||||
|
error_message AS errorMessage,
|
||||||
|
available_asins AS availableAsins
|
||||||
|
FROM category_analysis_runs WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.get(runId);
|
||||||
|
return run ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRunResults(processType: ProcessType, runId: number, filters: URLSearchParams) {
|
||||||
|
const page = parseIntParam(filters.get("page"), 1);
|
||||||
|
const pageSize = Math.min(parseIntParam(filters.get("pageSize"), DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE);
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const tableName = processType === "lead_analysis" ? "results" : "product_analysis_results";
|
||||||
|
const productNameSelect = processType === "lead_analysis" ? "product_name" : "name AS product_name";
|
||||||
|
const sellerCountSelect = processType === "lead_analysis" ? "sellers AS seller_count" : "seller_count";
|
||||||
|
const salesRankAvgSelect = processType === "lead_analysis" ? "rank_avg_90d AS sales_rank_avg_90d" : "sales_rank_avg_90d";
|
||||||
|
|
||||||
|
const { where, params } = parseResultFilters(processType, runId, filters);
|
||||||
|
|
||||||
|
const allowedSort = new Set([
|
||||||
|
"asin",
|
||||||
|
"product_name",
|
||||||
|
"brand",
|
||||||
|
"category",
|
||||||
|
"current_price",
|
||||||
|
"avg_price_90d",
|
||||||
|
"sales_rank",
|
||||||
|
"seller_count",
|
||||||
|
"monthly_sold",
|
||||||
|
"verdict",
|
||||||
|
"confidence",
|
||||||
|
"fetched_at",
|
||||||
|
]);
|
||||||
|
const orderBy = parseResultSort(
|
||||||
|
filters.get("sort"),
|
||||||
|
allowedSort,
|
||||||
|
"CAST(COALESCE(monthly_sold, 0) AS INTEGER) DESC, asin ASC",
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalRow = db
|
||||||
|
.query(`SELECT COUNT(*) as total FROM ${tableName} ${where}`)
|
||||||
|
.get(...params) as { total: number };
|
||||||
|
|
||||||
|
const items = db
|
||||||
|
.query(
|
||||||
|
`SELECT
|
||||||
|
id,
|
||||||
|
run_id,
|
||||||
|
asin,
|
||||||
|
${productNameSelect},
|
||||||
|
brand,
|
||||||
|
category,
|
||||||
|
unit_cost,
|
||||||
|
current_price,
|
||||||
|
avg_price_90d,
|
||||||
|
avg_price_90d_sheet,
|
||||||
|
selling_price_sheet,
|
||||||
|
sales_rank,
|
||||||
|
${salesRankAvgSelect},
|
||||||
|
${sellerCountSelect},
|
||||||
|
monthly_sold,
|
||||||
|
rank_drops_30d,
|
||||||
|
rank_drops_90d,
|
||||||
|
fba_fee,
|
||||||
|
fbm_fee,
|
||||||
|
referral_percent,
|
||||||
|
can_sell,
|
||||||
|
sellability_status,
|
||||||
|
sellability_reason,
|
||||||
|
verdict,
|
||||||
|
confidence,
|
||||||
|
reasoning,
|
||||||
|
fetched_at
|
||||||
|
FROM ${tableName}
|
||||||
|
${where}
|
||||||
|
ORDER BY ${orderBy}
|
||||||
|
LIMIT ? OFFSET ?`,
|
||||||
|
)
|
||||||
|
.all(...params, pageSize, offset);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total: totalRow.total,
|
||||||
|
totalPages: Math.max(1, Math.ceil(totalRow.total / pageSize)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRun(processType: ProcessType, runId: number) {
|
||||||
|
if (processType === "lead_analysis") {
|
||||||
|
const resultRows = db.query("DELETE FROM results WHERE run_id = ?").run(runId);
|
||||||
|
const runRows = db.query("DELETE FROM runs WHERE id = ?").run(runId);
|
||||||
|
return {
|
||||||
|
deletedRun: runRows.changes > 0,
|
||||||
|
deletedResults: resultRows.changes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultRows = db.query("DELETE FROM product_analysis_results WHERE run_id = ?").run(runId);
|
||||||
|
const runRows = db.query("DELETE FROM category_analysis_runs WHERE id = ?").run(runId);
|
||||||
|
return {
|
||||||
|
deletedRun: runRows.changes > 0,
|
||||||
|
deletedResults: resultRows.changes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportRunResultsCsv(processType: ProcessType, runId: number, filters: URLSearchParams) {
|
||||||
|
const tableName = processType === "lead_analysis" ? "results" : "product_analysis_results";
|
||||||
|
const productNameSelect = processType === "lead_analysis" ? "product_name" : "name AS product_name";
|
||||||
|
const sellerCountSelect = processType === "lead_analysis" ? "sellers AS seller_count" : "seller_count";
|
||||||
|
const salesRankAvgSelect = processType === "lead_analysis" ? "rank_avg_90d AS sales_rank_avg_90d" : "sales_rank_avg_90d";
|
||||||
|
|
||||||
|
const { where, params } = parseResultFilters(processType, runId, filters);
|
||||||
|
|
||||||
|
const allowedSort = new Set([
|
||||||
|
"asin",
|
||||||
|
"product_name",
|
||||||
|
"brand",
|
||||||
|
"category",
|
||||||
|
"current_price",
|
||||||
|
"avg_price_90d",
|
||||||
|
"sales_rank",
|
||||||
|
"seller_count",
|
||||||
|
"monthly_sold",
|
||||||
|
"verdict",
|
||||||
|
"confidence",
|
||||||
|
"fetched_at",
|
||||||
|
]);
|
||||||
|
const orderBy = parseResultSort(
|
||||||
|
filters.get("sort"),
|
||||||
|
allowedSort,
|
||||||
|
"CAST(COALESCE(monthly_sold, 0) AS INTEGER) DESC, asin ASC",
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.query(
|
||||||
|
`SELECT
|
||||||
|
run_id,
|
||||||
|
asin,
|
||||||
|
${productNameSelect},
|
||||||
|
brand,
|
||||||
|
category,
|
||||||
|
unit_cost,
|
||||||
|
current_price,
|
||||||
|
avg_price_90d,
|
||||||
|
${salesRankAvgSelect},
|
||||||
|
${sellerCountSelect},
|
||||||
|
monthly_sold,
|
||||||
|
sellability_status,
|
||||||
|
verdict,
|
||||||
|
confidence,
|
||||||
|
reasoning,
|
||||||
|
fetched_at
|
||||||
|
FROM ${tableName}
|
||||||
|
${where}
|
||||||
|
ORDER BY ${orderBy}`,
|
||||||
|
)
|
||||||
|
.all(...params) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
"run_id",
|
||||||
|
"asin",
|
||||||
|
"product_name",
|
||||||
|
"brand",
|
||||||
|
"category",
|
||||||
|
"unit_cost",
|
||||||
|
"current_price",
|
||||||
|
"avg_price_90d",
|
||||||
|
"sales_rank_avg_90d",
|
||||||
|
"seller_count",
|
||||||
|
"monthly_sold",
|
||||||
|
"sellability_status",
|
||||||
|
"verdict",
|
||||||
|
"confidence",
|
||||||
|
"reasoning",
|
||||||
|
"fetched_at",
|
||||||
|
];
|
||||||
|
|
||||||
|
const lines = [headers.join(",")];
|
||||||
|
for (const row of rows) {
|
||||||
|
lines.push(headers.map((h) => escapeCsvValue(row[h])).join(","));
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: Number(process.env.PORT || "3000"),
|
||||||
|
routes: {
|
||||||
|
"/": index,
|
||||||
|
"/products": index,
|
||||||
|
"/runs/:processType/:runId": index,
|
||||||
|
"/api/runs": (req) => {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
return json(getRuns(url.searchParams));
|
||||||
|
},
|
||||||
|
"/api/products": (req) => {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
return json(getProductList(url.searchParams));
|
||||||
|
},
|
||||||
|
"/api/runs/:processType/:runId": (req) => {
|
||||||
|
const processType = req.params.processType as ProcessType;
|
||||||
|
const runId = Number(req.params.runId);
|
||||||
|
|
||||||
|
if (!(processType === "lead_analysis" || processType === "category_analysis") || !Number.isInteger(runId)) {
|
||||||
|
return json({ error: "Invalid run identifier" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "DELETE") {
|
||||||
|
const deleted = deleteRun(processType, runId);
|
||||||
|
if (!deleted.deletedRun) return json({ error: "Run not found" }, 404);
|
||||||
|
return json(deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = getRun(processType, runId);
|
||||||
|
if (!run) return json({ error: "Run not found" }, 404);
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
totalProducts: (run as { totalProducts: number }).totalProducts,
|
||||||
|
fbaCount: (run as { fbaCount: number }).fbaCount,
|
||||||
|
fbmCount: (run as { fbmCount: number }).fbmCount,
|
||||||
|
skipCount: (run as { skipCount: number }).skipCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
return json({ processType, ...run, summary });
|
||||||
|
},
|
||||||
|
"/api/runs/:processType/:runId/results": (req) => {
|
||||||
|
const processType = req.params.processType as ProcessType;
|
||||||
|
const runId = Number(req.params.runId);
|
||||||
|
|
||||||
|
if (!(processType === "lead_analysis" || processType === "category_analysis") || !Number.isInteger(runId)) {
|
||||||
|
return json({ error: "Invalid run identifier" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const payload = getRunResults(processType, runId, url.searchParams);
|
||||||
|
return json(payload);
|
||||||
|
},
|
||||||
|
"/api/runs/:processType/:runId/export.csv": (req) => {
|
||||||
|
const processType = req.params.processType as ProcessType;
|
||||||
|
const runId = Number(req.params.runId);
|
||||||
|
|
||||||
|
if (!(processType === "lead_analysis" || processType === "category_analysis") || !Number.isInteger(runId)) {
|
||||||
|
return json({ error: "Invalid run identifier" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const csvText = exportRunResultsCsv(processType, runId, url.searchParams);
|
||||||
|
return csv(csvText, `run-${processType}-${runId}.csv`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fetch() {
|
||||||
|
return json({ error: "Not found" }, 404);
|
||||||
|
},
|
||||||
|
development: {
|
||||||
|
hmr: true,
|
||||||
|
console: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Results viewer running on http://localhost:${server.port}`);
|
||||||
780
src/web/frontend.tsx
Normal file
780
src/web/frontend.tsx
Normal file
@@ -0,0 +1,780 @@
|
|||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
type ProcessType = "lead_analysis" | "category_analysis";
|
||||||
|
type SortDirection = "ASC" | "DESC";
|
||||||
|
|
||||||
|
type Run = {
|
||||||
|
processType: ProcessType;
|
||||||
|
runId: number;
|
||||||
|
timestamp: string;
|
||||||
|
status: string;
|
||||||
|
jobType: string;
|
||||||
|
source: string | null;
|
||||||
|
output: string | null;
|
||||||
|
totalProducts: number;
|
||||||
|
fbaCount: number;
|
||||||
|
fbmCount: number;
|
||||||
|
skipCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RunsResponse = {
|
||||||
|
items: Run[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RunDetail = {
|
||||||
|
processType: ProcessType;
|
||||||
|
runId: number;
|
||||||
|
timestamp: string;
|
||||||
|
status: string;
|
||||||
|
jobType: string;
|
||||||
|
source: string | null;
|
||||||
|
output: string | null;
|
||||||
|
totalProducts: number;
|
||||||
|
fbaCount: number;
|
||||||
|
fbmCount: number;
|
||||||
|
skipCount: number;
|
||||||
|
summary: {
|
||||||
|
totalProducts: number;
|
||||||
|
fbaCount: number;
|
||||||
|
fbmCount: number;
|
||||||
|
skipCount: number;
|
||||||
|
};
|
||||||
|
errorMessage?: string;
|
||||||
|
availableAsins?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResultItem = {
|
||||||
|
id?: number;
|
||||||
|
run_id: number;
|
||||||
|
asin: string;
|
||||||
|
product_name: string | null;
|
||||||
|
brand: string | null;
|
||||||
|
category: string | null;
|
||||||
|
current_price: number | null;
|
||||||
|
avg_price_90d: number | null;
|
||||||
|
sales_rank: number | null;
|
||||||
|
seller_count: number | null;
|
||||||
|
monthly_sold: number | null;
|
||||||
|
verdict: "FBA" | "FBM" | "SKIP";
|
||||||
|
confidence: number | null;
|
||||||
|
sellability_status: string | null;
|
||||||
|
reasoning: string | null;
|
||||||
|
fetched_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResultsResponse = {
|
||||||
|
items: ResultItem[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VerdictFilter = "" | "FBA" | "FBM" | "SKIP";
|
||||||
|
|
||||||
|
type ProductListItem = {
|
||||||
|
processType: ProcessType;
|
||||||
|
runId: number;
|
||||||
|
asin: string;
|
||||||
|
product_name: string | null;
|
||||||
|
brand: string | null;
|
||||||
|
category: string | null;
|
||||||
|
verdict: "FBA" | "FBM" | "SKIP";
|
||||||
|
confidence: number | null;
|
||||||
|
sellability_status: string | null;
|
||||||
|
fetched_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProductListResponse = {
|
||||||
|
items: ProductListItem[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SortState = {
|
||||||
|
field: string;
|
||||||
|
direction: SortDirection;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(input: string): string {
|
||||||
|
const d = new Date(input);
|
||||||
|
if (Number.isNaN(d.getTime())) return input;
|
||||||
|
return d.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number | null | undefined): string {
|
||||||
|
if (value === null || value === undefined) return "-";
|
||||||
|
return new Intl.NumberFormat().format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(value: number | null | undefined): string {
|
||||||
|
if (value === null || value === undefined) return "-";
|
||||||
|
return new Intl.NumberFormat(undefined, {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSortValue(sort: SortState): string {
|
||||||
|
return `${sort.field}:${sort.direction}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextSort(current: SortState, field: string): SortState {
|
||||||
|
if (current.field !== field) {
|
||||||
|
return { field, direction: "ASC" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
field,
|
||||||
|
direction: current.direction === "ASC" ? "DESC" : "ASC",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadgeClass(status: string): string {
|
||||||
|
if (status === "ok" || status === "completed") return "badge badge-ok";
|
||||||
|
if (status === "failed") return "badge badge-failed";
|
||||||
|
return "badge badge-empty";
|
||||||
|
}
|
||||||
|
|
||||||
|
function verdictBadgeClass(verdict: string): string {
|
||||||
|
if (verdict === "FBA") return "badge badge-fba";
|
||||||
|
if (verdict === "FBM") return "badge badge-fbm";
|
||||||
|
return "badge badge-skip";
|
||||||
|
}
|
||||||
|
|
||||||
|
function TinyBar({ fba, fbm, skip }: { fba: number; fbm: number; skip: number }) {
|
||||||
|
const total = Math.max(1, fba + fbm + skip);
|
||||||
|
const fbaPct = (fba / total) * 100;
|
||||||
|
const fbmPct = (fbm / total) * 100;
|
||||||
|
const skipPct = (skip / total) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tiny-bar" title={`FBA ${fba}, FBM ${fbm}, SKIP ${skip}`}>
|
||||||
|
<span className="tiny-fba" style={{ width: `${fbaPct}%` }} />
|
||||||
|
<span className="tiny-fbm" style={{ width: `${fbmPct}%` }} />
|
||||||
|
<span className="tiny-skip" style={{ width: `${skipPct}%` }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectAnomaly(item: ResultItem): string {
|
||||||
|
const confidence = item.confidence ?? 0;
|
||||||
|
if ((item.sellability_status === "restricted" || item.sellability_status === "not_available") && item.verdict !== "SKIP") {
|
||||||
|
return "restricted-vs-verdict";
|
||||||
|
}
|
||||||
|
if (item.verdict === "FBA" && confidence < 60) {
|
||||||
|
return "low-confidence-fba";
|
||||||
|
}
|
||||||
|
if ((item.sales_rank ?? Number.MAX_SAFE_INTEGER) > 300000 && item.verdict === "FBA") {
|
||||||
|
return "high-rank-fba";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dashboard({
|
||||||
|
onOpenRun,
|
||||||
|
onOpenProducts,
|
||||||
|
}: {
|
||||||
|
onOpenRun: (run: Run) => void;
|
||||||
|
onOpenProducts: (verdict: VerdictFilter) => void;
|
||||||
|
}) {
|
||||||
|
const [runs, setRuns] = useState<RunsResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [processType, setProcessType] = useState("");
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [startDate, setStartDate] = useState("");
|
||||||
|
const [endDate, setEndDate] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
const [sort, setSort] = useState<SortState>({ field: "timestamp", direction: "DESC" });
|
||||||
|
const [refreshTick, setRefreshTick] = useState(0);
|
||||||
|
const [deletingKey, setDeletingKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
if (!runs) return { total: 0, fba: 0, fbm: 0, skip: 0 };
|
||||||
|
return runs.items.reduce(
|
||||||
|
(acc, run) => {
|
||||||
|
acc.total += run.totalProducts;
|
||||||
|
acc.fba += run.fbaCount;
|
||||||
|
acc.fbm += run.fbmCount;
|
||||||
|
acc.skip += run.skipCount;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ total: 0, fba: 0, fbm: 0, skip: 0 },
|
||||||
|
);
|
||||||
|
}, [runs]);
|
||||||
|
|
||||||
|
const timeline = useMemo(() => {
|
||||||
|
if (!runs) return [] as Run[];
|
||||||
|
return [...runs.items]
|
||||||
|
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
|
||||||
|
.slice(-12);
|
||||||
|
}, [runs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
pageSize: String(pageSize),
|
||||||
|
sort: buildSortValue(sort),
|
||||||
|
});
|
||||||
|
if (search) params.set("q", search);
|
||||||
|
if (processType) params.set("processType", processType);
|
||||||
|
if (status) params.set("status", status);
|
||||||
|
if (startDate) params.set("startDate", `${startDate}T00:00:00.000Z`);
|
||||||
|
if (endDate) params.set("endDate", `${endDate}T23:59:59.999Z`);
|
||||||
|
|
||||||
|
const resp = await fetch(`/api/runs?${params.toString()}`);
|
||||||
|
const data = (await resp.json()) as RunsResponse;
|
||||||
|
if (!cancelled) {
|
||||||
|
setRuns(data);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [search, processType, status, startDate, endDate, page, pageSize, sort, refreshTick]);
|
||||||
|
|
||||||
|
async function deleteRun(run: Run) {
|
||||||
|
const key = `${run.processType}-${run.runId}`;
|
||||||
|
const confirmed = window.confirm(`Delete run ${run.runId} (${run.processType}) and all associated results?`);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
setDeletingKey(key);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/runs/${run.processType}/${run.runId}`, { method: "DELETE" });
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorPayload = await response.json().catch(() => null) as { error?: string } | null;
|
||||||
|
const message = errorPayload?.error ?? "Failed to delete run";
|
||||||
|
window.alert(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPage(1);
|
||||||
|
setRefreshTick((tick) => tick + 1);
|
||||||
|
} finally {
|
||||||
|
setDeletingKey(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="card">
|
||||||
|
<h2>Runs Dashboard</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="metrics">
|
||||||
|
<div className="metric" role="button" tabIndex={0} onClick={() => onOpenProducts("")} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onOpenProducts(""); }}>
|
||||||
|
<div className="label">Total products</div>
|
||||||
|
<div className="value">{formatNumber(summary.total)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="metric" role="button" tabIndex={0} onClick={() => onOpenProducts("FBA")} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onOpenProducts("FBA"); }}>
|
||||||
|
<div className="label">FBA</div>
|
||||||
|
<div className="value">{formatNumber(summary.fba)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="metric" role="button" tabIndex={0} onClick={() => onOpenProducts("FBM")} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onOpenProducts("FBM"); }}>
|
||||||
|
<div className="label">FBM</div>
|
||||||
|
<div className="value">{formatNumber(summary.fbm)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="metric" role="button" tabIndex={0} onClick={() => onOpenProducts("SKIP")} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onOpenProducts("SKIP"); }}>
|
||||||
|
<div className="label">SKIP</div>
|
||||||
|
<div className="value">{formatNumber(summary.skip)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="toolbar">
|
||||||
|
<input value={search} onChange={(e) => { setPage(1); setSearch(e.target.value); }} placeholder="Search run/job/source" />
|
||||||
|
<select value={processType} onChange={(e) => { setPage(1); setProcessType(e.target.value); }}>
|
||||||
|
<option value="">All processes</option>
|
||||||
|
<option value="lead_analysis">lead_analysis</option>
|
||||||
|
<option value="category_analysis">category_analysis</option>
|
||||||
|
</select>
|
||||||
|
<select value={status} onChange={(e) => { setPage(1); setStatus(e.target.value); }}>
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
<option value="completed">completed</option>
|
||||||
|
<option value="ok">ok</option>
|
||||||
|
<option value="empty">empty</option>
|
||||||
|
<option value="failed">failed</option>
|
||||||
|
</select>
|
||||||
|
<input type="date" value={startDate} onChange={(e) => { setPage(1); setStartDate(e.target.value); }} />
|
||||||
|
<input type="date" value={endDate} onChange={(e) => { setPage(1); setEndDate(e.target.value); }} />
|
||||||
|
<select value={String(pageSize)} onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)); }}>
|
||||||
|
<option value="25">25 / page</option>
|
||||||
|
<option value="50">50 / page</option>
|
||||||
|
<option value="100">100 / page</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><button onClick={() => setSort(nextSort(sort, "runId"))}>Run ID</button></th>
|
||||||
|
<th><button onClick={() => setSort(nextSort(sort, "processType"))}>Process</button></th>
|
||||||
|
<th><button onClick={() => setSort(nextSort(sort, "jobType"))}>Job Type</button></th>
|
||||||
|
<th><button onClick={() => setSort(nextSort(sort, "timestamp"))}>Timestamp</button></th>
|
||||||
|
<th><button onClick={() => setSort(nextSort(sort, "status"))}>Status</button></th>
|
||||||
|
<th><button onClick={() => setSort(nextSort(sort, "totalProducts"))}>Total</button></th>
|
||||||
|
<th>FBA</th>
|
||||||
|
<th>FBM</th>
|
||||||
|
<th>SKIP</th>
|
||||||
|
<th>Mix</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Open</th>
|
||||||
|
<th>Delete</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={13}>Loading...</td></tr>
|
||||||
|
) : runs?.items.length ? (
|
||||||
|
runs.items.map((run) => (
|
||||||
|
<tr key={`${run.processType}-${run.runId}`}>
|
||||||
|
<td>{run.runId}</td>
|
||||||
|
<td>{run.processType}</td>
|
||||||
|
<td>{run.jobType}</td>
|
||||||
|
<td>{formatDate(run.timestamp)}</td>
|
||||||
|
<td><span className={statusBadgeClass(run.status)}>{run.status}</span></td>
|
||||||
|
<td>{formatNumber(run.totalProducts)}</td>
|
||||||
|
<td>{formatNumber(run.fbaCount)}</td>
|
||||||
|
<td>{formatNumber(run.fbmCount)}</td>
|
||||||
|
<td>{formatNumber(run.skipCount)}</td>
|
||||||
|
<td><TinyBar fba={run.fbaCount} fbm={run.fbmCount} skip={run.skipCount} /></td>
|
||||||
|
<td>{run.source || "-"}</td>
|
||||||
|
<td><button onClick={() => onOpenRun(run)}>View</button></td>
|
||||||
|
<td>
|
||||||
|
<button disabled={deletingKey === `${run.processType}-${run.runId}`} onClick={() => deleteRun(run)}>
|
||||||
|
{deletingKey === `${run.processType}-${run.runId}` ? "Deleting..." : "Delete"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr><td colSpan={13}>No runs found</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="pager">
|
||||||
|
<div>Showing {runs?.items.length ?? 0} of {runs?.total ?? 0}</div>
|
||||||
|
<div>
|
||||||
|
<button disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</button>
|
||||||
|
<span style={{ padding: "0 8px" }}>Page {runs?.page ?? page} / {runs?.totalPages ?? 1}</span>
|
||||||
|
<button disabled={Boolean(runs && page >= runs.totalPages)} onClick={() => setPage((p) => p + 1)}>Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>Recent trend (last 12 runs in current view)</h3>
|
||||||
|
<div className="spark-grid" style={{ marginTop: 10 }}>
|
||||||
|
{timeline.length === 0 ? (
|
||||||
|
<div className="meta">No trend data</div>
|
||||||
|
) : (
|
||||||
|
timeline.map((run) => (
|
||||||
|
<div key={`trend-${run.processType}-${run.runId}`} className="spark-item" title={`${run.runId} • ${formatDate(run.timestamp)}`}>
|
||||||
|
<div className="spark-label">#{run.runId}</div>
|
||||||
|
<TinyBar fba={run.fbaCount} fbm={run.fbmCount} skip={run.skipCount} />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RunDetails({
|
||||||
|
processType,
|
||||||
|
runId,
|
||||||
|
onBack,
|
||||||
|
}: {
|
||||||
|
processType: ProcessType;
|
||||||
|
runId: number;
|
||||||
|
onBack: () => void;
|
||||||
|
}) {
|
||||||
|
const [run, setRun] = useState<RunDetail | null>(null);
|
||||||
|
const [results, setResults] = useState<ResultsResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [verdict, setVerdict] = useState("");
|
||||||
|
const [sellabilityStatus, setSellabilityStatus] = useState("");
|
||||||
|
const [minConfidence, setMinConfidence] = useState("");
|
||||||
|
const [maxConfidence, setMaxConfidence] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
const [sort, setSort] = useState<SortState>({ field: "monthly_sold", direction: "DESC" });
|
||||||
|
const [refreshTick, setRefreshTick] = useState(0);
|
||||||
|
|
||||||
|
const anomalies = useMemo(() => {
|
||||||
|
if (!results) return [] as ResultItem[];
|
||||||
|
return results.items.filter((item) => detectAnomaly(item) !== "");
|
||||||
|
}, [results]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function loadRun() {
|
||||||
|
const res = await fetch(`/api/runs/${processType}/${runId}`);
|
||||||
|
const payload = (await res.json()) as RunDetail;
|
||||||
|
if (!cancelled) {
|
||||||
|
setRun(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadRun();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [processType, runId, refreshTick]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function loadResults() {
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
pageSize: String(pageSize),
|
||||||
|
sort: buildSortValue(sort),
|
||||||
|
});
|
||||||
|
if (search) params.set("q", search);
|
||||||
|
if (verdict) params.set("verdict", verdict);
|
||||||
|
if (sellabilityStatus) params.set("sellabilityStatus", sellabilityStatus);
|
||||||
|
if (minConfidence) params.set("minConfidence", minConfidence);
|
||||||
|
if (maxConfidence) params.set("maxConfidence", maxConfidence);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/runs/${processType}/${runId}/results?${params.toString()}`);
|
||||||
|
const payload = (await res.json()) as ResultsResponse;
|
||||||
|
if (!cancelled) {
|
||||||
|
setResults(payload);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadResults();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [processType, runId, search, verdict, sellabilityStatus, minConfidence, maxConfidence, page, pageSize, sort, refreshTick]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
setRefreshTick((tick) => tick + 1);
|
||||||
|
}, 4000);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [processType, runId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<button className="back" onClick={onBack}>Back</button>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2>Run Detail</h2>
|
||||||
|
<div className="meta-grid" style={{ marginTop: 12 }}>
|
||||||
|
<div className="meta"><strong>Process:</strong> {processType}</div>
|
||||||
|
<div className="meta"><strong>Run ID:</strong> {runId}</div>
|
||||||
|
<div className="meta"><strong>Status:</strong> {run ? <span className={statusBadgeClass(run.status)}>{run.status}</span> : "-"}</div>
|
||||||
|
<div className="meta"><strong>Timestamp:</strong> {run ? formatDate(run.timestamp) : "-"}</div>
|
||||||
|
<div className="meta"><strong>Job:</strong> {run?.jobType ?? "-"}</div>
|
||||||
|
<div className="meta"><strong>Source:</strong> {run?.source ?? "-"}</div>
|
||||||
|
<div className="meta"><strong>Total:</strong> {formatNumber(run?.summary.totalProducts)}</div>
|
||||||
|
<div className="meta"><strong>FBA/FBM/SKIP:</strong> {formatNumber(run?.summary.fbaCount)}/{formatNumber(run?.summary.fbmCount)}/{formatNumber(run?.summary.skipCount)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<TinyBar fba={run?.summary.fbaCount ?? 0} fbm={run?.summary.fbmCount ?? 0} skip={run?.summary.skipCount ?? 0} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="toolbar">
|
||||||
|
<input value={search} onChange={(e) => { setPage(1); setSearch(e.target.value); }} placeholder="Search ASIN/name/brand/category/reason" />
|
||||||
|
<select value={verdict} onChange={(e) => { setPage(1); setVerdict(e.target.value); }}>
|
||||||
|
<option value="">All verdicts</option>
|
||||||
|
<option value="FBA">FBA</option>
|
||||||
|
<option value="FBM">FBM</option>
|
||||||
|
<option value="SKIP">SKIP</option>
|
||||||
|
</select>
|
||||||
|
<select value={sellabilityStatus} onChange={(e) => { setPage(1); setSellabilityStatus(e.target.value); }}>
|
||||||
|
<option value="">All sellability</option>
|
||||||
|
<option value="available">available</option>
|
||||||
|
<option value="restricted">restricted</option>
|
||||||
|
<option value="not_available">not_available</option>
|
||||||
|
<option value="unknown">unknown</option>
|
||||||
|
</select>
|
||||||
|
<input value={minConfidence} onChange={(e) => { setPage(1); setMinConfidence(e.target.value); }} placeholder="Min confidence" />
|
||||||
|
<input value={maxConfidence} onChange={(e) => { setPage(1); setMaxConfidence(e.target.value); }} placeholder="Max confidence" />
|
||||||
|
<select value={String(pageSize)} onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)); }}>
|
||||||
|
<option value="25">25 / page</option>
|
||||||
|
<option value="50">50 / page</option>
|
||||||
|
<option value="100">100 / page</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<a
|
||||||
|
href={`/api/runs/${processType}/${runId}/export.csv?q=${encodeURIComponent(search)}&verdict=${encodeURIComponent(verdict)}&sellabilityStatus=${encodeURIComponent(sellabilityStatus)}&minConfidence=${encodeURIComponent(minConfidence)}&maxConfidence=${encodeURIComponent(maxConfidence)}&sort=${encodeURIComponent(buildSortValue(sort))}`}
|
||||||
|
>
|
||||||
|
<button>Export filtered CSV</button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>Anomalies in current page</h3>
|
||||||
|
{anomalies.length === 0 ? (
|
||||||
|
<div className="meta" style={{ marginTop: 8 }}>No anomalies detected with current heuristic.</div>
|
||||||
|
) : (
|
||||||
|
<div className="anomaly-list" style={{ marginTop: 8 }}>
|
||||||
|
{anomalies.slice(0, 8).map((item) => (
|
||||||
|
<div key={`anom-${item.asin}-${item.fetched_at}`} className="anomaly-item">
|
||||||
|
<a href={`http://amazon.com/dp/${item.asin}`} target="_blank" rel="noreferrer">{item.asin}</a>
|
||||||
|
<span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span>
|
||||||
|
<span>{detectAnomaly(item)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><button onClick={() => setSort(nextSort(sort, "asin"))}>ASIN</button></th>
|
||||||
|
<th><button onClick={() => setSort(nextSort(sort, "verdict"))}>Verdict</button></th>
|
||||||
|
<th><button onClick={() => setSort(nextSort(sort, "monthly_sold"))}>Monthly Sold</button></th>
|
||||||
|
<th><button onClick={() => setSort(nextSort(sort, "seller_count"))}>Sellers</button></th>
|
||||||
|
<th><button onClick={() => setSort(nextSort(sort, "sales_rank"))}>Sales Rank</button></th>
|
||||||
|
<th><button onClick={() => setSort(nextSort(sort, "current_price"))}>Current Price</button></th>
|
||||||
|
<th><button onClick={() => setSort(nextSort(sort, "product_name"))}>Product</button></th>
|
||||||
|
<th><button onClick={() => setSort(nextSort(sort, "brand"))}>Brand</button></th>
|
||||||
|
<th><button onClick={() => setSort(nextSort(sort, "category"))}>Category</button></th>
|
||||||
|
<th><button onClick={() => setSort(nextSort(sort, "avg_price_90d"))}>Avg 90d</button></th>
|
||||||
|
<th><button onClick={() => setSort(nextSort(sort, "confidence"))}>Confidence</button></th>
|
||||||
|
<th className="reason-col"><button onClick={() => setSort(nextSort(sort, "reasoning"))}>Reasoning</button></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={12}>Loading...</td></tr>
|
||||||
|
) : results?.items.length ? (
|
||||||
|
results.items.map((item) => (
|
||||||
|
<tr key={`${item.asin}-${item.fetched_at}`}>
|
||||||
|
<td><a href={`http://amazon.com/dp/${item.asin}`} target="_blank" rel="noreferrer">{item.asin}</a></td>
|
||||||
|
<td><span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span></td>
|
||||||
|
<td>{formatNumber(item.monthly_sold)}</td>
|
||||||
|
<td>{formatNumber(item.seller_count)}</td>
|
||||||
|
<td>{formatNumber(item.sales_rank)}</td>
|
||||||
|
<td>{formatCurrency(item.current_price)}</td>
|
||||||
|
<td title={item.reasoning || undefined}>{item.product_name || "-"}</td>
|
||||||
|
<td>{item.brand || "-"}</td>
|
||||||
|
<td>{item.category || "-"}</td>
|
||||||
|
<td>{formatCurrency(item.avg_price_90d)}</td>
|
||||||
|
<td>{formatNumber(item.confidence)}</td>
|
||||||
|
<td className="reason-col" title={item.reasoning || undefined}>{item.reasoning || "-"}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr><td colSpan={12}>No results found</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="pager">
|
||||||
|
<div>Showing {results?.items.length ?? 0} of {results?.total ?? 0}</div>
|
||||||
|
<div>
|
||||||
|
<button disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</button>
|
||||||
|
<span style={{ padding: "0 8px" }}>Page {results?.page ?? page} / {results?.totalPages ?? 1}</span>
|
||||||
|
<button disabled={Boolean(results && page >= results.totalPages)} onClick={() => setPage((p) => p + 1)}>Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () => void }) {
|
||||||
|
const [items, setItems] = useState<ProductListResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [activeVerdict, setActiveVerdict] = useState<VerdictFilter>(verdict);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveVerdict(verdict);
|
||||||
|
setPage(1);
|
||||||
|
}, [verdict]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||||
|
if (search) params.set("q", search);
|
||||||
|
if (activeVerdict) params.set("verdict", activeVerdict);
|
||||||
|
const res = await fetch(`/api/products?${params.toString()}`);
|
||||||
|
const payload = (await res.json()) as ProductListResponse;
|
||||||
|
if (!cancelled) {
|
||||||
|
setItems(payload);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [search, activeVerdict, page, pageSize]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<button className="back" onClick={onBack}>Back</button>
|
||||||
|
<div className="card">
|
||||||
|
<h2>Products</h2>
|
||||||
|
<div className="toolbar" style={{ marginTop: 10 }}>
|
||||||
|
<input value={search} onChange={(e) => { setPage(1); setSearch(e.target.value); }} placeholder="Search ASIN/name/brand/category" />
|
||||||
|
<select value={activeVerdict} onChange={(e) => { setPage(1); setActiveVerdict(e.target.value as VerdictFilter); }}>
|
||||||
|
<option value="">All verdicts</option>
|
||||||
|
<option value="FBA">FBA</option>
|
||||||
|
<option value="FBM">FBM</option>
|
||||||
|
<option value="SKIP">SKIP</option>
|
||||||
|
</select>
|
||||||
|
<select value={String(pageSize)} onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)); }}>
|
||||||
|
<option value="25">25 / page</option>
|
||||||
|
<option value="50">50 / page</option>
|
||||||
|
<option value="100">100 / page</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ASIN</th>
|
||||||
|
<th>Verdict</th>
|
||||||
|
<th>Product</th>
|
||||||
|
<th>Brand</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Confidence</th>
|
||||||
|
<th>Process</th>
|
||||||
|
<th>Run ID</th>
|
||||||
|
<th>Fetched</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={9}>Loading...</td></tr>
|
||||||
|
) : items?.items.length ? (
|
||||||
|
items.items.map((item) => (
|
||||||
|
<tr key={`${item.processType}-${item.runId}-${item.asin}-${item.fetched_at}`}>
|
||||||
|
<td><a href={`http://amazon.com/dp/${item.asin}`} target="_blank" rel="noreferrer">{item.asin}</a></td>
|
||||||
|
<td><span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span></td>
|
||||||
|
<td>{item.product_name || "-"}</td>
|
||||||
|
<td>{item.brand || "-"}</td>
|
||||||
|
<td>{item.category || "-"}</td>
|
||||||
|
<td>{formatNumber(item.confidence)}</td>
|
||||||
|
<td>{item.processType}</td>
|
||||||
|
<td>{item.runId}</td>
|
||||||
|
<td>{formatDate(item.fetched_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr><td colSpan={9}>No products found</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="pager">
|
||||||
|
<div>Showing {items?.items.length ?? 0} of {items?.total ?? 0}</div>
|
||||||
|
<div>
|
||||||
|
<button disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</button>
|
||||||
|
<span style={{ padding: "0 8px" }}>Page {items?.page ?? page} / {items?.totalPages ?? 1}</span>
|
||||||
|
<button disabled={Boolean(items && page >= items.totalPages)} onClick={() => setPage((p) => p + 1)}>Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppRoute =
|
||||||
|
| { kind: "dashboard" }
|
||||||
|
| { kind: "run"; processType: ProcessType; runId: number }
|
||||||
|
| { kind: "products"; verdict: VerdictFilter };
|
||||||
|
|
||||||
|
function parseRoute(pathname: string, search: string): AppRoute {
|
||||||
|
const runMatch = pathname.match(/^\/runs\/(lead_analysis|category_analysis)\/(\d+)$/);
|
||||||
|
if (runMatch) {
|
||||||
|
return { kind: "run", processType: runMatch[1] as ProcessType, runId: Number(runMatch[2]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === "/products") {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
const verdictParam = params.get("verdict");
|
||||||
|
const verdict = verdictParam === "FBA" || verdictParam === "FBM" || verdictParam === "SKIP" ? verdictParam : "";
|
||||||
|
return { kind: "products", verdict };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: "dashboard" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [route, setRoute] = useState<AppRoute>(() => parseRoute(window.location.pathname, window.location.search));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPopState = () => setRoute(parseRoute(window.location.pathname, window.location.search));
|
||||||
|
window.addEventListener("popstate", onPopState);
|
||||||
|
return () => window.removeEventListener("popstate", onPopState);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function openRun(run: Run) {
|
||||||
|
const path = `/runs/${run.processType}/${run.runId}`;
|
||||||
|
history.pushState({}, "", path);
|
||||||
|
setRoute({ kind: "run", processType: run.processType, runId: run.runId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function openProducts(verdict: VerdictFilter) {
|
||||||
|
const path = verdict ? `/products?verdict=${encodeURIComponent(verdict)}` : "/products";
|
||||||
|
history.pushState({}, "", path);
|
||||||
|
setRoute({ kind: "products", verdict });
|
||||||
|
}
|
||||||
|
|
||||||
|
function backToDashboard() {
|
||||||
|
history.pushState({}, "", "/");
|
||||||
|
setRoute({ kind: "dashboard" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.kind === "run") {
|
||||||
|
return <RunDetails processType={route.processType} runId={route.runId} onBack={backToDashboard} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.kind === "products") {
|
||||||
|
return <ProductList verdict={route.verdict} onBack={backToDashboard} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Dashboard onOpenRun={openRun} onOpenProducts={openProducts} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
if (!root) {
|
||||||
|
throw new Error("Root element not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(root).render(<App />);
|
||||||
13
src/web/index.html
Normal file
13
src/web/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Run Results Viewer</title>
|
||||||
|
<link rel="stylesheet" href="./styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./frontend.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
258
src/web/styles.css
Normal file
258
src/web/styles.css
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
background: #f6f7f8;
|
||||||
|
color: #121418;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e7e8ea;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar input,
|
||||||
|
.toolbar select,
|
||||||
|
button {
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #d8dce0;
|
||||||
|
background: #fff;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #eceef0;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 980px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #f0f1f3;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-col {
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 420px;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: #fafafb;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
th button {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-fba {
|
||||||
|
background: #dff7e6;
|
||||||
|
color: #1e7a39;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-fbm {
|
||||||
|
background: #e6f0ff;
|
||||||
|
color: #245fce;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-skip {
|
||||||
|
background: #ffe9e7;
|
||||||
|
color: #b5382a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-ok {
|
||||||
|
background: #dff7e6;
|
||||||
|
color: #1e7a39;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-failed {
|
||||||
|
background: #ffe9e7;
|
||||||
|
color: #b5382a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-empty {
|
||||||
|
background: #f2f4f7;
|
||||||
|
color: #5f6b7a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e7e8ea;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric[role="button"]:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.metric .label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #677282;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric .value {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #445060;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiny-bar {
|
||||||
|
width: 100px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eef1f4;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiny-fba {
|
||||||
|
background: #1e7a39;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiny-fbm {
|
||||||
|
background: #245fce;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiny-skip {
|
||||||
|
background: #b5382a;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spark-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spark-item {
|
||||||
|
border: 1px solid #eceef0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spark-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #677282;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anomaly-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anomaly-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid #eceef0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.toolbar {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics,
|
||||||
|
.meta-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.spark-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// Environment setup & latest features
|
// Environment setup & latest features
|
||||||
"lib": ["ESNext"],
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "Preserve",
|
"module": "Preserve",
|
||||||
|
"types": ["bun-types", "node"],
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user