Refactor supplier analysis and product handling

- Updated `SupplierAnalysisResult` to include a `product` field and modified related tests.
- Refactored `addRowsSheet` to accommodate changes in the product structure.
- Enhanced UPC file analysis to utilize a new `toSupplierInputRecord` function for cleaner record creation.
- Introduced new types for supplier input records and product observations.
- Updated frontend components to handle new product details and analysis history.
- Improved database writing functions to streamline run completion and error handling.
- Added new API endpoints for product details and adjusted routing in the frontend.
This commit is contained in:
Victor Noguera
2026-05-25 12:27:41 -04:00
parent c006d87c54
commit 923ebbaec5
33 changed files with 2536 additions and 4872 deletions

View File

@@ -1,6 +1,7 @@
import { db } from "../db/index.ts";
import { categoryProductResults, runs } from "../db/schema.ts";
import { eq, sql } from "drizzle-orm";
import { persistLlmResults, refreshRunStats } from "../db/persistence.ts";
import { sql } from "drizzle-orm";
import { normalizeAsin } from "../asin.ts";
import { analyzeProducts } from "../integrations/llm.ts";
import { fetchSpApiPricingAndFees } from "../integrations/sp-api.ts";
import type {
@@ -22,6 +23,7 @@ type Args = {
};
type InventoryRow = {
inventoryItemId: number;
asin: string;
productTitle: string | null;
brand: string | null;
@@ -49,8 +51,8 @@ function parseArgs(argv = process.argv.slice(2)): Args {
const useClaude = argv.includes("--claude");
const asins = (readFlagValue(argv, "--asins") ?? "")
.split(",")
.map((asin) => asin.trim().toUpperCase())
.filter(Boolean);
.map((asin) => normalizeAsin(asin))
.filter((asin): asin is string => asin !== null);
if (!Number.isInteger(stalkerRunId) || stalkerRunId <= 0) {
throw new Error("--stalker-run-id must be a positive integer");
@@ -68,15 +70,7 @@ function wait(ms: number): Promise<void> {
}
function parseCategoryTree(value: string | null): string[] {
if (!value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === "string")
: [];
} catch {
return [];
}
return value ? value.split(" > ").filter(Boolean) : [];
}
function toProductRecord(row: InventoryRow): ProductRecord {
@@ -128,25 +122,29 @@ async function loadInventoryRows(
): Promise<InventoryRow[]> {
if (asins.length === 0) return [];
return db.execute(
sql<InventoryRow>`SELECT DISTINCT ON (asin)
asin,
product_title AS "productTitle",
brand,
category_tree AS "categoryTree",
current_price AS "currentPrice",
avg_price_90d AS "avgPrice90d",
sales_rank AS "salesRank",
monthly_sold AS "monthlySold",
seller_count AS "sellerCount",
amazon_is_seller AS "amazonIsSeller",
can_sell AS "canSell",
sellability_status AS "sellabilityStatus",
sellability_reason AS "sellabilityReason"
FROM stalker_seller_inventory
WHERE run_id = ${stalkerRunId}
AND can_sell = true
AND sellability_status = 'available'
AND asin = ANY(${asins})`,
sql<InventoryRow>`SELECT DISTINCT ON (inventory.product_asin)
inventory.id AS "inventoryItemId",
inventory.product_asin AS asin,
product.name AS "productTitle",
product.brand,
product.category AS "categoryTree",
observation.current_price AS "currentPrice",
observation.avg_price_90d AS "avgPrice90d",
observation.sales_rank AS "salesRank",
observation.monthly_sold AS "monthlySold",
observation.seller_count AS "sellerCount",
observation.amazon_is_seller AS "amazonIsSeller",
observation.can_sell AS "canSell",
observation.sellability_status AS "sellabilityStatus",
observation.sellability_reason AS "sellabilityReason"
FROM stalker_inventory_items inventory
JOIN products product ON product.asin = inventory.product_asin
JOIN product_observations observation ON observation.id = inventory.observation_id
WHERE inventory.run_id = ${stalkerRunId}
AND observation.can_sell = true
AND observation.sellability_status = 'available'
AND inventory.product_asin = ANY(${asins})
ORDER BY inventory.product_asin, observation.fetched_at DESC`,
);
}
@@ -177,111 +175,18 @@ async function buildEnrichedProducts(
async function insertProductAnalysisResults(
runId: number,
results: AnalysisResult[],
sourceInventoryIds: Map<string, number>,
): Promise<void> {
if (results.length === 0) return;
const rows = results.map((result) => {
const keepa = result.product.keepa;
const record = result.product.record;
const spApi = result.product.spApi;
const canSell =
spApi.canSell == null ? "unknown" : spApi.canSell ? "yes" : "no";
return {
asin: record.asin,
runId,
name: record.name,
brand: record.brand ?? null,
category: record.category ?? keepa?.categoryTree.join(" > ") ?? null,
unitCost: record.unitCost ?? null,
currentPrice: keepa?.currentPrice ?? spApi.estimatedSalePrice ?? null,
avgPrice90d: keepa?.avgPrice90 ?? null,
avgPrice90dSheet: record.avgPrice90FromSheet ?? null,
sellingPriceSheet: record.sellingPriceFromSheet ?? null,
salesRank: keepa?.salesRank ?? record.amazonRank ?? null,
salesRankAvg90d: keepa?.salesRankAvg90 ?? null,
sellerCount: keepa?.sellerCount ?? null,
amazonIsSeller: keepa?.amazonIsSeller ?? null,
amazonBuyboxSharePct90d: keepa?.amazonBuyboxSharePct90d ?? null,
monthlySold: keepa?.monthlySold ?? null,
rankDrops30d: keepa?.salesRankDrops30 ?? null,
rankDrops90d: keepa?.salesRankDrops90 ?? null,
fbaFee: spApi.fbaFee ?? null,
fbmFee: spApi.fbmFee ?? null,
referralPercent: spApi.referralFeePercent ?? null,
canSell,
sellabilityStatus: spApi.sellabilityStatus ?? null,
sellabilityReason: spApi.sellabilityReason ?? null,
verdict: result.verdict.verdict,
confidence: result.verdict.confidence ?? 0,
reasoning: result.verdict.reasoning ?? null,
fetchedAt: new Date(result.product.fetchedAt),
};
await persistLlmResults(runId, results, {
source: "stalker_analysis",
metadataSource: "catalog",
sourceInventoryIds,
});
await db
.insert(categoryProductResults)
.values(rows)
.onConflictDoUpdate({
target: categoryProductResults.asin,
set: {
runId: sql`EXCLUDED.run_id`,
name: sql`EXCLUDED.name`,
brand: sql`EXCLUDED.brand`,
category: sql`EXCLUDED.category`,
unitCost: sql`EXCLUDED.unit_cost`,
currentPrice: sql`EXCLUDED.current_price`,
avgPrice90d: sql`EXCLUDED.avg_price_90d`,
avgPrice90dSheet: sql`EXCLUDED.avg_price_90d_sheet`,
sellingPriceSheet: sql`EXCLUDED.selling_price_sheet`,
salesRank: sql`EXCLUDED.sales_rank`,
salesRankAvg90d: sql`EXCLUDED.sales_rank_avg_90d`,
sellerCount: sql`EXCLUDED.seller_count`,
amazonIsSeller: sql`EXCLUDED.amazon_is_seller`,
amazonBuyboxSharePct90d: sql`EXCLUDED.amazon_buybox_share_pct_90d`,
monthlySold: sql`EXCLUDED.monthly_sold`,
rankDrops30d: sql`EXCLUDED.rank_drops_30d`,
rankDrops90d: sql`EXCLUDED.rank_drops_90d`,
fbaFee: sql`EXCLUDED.fba_fee`,
fbmFee: sql`EXCLUDED.fbm_fee`,
referralPercent: sql`EXCLUDED.referral_percent`,
canSell: sql`EXCLUDED.can_sell`,
sellabilityStatus: sql`EXCLUDED.sellability_status`,
sellabilityReason: sql`EXCLUDED.sellability_reason`,
verdict: sql`EXCLUDED.verdict`,
confidence: sql`EXCLUDED.confidence`,
reasoning: sql`EXCLUDED.reasoning`,
fetchedAt: sql`EXCLUDED.fetched_at`,
},
});
}
async function refreshAnalysisRun(runId: number): Promise<void> {
const [stats] = await db.execute(
sql<{
total: string;
fba: string | null;
fbm: string | null;
skip: string | null;
}>`SELECT
COUNT(*) AS total,
SUM(CASE WHEN verdict = 'FBA' THEN 1 ELSE 0 END) AS fba,
SUM(CASE WHEN verdict = 'FBM' THEN 1 ELSE 0 END) AS fbm,
SUM(CASE WHEN verdict = 'SKIP' THEN 1 ELSE 0 END) AS skip
FROM category_product_results
WHERE run_id = ${runId}`,
);
await db
.update(runs)
.set({
topAsinsChecked: Number(stats?.total ?? 0),
availableAsins: Number(stats?.total ?? 0),
fbaCount: Number(stats?.fba ?? 0),
fbmCount: Number(stats?.fbm ?? 0),
skipCount: Number(stats?.skip ?? 0),
})
.where(eq(runs.id, runId));
await refreshRunStats(runId);
}
async function analyzeInBatches(
@@ -344,7 +249,14 @@ async function main(): Promise<void> {
console.log(`Stalker analysis: analyzing ${rows.length} sellable ASIN(s).`);
const enriched = await buildEnrichedProducts(rows);
const results = await analyzeInBatches(enriched, args.useClaude);
await insertProductAnalysisResults(args.analysisRunId, results);
const sourceInventoryIds = new Map(
rows.map((row) => [row.asin, row.inventoryItemId]),
);
await insertProductAnalysisResults(
args.analysisRunId,
results,
sourceInventoryIds,
);
await refreshAnalysisRun(args.analysisRunId);
}