Price Report
All checks were successful
Deploy to Server / deploy (push) Successful in 31s

This commit is contained in:
2025-10-25 18:56:45 -04:00
parent 2a60504cea
commit cd3206da1b
5 changed files with 256 additions and 2 deletions

View File

@@ -19,6 +19,7 @@
"type": "module",
"dependencies": {
"cors": "^2.8.5",
"cheerio": "^1.0.0",
"dotenv": "^17.2.3",
"exceljs": "^4.4.0",
"express": "^5.1.0",
@@ -29,6 +30,7 @@
"pdfkit": "^0.17.2"
},
"devDependencies": {
"@types/cheerio": "^0.22.35",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.4",
"@types/express-fileupload": "^1.5.1",

View File

@@ -36,6 +36,18 @@ async function ensureSchema() {
// add image_data and image_mime if missing
await pool.query(`alter table items add column if not exists image_data longblob null` as any);
await pool.query(`alter table items add column if not exists image_mime varchar(100) null` as any);
// price cache per SKU
const priceSql = `
create table if not exists sku_prices (
sku varchar(64) not null primary key,
price decimal(10,2) null,
currency varchar(3) not null default 'USD',
source varchar(32) not null default 'ebay',
updated_at timestamp null
) engine=InnoDB;
`;
await pool.query(priceSql);
}
let schemaEnsured: Promise<void> | null = null;
@@ -126,6 +138,29 @@ export const db = {
async deleteAll() {
await getReady();
await pool.query('truncate table items');
},
async listDistinctSkus() {
await getReady();
const [rows] = await pool.query(`select distinct sku from items where sku is not null and sku <> ''`);
return (rows as any[]).map(r => r.sku as string);
},
async upsertSkuPrice(sku: string, price: number | null, currency: string = 'USD', source: string = 'ebay') {
await getReady();
await pool.query(
`insert into sku_prices (sku, price, currency, source, updated_at)
values (?,?,?,?, current_timestamp)
on duplicate key update price = values(price), currency = values(currency), source = values(source), updated_at = current_timestamp`,
[sku, price, currency, source]
);
},
async getSkuPriceMap() {
await getReady();
const [rows] = await pool.query(`select sku, price, currency, updated_at as updatedAt from sku_prices`);
const map: Record<string, { price: number | null, currency: string, updatedAt: string | null }> = {};
for (const r of rows as any[]) {
map[r.sku] = { price: r.price !== null ? Number(r.price) : null, currency: r.currency, updatedAt: r.updatedAt };
}
return map;
}
};

View File

@@ -29,9 +29,13 @@
<header>
<h2>Train-ID</h2>
<p>Upload a photo of a model train to extract details and save.</p>
<div class="actions" style="margin-top:8px;">
<button id="tabItemsBtn" class="secondary">Items</button>
<button id="tabPricesBtn" class="secondary">Price Report</button>
</div>
</header>
<section class="card">
<section id="tab-items" class="card">
<label>Photo</label>
<input id="file" type="file" accept="image/*" />
<div class="row" style="align-items:flex-start; margin-top: 12px;">
@@ -43,7 +47,7 @@
<div id="status"></div>
</section>
<section class="card">
<section id="items-section" class="card">
<h3>Items</h3>
<div class="actions">
<input id="search" type="text" placeholder="Search model or SKU" />
@@ -71,6 +75,28 @@
</table>
</section>
<section id="tab-prices" class="card" style="display:none;">
<h3>Price Report</h3>
<div class="actions">
<button id="updatePrices">Update Prices</button>
</div>
<div style="display:flex; gap:16px; align-items:flex-start; flex-wrap:wrap;">
<canvas id="pricePie" width="360" height="360" style="border:1px solid #eee; border-radius:8px;"></canvas>
<div style="flex:1; min-width:280px;">
<table id="skuPrices">
<thead>
<tr>
<th>SKU</th>
<th>Price (USD)</th>
<th class="hide-sm">Updated</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</section>
<script type="module">
const $ = (sel) => document.querySelector(sel);
const fileInput = $('#file');
@@ -79,6 +105,27 @@
const tableBody = document.querySelector('#items tbody');
const searchInput = document.querySelector('#search');
const analyzeBtn = document.querySelector('#analyze');
const tabItemsBtn = $('#tabItemsBtn');
const tabPricesBtn = $('#tabPricesBtn');
const tabItems = $('#tab-items');
const itemsSection = $('#items-section');
const tabPrices = $('#tab-prices');
const skuTbody = document.querySelector('#skuPrices tbody');
const updatePricesBtn = $('#updatePrices');
const pricePie = /** @type {HTMLCanvasElement} */ (document.getElementById('pricePie'));
function showTab(tab) {
if (tab === 'items') {
tabItems.style.display = '';
itemsSection.style.display = '';
tabPrices.style.display = 'none';
} else {
tabItems.style.display = 'none';
itemsSection.style.display = 'none';
tabPrices.style.display = '';
loadPriceReport();
}
}
fileInput.addEventListener('change', () => {
const f = fileInput.files?.[0];
@@ -119,6 +166,67 @@
});
}
function drawPie(items) {
const ctx = pricePie.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, pricePie.width, pricePie.height);
const total = items.reduce((sum, it) => sum + (it.totalValue || 0), 0);
if (total <= 0) {
ctx.fillStyle = '#666';
ctx.fillText('No priced items yet', 100, 180);
return;
}
let start = -Math.PI / 2;
const cx = pricePie.width / 2, cy = pricePie.height / 2, r = Math.min(cx, cy) - 8;
items.forEach((it, i) => {
const val = it.totalValue || 0;
if (val <= 0) return;
const angle = (val / total) * Math.PI * 2;
const end = start + angle;
const hue = (i * 47) % 360;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, r, start, end);
ctx.closePath();
ctx.fillStyle = `hsl(${hue} 70% 55%)`;
ctx.fill();
start = end;
});
}
async function loadPriceReport() {
const res = await fetch('/api/price-report');
const data = await res.json();
// table of skus
skuTbody.innerHTML = '';
for (const s of data.skus) {
const tr = document.createElement('tr');
const title = s.description || '';
tr.innerHTML = `
<td title="${title.replaceAll('"', '\\"')}">${s.sku}</td>
<td>${s.price !== null ? `$${s.price.toFixed(2)}` : ''}</td>
<td class="hide-sm">${s.updatedAt ? new Date(s.updatedAt).toLocaleString() : ''}</td>
`;
skuTbody.appendChild(tr);
}
// pie of item values
const pricedItems = data.items.filter((it) => it.totalValue > 0);
drawPie(pricedItems);
}
updatePricesBtn.addEventListener('click', async () => {
const prev = updatePricesBtn.textContent;
updatePricesBtn.textContent = 'Updating...';
updatePricesBtn.disabled = true;
try {
await fetch('/api/prices/update', { method: 'POST' });
await loadPriceReport();
} finally {
updatePricesBtn.disabled = false;
updatePricesBtn.textContent = prev;
}
});
analyzeBtn.addEventListener('click', async () => {
const f = fileInput.files?.[0];
if (!f) { alert('Choose an image first'); return; }
@@ -155,6 +263,9 @@
document.querySelector('#clearSearch').addEventListener('click', () => { searchInput.value=''; refresh(); });
searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') refresh(); });
tabItemsBtn.addEventListener('click', () => showTab('items'));
tabPricesBtn.addEventListener('click', () => showTab('prices'));
refresh();
</script>
</body>

View File

@@ -3,6 +3,7 @@ import path from 'path';
import fs from 'fs/promises';
import { analyzeImageToMetadata } from '../services/openaiVision.js';
import { db } from '../db/client.js';
import { fetchMedianSoldPriceUSDForSku } from '../services/ebay.js';
import { generatePdfForItem } from '../services/pdf.js';
export const router = Router();
@@ -172,4 +173,71 @@ router.delete('/items', async (_req, res) => {
}
});
// Update cached prices for all distinct SKUs (on-demand only)
router.post('/prices/update', async (_req, res) => {
try {
const skus = await db.listDistinctSkus();
const out: Record<string, number | null> = {};
for (const sku of skus) {
try {
const price = await fetchMedianSoldPriceUSDForSku(sku);
await db.upsertSkuPrice(sku, price, 'USD', 'ebay');
out[sku] = price;
// small delay to be gentle
await new Promise(r => setTimeout(r, 400));
} catch (e) {
console.error('Failed to update price for', sku, e);
out[sku] = null;
}
}
res.json({ updated: out });
} catch (err: any) {
console.error(err);
res.status(500).json({ error: err.message || 'Price update failed' });
}
});
// Price report data: items with value slices and sku price list
router.get('/price-report', async (_req, res) => {
try {
const [items, priceMap] = await Promise.all([
db.listItems(),
db.getSkuPriceMap()
]);
const itemValues = items.map(it => {
const p = it.sku ? priceMap[it.sku]?.price ?? null : null;
const unitPrice = p !== null ? Number(p) : null;
const totalValue = unitPrice !== null ? Number((unitPrice * it.quantity).toFixed(2)) : 0;
return {
id: it.id,
sku: it.sku || null,
description: it.description,
quantity: it.quantity,
unitPrice,
totalValue
};
});
// Build sku list with one description (first encountered)
const skuListMap: Record<string, { sku: string, price: number | null, currency: string, updatedAt: string | null, description: string } > = {};
for (const it of items) {
if (!it.sku) continue;
if (!skuListMap[it.sku]) {
const p = priceMap[it.sku];
skuListMap[it.sku] = {
sku: it.sku,
price: p ? (p.price !== null ? Number(p.price) : null) : null,
currency: p ? p.currency : 'USD',
updatedAt: p ? p.updatedAt : null,
description: it.description
};
}
}
const skuList = Object.values(skuListMap).sort((a, b) => (a.sku || '').localeCompare(b.sku || ''));
res.json({ items: itemValues, skus: skuList });
} catch (err: any) {
console.error(err);
res.status(500).json({ error: err.message || 'Failed to build price report' });
}
});

38
src/services/ebay.ts Normal file
View File

@@ -0,0 +1,38 @@
import * as cheerio from 'cheerio';
export async function fetchMedianSoldPriceUSDForSku(sku: string): Promise<number | null> {
const query = encodeURIComponent(`"${sku}"`);
const url = `https://www.ebay.com/sch/i.html?_nkw=${query}&LH_Sold=1&LH_Complete=1&rt=nc`;
const res = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
} as any
} as any);
if (!res.ok) return null;
const html = await res.text();
const $ = cheerio.load(html);
const prices: number[] = [];
$('li.s-item').each((_i, el) => {
const priceText = $(el).find('.s-item__price').first().text().trim();
if (!priceText) return;
// Accept only USD (has $)
if (!priceText.includes('$')) return;
// Remove ranges like "$10.00 to $20.00"
const single = priceText.split(' to ')[0];
const num = single.replace(/[^0-9.]/g, '');
if (!num) return;
const value = Number(num);
if (!Number.isFinite(value) || value <= 0) return;
prices.push(value);
});
if (prices.length === 0) return null;
prices.sort((a, b) => a - b);
const mid = Math.floor(prices.length / 2);
if (prices.length % 2 === 0) {
return Number(((prices[mid - 1] + prices[mid]) / 2).toFixed(2));
} else {
return Number(prices[mid].toFixed(2));
}
}