- Introduced `amazonIsSeller` and `amazonBuyboxSharePct90d` fields in KeepaData type. - Updated database schema and queries to store Amazon seller status and buy box share percentage. - Enhanced product analysis results with new metrics from Keepa API. - Modified frontend components to display Amazon seller status and buy box share percentage. - Implemented reanalysis functionality for products to refresh Amazon-related metrics.
265 lines
9.0 KiB
TypeScript
265 lines
9.0 KiB
TypeScript
import { getDb } from "./database.ts";
|
|
import type { AnalysisResult } from "./types.ts";
|
|
|
|
function buildRow(r: AnalysisResult) {
|
|
const price =
|
|
r.product.keepa?.currentPrice ??
|
|
r.product.record.sellingPriceFromSheet ??
|
|
r.product.spApi.estimatedSalePrice;
|
|
const rank = r.product.keepa?.salesRank ?? r.product.record.amazonRank;
|
|
const canSellStatus =
|
|
r.product.spApi.canSell == null
|
|
? "unknown"
|
|
: r.product.spApi.canSell
|
|
? "yes"
|
|
: "no";
|
|
|
|
return {
|
|
ASIN: r.product.record.asin,
|
|
Name: r.product.record.name,
|
|
Brand: r.product.record.brand ?? "",
|
|
Category:
|
|
r.product.record.category ??
|
|
r.product.keepa?.categoryTree?.join(" > ") ??
|
|
"",
|
|
"Unit Cost": r.product.record.unitCost,
|
|
"Current Price": price ?? "",
|
|
"Avg Price 90d": r.product.keepa?.avgPrice90 ?? "",
|
|
"Avg Price 90d (sheet)": r.product.record.avgPrice90FromSheet ?? "",
|
|
"Selling Price (sheet)": r.product.record.sellingPriceFromSheet ?? "",
|
|
"Sales Rank": rank ?? "",
|
|
"Rank Avg 90d": r.product.keepa?.salesRankAvg90 ?? "",
|
|
Sellers: r.product.keepa?.sellerCount ?? "",
|
|
"Amazon Is Seller": r.product.keepa?.amazonIsSeller ?? null,
|
|
"Amazon Buy Box Share 90d %":
|
|
r.product.keepa?.amazonBuyboxSharePct90d ?? "",
|
|
"Monthly Sold": r.product.keepa?.monthlySold ?? "",
|
|
"Rank Drops 30d": r.product.keepa?.salesRankDrops30 ?? "",
|
|
"Rank Drops 90d": r.product.keepa?.salesRankDrops90 ?? "",
|
|
"FBA Net (sheet)": r.product.record.fbaNet ?? "",
|
|
"Gross Profit $": r.product.record.grossProfit ?? "",
|
|
"Gross Profit %": r.product.record.grossProfitPct ?? "",
|
|
"Net Profit (sheet)": r.product.record.netProfitFromSheet ?? "",
|
|
"ROI (sheet)": r.product.record.roiFromSheet ?? "",
|
|
MOQ: r.product.record.moq ?? "",
|
|
"MOQ Cost": r.product.record.moqCost ?? "",
|
|
"Qty Available": r.product.record.totalQtyAvail ?? "",
|
|
Supplier: r.product.record.supplier ?? "",
|
|
"Source URL": r.product.record.sourceUrl ?? "",
|
|
"ASIN Link": r.product.record.asinLink ?? "",
|
|
"Promo/Coupon Code": r.product.record.promoCouponCode ?? "",
|
|
Notes: r.product.record.notes ?? "",
|
|
"Lead Date": r.product.record.leadDate ?? "",
|
|
"FBA Fee": r.product.spApi.fbaFee,
|
|
"FBM Fee": r.product.spApi.fbmFee,
|
|
"Referral %": r.product.spApi.referralFeePercent,
|
|
"Can Sell": canSellStatus,
|
|
Sellability: r.product.spApi.sellabilityStatus,
|
|
"Sellability Reason": r.product.spApi.sellabilityReason ?? "",
|
|
Verdict: r.verdict.verdict,
|
|
Confidence: r.verdict.confidence,
|
|
Reasoning: r.verdict.reasoning,
|
|
};
|
|
}
|
|
|
|
export function writeResultsToDb(
|
|
results: AnalysisResult[],
|
|
dbPath: string,
|
|
inputFile: string,
|
|
outputFile: string | undefined,
|
|
): void {
|
|
const database = getDb(dbPath);
|
|
|
|
const timestamp = new Date().toISOString();
|
|
const fbaCount = results.filter((r) => r.verdict.verdict === "FBA").length;
|
|
const fbmCount = results.filter((r) => r.verdict.verdict === "FBM").length;
|
|
const skipCount = results.filter((r) => r.verdict.verdict === "SKIP").length;
|
|
|
|
const insertRun = database.prepare(
|
|
`INSERT INTO runs (
|
|
timestamp,
|
|
input_file,
|
|
output_file,
|
|
total_products,
|
|
fba_count,
|
|
fbm_count,
|
|
skip_count
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
);
|
|
const runInfo = insertRun.run(
|
|
timestamp,
|
|
inputFile,
|
|
outputFile ?? null,
|
|
results.length,
|
|
fbaCount,
|
|
fbmCount,
|
|
skipCount,
|
|
);
|
|
const runId =
|
|
(runInfo.changes as number) > 0
|
|
? (runInfo.lastInsertRowid as number)
|
|
: null;
|
|
|
|
if (runId === null) {
|
|
console.error("Failed to insert run record into SQLite.");
|
|
return;
|
|
}
|
|
|
|
const insertResult = database.prepare(
|
|
`INSERT INTO results (
|
|
run_id, asin, product_name, brand, category, unit_cost, current_price,
|
|
avg_price_90d, avg_price_90d_sheet, selling_price_sheet, sales_rank, rank_avg_90d,
|
|
sellers, amazon_is_seller, amazon_buybox_share_pct_90d,
|
|
monthly_sold, rank_drops_30d, rank_drops_90d, fba_net_sheet,
|
|
gross_profit_dollar, gross_profit_pct, net_profit_sheet, roi_sheet, moq, moq_cost,
|
|
qty_available, supplier, source_url, asin_link, promo_coupon_code, notes, lead_date,
|
|
fba_fee, fbm_fee, referral_percent, can_sell, sellability_status, sellability_reason,
|
|
verdict, confidence, reasoning, fetched_at
|
|
) VALUES (
|
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
)`,
|
|
);
|
|
|
|
database.transaction(() => {
|
|
for (const r of results) {
|
|
const row = buildRow(r);
|
|
insertResult.run(
|
|
runId,
|
|
row.ASIN,
|
|
row.Name,
|
|
row.Brand,
|
|
row.Category,
|
|
row["Unit Cost"] ?? null,
|
|
row["Current Price"] ?? null,
|
|
row["Avg Price 90d"] ?? null,
|
|
row["Avg Price 90d (sheet)"] ?? null,
|
|
row["Selling Price (sheet)"] ?? null,
|
|
row["Sales Rank"] ?? null,
|
|
row["Rank Avg 90d"] ?? null,
|
|
row.Sellers ?? null,
|
|
row["Amazon Is Seller"] == null
|
|
? null
|
|
: row["Amazon Is Seller"]
|
|
? 1
|
|
: 0,
|
|
row["Amazon Buy Box Share 90d %"] ?? null,
|
|
row["Monthly Sold"] ?? null,
|
|
row["Rank Drops 30d"] ?? null,
|
|
row["Rank Drops 90d"] ?? null,
|
|
row["FBA Net (sheet)"] ?? null,
|
|
row["Gross Profit $"] ?? null,
|
|
row["Gross Profit %"] ?? null,
|
|
row["Net Profit (sheet)"] ?? null,
|
|
row["ROI (sheet)"] ?? null,
|
|
row.MOQ ?? null,
|
|
row["MOQ Cost"] ?? null,
|
|
row["Qty Available"] ?? null,
|
|
row.Supplier ?? null,
|
|
row["Source URL"] ?? null,
|
|
row["ASIN Link"] ?? null,
|
|
row["Promo/Coupon Code"] ?? null,
|
|
row.Notes ?? null,
|
|
row["Lead Date"] ?? null,
|
|
row["FBA Fee"] ?? null,
|
|
row["FBM Fee"] ?? null,
|
|
row["Referral %"] ?? null,
|
|
row["Can Sell"],
|
|
row.Sellability,
|
|
row["Sellability Reason"] ?? null,
|
|
row.Verdict,
|
|
row.Confidence ?? null,
|
|
row.Reasoning,
|
|
r.product.fetchedAt,
|
|
);
|
|
}
|
|
})();
|
|
console.log(`Results written to SQLite database for run_id: ${runId}`);
|
|
}
|
|
export function printResults(results: AnalysisResult[]): void {
|
|
const rows = results
|
|
.filter((r) => r.verdict.verdict === "FBA" || r.verdict.verdict === "FBM")
|
|
.map((r) => {
|
|
const sellingPrice =
|
|
r.product.keepa?.currentPrice ??
|
|
r.product.record.sellingPriceFromSheet ??
|
|
r.product.spApi.estimatedSalePrice;
|
|
const referralFee =
|
|
sellingPrice != null
|
|
? sellingPrice * (r.product.spApi.referralFeePercent / 100)
|
|
: null;
|
|
const fulfillmentFee =
|
|
r.verdict.verdict === "FBA"
|
|
? r.product.spApi.fbaFee
|
|
: r.product.spApi.fbmFee;
|
|
const netProfit =
|
|
sellingPrice != null
|
|
? Math.round(
|
|
(sellingPrice -
|
|
r.product.record.unitCost -
|
|
fulfillmentFee -
|
|
(referralFee ?? 0)) *
|
|
100,
|
|
) / 100
|
|
: "";
|
|
|
|
return {
|
|
ASIN: r.product.record.asin,
|
|
Name: r.product.record.name.slice(0, 40),
|
|
Category: String(
|
|
r.product.record.category ??
|
|
r.product.keepa?.categoryTree?.join(" > ") ??
|
|
"",
|
|
).slice(0, 20),
|
|
"Unit Cost": r.product.record.unitCost,
|
|
"Selling Price": sellingPrice ?? "",
|
|
"Net Profit": netProfit,
|
|
"Monthly Sold": r.product.keepa?.monthlySold ?? "",
|
|
"Sold 90 Day": r.product.keepa?.salesRankDrops90 ?? "",
|
|
"Can Sell":
|
|
r.product.spApi.canSell == null
|
|
? "unknown"
|
|
: r.product.spApi.canSell
|
|
? "yes"
|
|
: "no",
|
|
Sellability: r.product.spApi.sellabilityStatus,
|
|
"Sellability Reason": String(
|
|
r.product.spApi.sellabilityReason ?? "",
|
|
).slice(0, 60),
|
|
Confidence: r.verdict.confidence,
|
|
Reasoning: r.verdict.reasoning.slice(0, 60),
|
|
};
|
|
});
|
|
|
|
console.log("\n=== Analysis Results ===\n");
|
|
if (rows.length === 0) {
|
|
console.log("No FBA/FBM leads found.");
|
|
} else {
|
|
console.table(rows);
|
|
}
|
|
|
|
const summary = {
|
|
FBA: results.filter((r) => r.verdict.verdict === "FBA").length,
|
|
FBM: results.filter((r) => r.verdict.verdict === "FBM").length,
|
|
SKIP: results.filter((r) => r.verdict.verdict === "SKIP").length,
|
|
Available: results.filter(
|
|
(r) => r.product.spApi.sellabilityStatus === "available",
|
|
).length,
|
|
Restricted: results.filter(
|
|
(r) => r.product.spApi.sellabilityStatus === "restricted",
|
|
).length,
|
|
NotAvailable: results.filter(
|
|
(r) => r.product.spApi.sellabilityStatus === "not_available",
|
|
).length,
|
|
Unknown: results.filter(
|
|
(r) => r.product.spApi.sellabilityStatus === "unknown",
|
|
).length,
|
|
};
|
|
console.log(
|
|
`\nSummary: ${summary.FBA} FBA | ${summary.FBM} FBM | ${summary.SKIP} SKIP out of ${results.length} products\n`,
|
|
);
|
|
console.log(
|
|
`Sellability: ${summary.Available} available | ${summary.Restricted} restricted | ${summary.NotAvailable} not_available | ${summary.Unknown} unknown\n`,
|
|
);
|
|
}
|