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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user