feat: stock confidence badges, multi-vendor price comparison, expanded Cisco TMG + Juniper HCT
Stock API & Dashboard: - /api/stock/summary: vendor_breakdown adds avg_confidence, currencies, conf_per_warehouse/aggregated/boolean - /api/stock/summary: new price_comparison endpoint (multi-vendor SKUs, min/max/avg price) - /api/stock/summary: totals adds multi_vendor_skus count - Dashboard: 6th stat card (Multi-Vendor SKUs), confidence badge column (🟢 L3 / 🟡 L2 / ⚪ L1) - Dashboard: price comparison table with vendor-by-vendor price breakdown - Dashboard: subtitle updated to include QSFPTEK + NADDOD - Dashboard: top sellers link to product URLs Cisco TMG improvements: - Added 5 new platform families: 8000 Series, NCS5500, NCS540, NCS560, NCS1000 - Per-device query strategy: iterates all switch model IDs from family filter instead of getting only 1 switch per family → 58 switches per N9300 run - Graceful error handling per device with rate limiting (1s between requests) Juniper HCT: ran manually → 475 Juniper-brand transceivers seeded
This commit is contained in:
parent
75cbd7cd86
commit
77bad0e020
@ -76,7 +76,11 @@ stockRouter.get("/", async (req: Request, res: Response) => {
|
||||
so.units_sold,
|
||||
so.compatible_brands,
|
||||
so.price_net,
|
||||
so.product_url
|
||||
so.product_url,
|
||||
so.stock_confidence,
|
||||
so.price_currency,
|
||||
so.price_includes_tax,
|
||||
so.stock_vendor_ts
|
||||
FROM (
|
||||
SELECT DISTINCT ON (transceiver_id, source_vendor_id) *
|
||||
FROM stock_observations
|
||||
@ -128,7 +132,7 @@ stockRouter.get("/", async (req: Request, res: Response) => {
|
||||
*/
|
||||
stockRouter.get("/summary", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const [totals, topSellers, vendorBreakdown, recentlyUpdated] = await Promise.all([
|
||||
const [totals, topSellers, vendorBreakdown, recentlyUpdated, priceComparison] = await Promise.all([
|
||||
// Overall totals from latest observations
|
||||
pool.query(`
|
||||
WITH latest AS (
|
||||
@ -137,16 +141,24 @@ stockRouter.get("/summary", async (req: Request, res: Response) => {
|
||||
ORDER BY transceiver_id, source_vendor_id, time DESC
|
||||
)
|
||||
SELECT
|
||||
COUNT(*) AS total_observations,
|
||||
COUNT(*) FILTER (WHERE in_stock = true) AS in_stock_count,
|
||||
SUM(COALESCE(warehouse_de_qty, 0)) AS total_de_qty,
|
||||
SUM(COALESCE(warehouse_global_qty, 0)) AS total_global_qty,
|
||||
SUM(COALESCE(backorder_qty, 0)) AS total_backorder_qty,
|
||||
COUNT(*) FILTER (WHERE warehouse_de_qty > 0) AS products_with_de_stock,
|
||||
COUNT(*) FILTER (WHERE warehouse_global_qty > 0) AS products_with_global_stock,
|
||||
COUNT(*) FILTER (WHERE backorder_qty > 0) AS products_with_backorder,
|
||||
COUNT(DISTINCT transceiver_id) AS unique_transceivers,
|
||||
COUNT(DISTINCT source_vendor_id) AS unique_vendors
|
||||
COUNT(*) AS total_observations,
|
||||
COUNT(*) FILTER (WHERE in_stock = true) AS in_stock_count,
|
||||
SUM(COALESCE(warehouse_de_qty, 0)) AS total_de_qty,
|
||||
SUM(COALESCE(warehouse_global_qty, 0)) AS total_global_qty,
|
||||
SUM(COALESCE(backorder_qty, 0)) AS total_backorder_qty,
|
||||
COUNT(*) FILTER (WHERE warehouse_de_qty > 0) AS products_with_de_stock,
|
||||
COUNT(*) FILTER (WHERE warehouse_global_qty > 0) AS products_with_global_stock,
|
||||
COUNT(*) FILTER (WHERE backorder_qty > 0) AS products_with_backorder,
|
||||
COUNT(DISTINCT transceiver_id) AS unique_transceivers,
|
||||
COUNT(DISTINCT source_vendor_id) AS unique_vendors,
|
||||
-- Data quality breakdown by confidence level
|
||||
COUNT(*) FILTER (WHERE stock_confidence = 1) AS conf_boolean_count,
|
||||
COUNT(*) FILTER (WHERE stock_confidence = 2) AS conf_aggregated_count,
|
||||
COUNT(*) FILTER (WHERE stock_confidence = 3) AS conf_per_warehouse_count,
|
||||
-- Multi-vendor coverage (SKUs tracked by 2+ vendors)
|
||||
COUNT(DISTINCT transceiver_id) FILTER (WHERE transceiver_id IN (
|
||||
SELECT transceiver_id FROM latest GROUP BY transceiver_id HAVING COUNT(DISTINCT source_vendor_id) >= 2
|
||||
)) AS multi_vendor_skus
|
||||
FROM latest
|
||||
`),
|
||||
|
||||
@ -175,7 +187,7 @@ stockRouter.get("/summary", async (req: Request, res: Response) => {
|
||||
LIMIT 20
|
||||
`),
|
||||
|
||||
// Per-vendor stock breakdown
|
||||
// Per-vendor stock breakdown (incl. confidence + currency breakdown)
|
||||
pool.query(`
|
||||
WITH latest AS (
|
||||
SELECT DISTINCT ON (transceiver_id, source_vendor_id) *
|
||||
@ -183,14 +195,22 @@ stockRouter.get("/summary", async (req: Request, res: Response) => {
|
||||
ORDER BY transceiver_id, source_vendor_id, time DESC
|
||||
)
|
||||
SELECT
|
||||
v.name AS vendor_name,
|
||||
v.website AS vendor_website,
|
||||
COUNT(*) AS product_count,
|
||||
COUNT(*) FILTER (WHERE so.in_stock = true) AS in_stock_count,
|
||||
SUM(COALESCE(so.warehouse_de_qty, 0)) AS total_de_qty,
|
||||
SUM(COALESCE(so.warehouse_global_qty, 0)) AS total_global_qty,
|
||||
SUM(COALESCE(so.backorder_qty, 0)) AS total_backorder,
|
||||
MAX(so.time) AS last_scraped
|
||||
v.name AS vendor_name,
|
||||
v.website AS vendor_website,
|
||||
COUNT(*) AS product_count,
|
||||
COUNT(*) FILTER (WHERE so.in_stock = true) AS in_stock_count,
|
||||
SUM(COALESCE(so.warehouse_de_qty, 0)) AS total_de_qty,
|
||||
SUM(COALESCE(so.warehouse_global_qty, 0)) AS total_global_qty,
|
||||
SUM(COALESCE(so.backorder_qty, 0)) AS total_backorder,
|
||||
MAX(so.time) AS last_scraped,
|
||||
ROUND(AVG(so.stock_confidence)::NUMERIC, 1) AS avg_confidence,
|
||||
ARRAY_AGG(DISTINCT so.price_currency)
|
||||
FILTER (WHERE so.price_currency IS NOT NULL) AS currencies,
|
||||
-- Per-confidence breakdown
|
||||
COUNT(*) FILTER (WHERE so.stock_confidence = 3) AS conf_per_warehouse,
|
||||
COUNT(*) FILTER (WHERE so.stock_confidence = 2) AS conf_aggregated,
|
||||
COUNT(*) FILTER (WHERE so.stock_confidence = 1
|
||||
OR so.stock_confidence IS NULL) AS conf_boolean
|
||||
FROM latest so
|
||||
JOIN vendors v ON v.id = so.source_vendor_id
|
||||
GROUP BY v.id, v.name, v.website
|
||||
@ -216,6 +236,35 @@ stockRouter.get("/summary", async (req: Request, res: Response) => {
|
||||
ORDER BY so.time DESC
|
||||
LIMIT 10
|
||||
`),
|
||||
|
||||
// Price comparison: SKUs tracked by multiple vendors (multi-vendor overlap)
|
||||
pool.query(`
|
||||
WITH latest AS (
|
||||
SELECT DISTINCT ON (transceiver_id, source_vendor_id) *
|
||||
FROM stock_observations
|
||||
WHERE price_net IS NOT NULL
|
||||
ORDER BY transceiver_id, source_vendor_id, time DESC
|
||||
)
|
||||
SELECT
|
||||
t.part_number,
|
||||
t.form_factor,
|
||||
t.speed,
|
||||
COUNT(DISTINCT so.source_vendor_id) AS vendor_count,
|
||||
MIN(so.price_net) AS price_min,
|
||||
MAX(so.price_net) AS price_max,
|
||||
ROUND(AVG(so.price_net)::NUMERIC, 2) AS price_avg,
|
||||
ARRAY_AGG(v.name ORDER BY so.price_net) AS vendor_names,
|
||||
ARRAY_AGG(so.price_net ORDER BY so.price_net) AS prices,
|
||||
ARRAY_AGG(so.price_currency ORDER BY so.price_net) AS currencies,
|
||||
MAX(so.stock_confidence) AS best_confidence
|
||||
FROM latest so
|
||||
JOIN transceivers t ON t.id = so.transceiver_id
|
||||
JOIN vendors v ON v.id = so.source_vendor_id
|
||||
GROUP BY t.id, t.part_number, t.form_factor, t.speed
|
||||
HAVING COUNT(DISTINCT so.source_vendor_id) >= 2
|
||||
ORDER BY vendor_count DESC, price_min ASC
|
||||
LIMIT 50
|
||||
`),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
@ -225,6 +274,7 @@ stockRouter.get("/summary", async (req: Request, res: Response) => {
|
||||
top_sellers: topSellers.rows,
|
||||
vendor_breakdown: vendorBreakdown.rows,
|
||||
recently_updated: recentlyUpdated.rows,
|
||||
price_comparison: priceComparison.rows,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@ -775,6 +775,7 @@
|
||||
<span data-goto="switches"><span class="val" id="stat-switches">—</span> switches</span>
|
||||
<span data-goto="standards"><span class="val" id="stat-standards">—</span> standards</span>
|
||||
<span data-goto="news"><span class="val" id="stat-news">—</span> articles</span>
|
||||
<span data-goto="stock"><span class="val" id="stat-stock-obs">—</span> stock obs</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status">
|
||||
@ -840,6 +841,15 @@
|
||||
<div id="verification-overview" class="mt" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem"></div>
|
||||
</div>
|
||||
|
||||
<!-- WAREHOUSE STOCK SUMMARY -->
|
||||
<div class="card mb" id="ov-stock-card" style="display:none">
|
||||
<div class="card-label" style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span>🏭 Warehouse Stock Summary</span>
|
||||
<button class="btn-sm" onclick="goToTab('stock')" style="font-size:0.7rem;padding:0.2rem 0.6rem">View Detail →</button>
|
||||
</div>
|
||||
<div id="ov-stock-grid" class="mt" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:0.75rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid g2 mb">
|
||||
<div class="card">
|
||||
<div class="card-label">Vector Collections</div>
|
||||
@ -1705,13 +1715,13 @@
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;flex-wrap:wrap;gap:0.5rem">
|
||||
<div>
|
||||
<h2 style="margin:0;font-size:1.1rem;color:var(--text-bright)">🏭 Warehouse Stock Intelligence</h2>
|
||||
<p style="margin:0.2rem 0 0;font-size:0.75rem;color:var(--text-dim)">Real-time DE-Lager · Global-Lager · Nachlieferung · Verkauft — scraped from fs.com and other vendors</p>
|
||||
<p style="margin:0.2rem 0 0;font-size:0.75rem;color:var(--text-dim)">Real-time DE-Lager · Global-Lager · Nachlieferung · Verkauft — scraped from fs.com · QSFPTEK · NADDOD & more</p>
|
||||
</div>
|
||||
<button onclick="loadStock()" class="btn" style="background:var(--indigo);color:#fff;font-size:0.75rem">↻ Refresh</button>
|
||||
<button onclick="stockLoaded=false;loadStock()" class="btn" style="background:var(--indigo);color:#fff;font-size:0.75rem">↻ Refresh</button>
|
||||
</div>
|
||||
|
||||
<!-- Summary stat cards -->
|
||||
<div class="grid mb" style="grid-template-columns:repeat(5,1fr);gap:0.75rem;margin-bottom:1.25rem" id="stock-stat-cards">
|
||||
<div class="grid mb" style="grid-template-columns:repeat(6,1fr);gap:0.75rem;margin-bottom:1.25rem" id="stock-stat-cards">
|
||||
<div class="stat-card" style="text-align:center">
|
||||
<div class="stat-icon blue">📦</div>
|
||||
<div class="stat-label">Total SKUs tracked</div>
|
||||
@ -1737,6 +1747,11 @@
|
||||
<div class="stat-label">In Nachlieferung</div>
|
||||
<div class="stat-val" id="stock-stat-backorder">—</div>
|
||||
</div>
|
||||
<div class="stat-card" style="text-align:center">
|
||||
<div class="stat-icon" style="color:#22c55e">🔀</div>
|
||||
<div class="stat-label">Multi-Vendor SKUs</div>
|
||||
<div class="stat-val" id="stock-stat-multiv">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1.5rem">
|
||||
@ -1775,11 +1790,12 @@
|
||||
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">DE-Lager</th>
|
||||
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Global</th>
|
||||
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Backorder</th>
|
||||
<th style="padding:6px 8px;text-align:center;color:var(--text-dim);font-weight:500" title="Stock data quality: L3=per-warehouse, L2=aggregated, L1=boolean">Quality</th>
|
||||
<th style="padding:6px 8px;text-align:left;color:var(--text-dim);font-weight:500">Last Scraped</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="stock-vendor-body">
|
||||
<tr><td colspan="7" style="text-align:center;padding:2rem;color:var(--text-dim)">No data yet</td></tr>
|
||||
<tr><td colspan="8" style="text-align:center;padding:2rem;color:var(--text-dim)">No data yet</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -1792,6 +1808,29 @@
|
||||
<div id="stock-recent" style="padding:1rem;color:var(--text-dim);font-size:0.8rem">No recent restock events</div>
|
||||
</div>
|
||||
|
||||
<!-- Multi-Vendor Price Comparison -->
|
||||
<div class="card" style="overflow:hidden;margin-top:1rem">
|
||||
<div style="padding:0.75rem 1rem;border-bottom:1px solid var(--border);font-size:0.85rem;font-weight:600;color:var(--text-bright)">🔀 Multi-Vendor Price Comparison <span style="font-weight:400;color:var(--text-dim);font-size:0.75rem">— SKUs tracked by 2+ vendors</span></div>
|
||||
<div style="overflow-x:auto">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:0.75rem" id="stock-price-compare-table">
|
||||
<thead>
|
||||
<tr style="background:var(--surface2)">
|
||||
<th style="padding:6px 8px;text-align:left;color:var(--text-dim);font-weight:500">Part Number</th>
|
||||
<th style="padding:6px 8px;text-align:left;color:var(--text-dim);font-weight:500">Form</th>
|
||||
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Vendors</th>
|
||||
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Min Price</th>
|
||||
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Max Price</th>
|
||||
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Avg Price</th>
|
||||
<th style="padding:6px 8px;text-align:left;color:var(--text-dim);font-weight:500">Vendors (low → high)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="stock-price-compare-body">
|
||||
<tr><td colspan="7" style="text-align:center;padding:2rem;color:var(--text-dim)">No multi-vendor data yet</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search by Part Number -->
|
||||
<div class="card" style="margin-top:1rem;padding:1rem">
|
||||
<div style="font-size:0.85rem;font-weight:600;color:var(--text-bright);margin-bottom:0.75rem">🔍 Lookup Stock by Part Number</div>
|
||||
@ -2212,6 +2251,26 @@ async function loadOverview() {
|
||||
animateValue(el('stat-switches'), h.database.stats.switch_count, 500);
|
||||
animateValue(el('stat-standards'), h.database.stats.standard_count, 500);
|
||||
animateValue(el('stat-news'), h.database.stats.news_count, 700);
|
||||
if (h.stock && h.stock.total_observations > 0) {
|
||||
animateValue(el('stat-stock-obs'), h.stock.total_observations, 900);
|
||||
// Show warehouse stock summary card
|
||||
var sc = el('ov-stock-card');
|
||||
if (sc) sc.style.display = '';
|
||||
var stockItems = [
|
||||
{ icon: '📦', label: 'Beobachtungen', val: h.stock.total_observations.toLocaleString(), color: '#6366f1' },
|
||||
{ icon: '🔌', label: 'SKUs mit Daten', val: h.stock.transceivers_with_stock.toLocaleString(), color: '#22c55e' },
|
||||
{ icon: '🏪', label: 'Anbieter', val: h.stock.vendors_with_stock.toLocaleString(), color: '#3b82f6' },
|
||||
{ icon: '🇩🇪', label: 'DE-Lager', val: h.stock.total_de_qty.toLocaleString(), color: '#a855f7' },
|
||||
{ icon: '🌍', label: 'Global-Lager', val: h.stock.total_global_qty.toLocaleString(), color: '#06b6d4' },
|
||||
];
|
||||
buildDOM(el('ov-stock-grid'), stockItems.map(function(si) {
|
||||
return '<div style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:0.75rem;text-align:center">'
|
||||
+ '<div style="font-size:1.4rem">' + si.icon + '</div>'
|
||||
+ '<div style="font-size:1.1rem;font-weight:700;color:' + si.color + ';margin:0.2rem 0">' + si.val + '</div>'
|
||||
+ '<div style="font-size:0.7rem;color:var(--text-dim)">' + si.label + '</div>'
|
||||
+ '</div>';
|
||||
}).join(''));
|
||||
}
|
||||
animateValue(el('ov-transceivers'), h.database.stats.transceiver_count, 1000);
|
||||
animateValue(el('ov-vendors'), h.database.stats.vendor_count, 800);
|
||||
animateValue(el('ov-switches'), h.database.stats.switch_count, 600);
|
||||
@ -6313,34 +6372,54 @@ async function runEquivalenceMatcher() {
|
||||
var stockLoaded = false;
|
||||
|
||||
async function loadStock() {
|
||||
if (stockLoaded) return; // already loaded — use Refresh button to force reload
|
||||
stockLoaded = false; // allow reloads via Refresh button
|
||||
if (stockLoaded) return; // already loaded — Refresh button resets stockLoaded=false first
|
||||
try {
|
||||
var data = await api('/api/stock/summary');
|
||||
if (!data.success) return;
|
||||
var d = data.data;
|
||||
var t = d.totals;
|
||||
|
||||
// Stat cards
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
function setEl(id, v) { var e = el(id); if (e) e.textContent = v; }
|
||||
|
||||
/** Returns a confidence badge HTML string based on avg_confidence value */
|
||||
function confBadge(avgConf) {
|
||||
var c = parseFloat(avgConf) || 0;
|
||||
if (c >= 2.5) return '<span title="L3: per-warehouse breakdown" style="background:#166534;color:#86efac;font-size:0.65rem;font-weight:700;padding:2px 6px;border-radius:10px;white-space:nowrap">🟢 L3</span>';
|
||||
if (c >= 1.5) return '<span title="L2: aggregated global count" style="background:#713f12;color:#fde68a;font-size:0.65rem;font-weight:700;padding:2px 6px;border-radius:10px;white-space:nowrap">🟡 L2</span>';
|
||||
return '<span title="L1: boolean in-stock only" style="background:var(--surface2);color:var(--text-dim);font-size:0.65rem;font-weight:700;padding:2px 6px;border-radius:10px;white-space:nowrap">⚪ L1</span>';
|
||||
}
|
||||
|
||||
/** Format price with currency symbol */
|
||||
function fmtPrice(net, currency) {
|
||||
if (net == null) return '—';
|
||||
var sym = currency === 'EUR' ? '€' : currency === 'USD' ? '$' : (currency || '') + ' ';
|
||||
return sym + Number(net).toFixed(2);
|
||||
}
|
||||
|
||||
// ── Stat cards ───────────────────────────────────────────────────────────
|
||||
setEl('stock-stat-skus', Number(t.unique_transceivers || 0).toLocaleString());
|
||||
setEl('stock-stat-instock', Number(t.in_stock_count || 0).toLocaleString());
|
||||
setEl('stock-stat-de', Number(t.total_de_qty || 0).toLocaleString());
|
||||
setEl('stock-stat-global', Number(t.total_global_qty || 0).toLocaleString());
|
||||
setEl('stock-stat-backorder', Number(t.total_backorder_qty || 0).toLocaleString());
|
||||
setEl('stock-stat-multiv', Number(t.multi_vendor_skus || 0).toLocaleString());
|
||||
|
||||
// Top sellers table
|
||||
// ── Top sellers table ────────────────────────────────────────────────────
|
||||
var tbody = el('stock-top-sellers-body');
|
||||
if (tbody) {
|
||||
if (d.top_sellers && d.top_sellers.length > 0) {
|
||||
tbody.innerHTML = d.top_sellers.map(function(r) {
|
||||
var pn = r.product_url
|
||||
? '<a href="' + esc(r.product_url) + '" target="_blank" style="color:var(--indigo);text-decoration:none;font-family:monospace;font-size:0.72rem">' + esc(r.part_number) + '</a>'
|
||||
: '<span style="font-family:monospace;font-size:0.72rem;color:var(--text-bright)">' + esc(r.part_number) + '</span>';
|
||||
return '<tr style="border-bottom:1px solid var(--border)">'
|
||||
+ '<td style="padding:5px 8px;font-family:monospace;font-size:0.72rem;color:var(--text-bright)">' + esc(r.part_number) + '</td>'
|
||||
+ '<td style="padding:5px 8px">' + pn + '</td>'
|
||||
+ '<td style="padding:5px 8px;color:var(--text-dim)">' + esc(r.form_factor || '—') + '</td>'
|
||||
+ '<td style="padding:5px 8px;text-align:right;color:#f59e0b;font-weight:600">' + Number(r.units_sold || 0).toLocaleString() + '</td>'
|
||||
+ '<td style="padding:5px 8px;text-align:right;color:#6366f1">' + (r.warehouse_de_qty != null ? Number(r.warehouse_de_qty).toLocaleString() : '—') + '</td>'
|
||||
+ '<td style="padding:5px 8px;text-align:right;color:#06b6d4">' + (r.warehouse_global_qty != null ? Number(r.warehouse_global_qty).toLocaleString() : '—') + '</td>'
|
||||
+ '<td style="padding:5px 8px;text-align:right">' + (r.price_net != null ? '€' + Number(r.price_net).toFixed(2) : '—') + '</td>'
|
||||
+ '<td style="padding:5px 8px;text-align:right">' + fmtPrice(r.price_net, r.price_currency) + '</td>'
|
||||
+ '</tr>';
|
||||
}).join('');
|
||||
} else {
|
||||
@ -6348,7 +6427,7 @@ async function loadStock() {
|
||||
}
|
||||
}
|
||||
|
||||
// Vendor breakdown
|
||||
// ── Vendor breakdown (with confidence badge) ─────────────────────────────
|
||||
var vbody = el('stock-vendor-body');
|
||||
if (vbody) {
|
||||
if (d.vendor_breakdown && d.vendor_breakdown.length > 0) {
|
||||
@ -6361,15 +6440,16 @@ async function loadStock() {
|
||||
+ '<td style="padding:5px 8px;text-align:right;color:#6366f1">' + Number(r.total_de_qty || 0).toLocaleString() + '</td>'
|
||||
+ '<td style="padding:5px 8px;text-align:right;color:#06b6d4">' + Number(r.total_global_qty || 0).toLocaleString() + '</td>'
|
||||
+ '<td style="padding:5px 8px;text-align:right;color:#f59e0b">' + Number(r.total_backorder || 0).toLocaleString() + '</td>'
|
||||
+ '<td style="padding:5px 8px;text-align:center">' + confBadge(r.avg_confidence) + '</td>'
|
||||
+ '<td style="padding:5px 8px;color:var(--text-dim);font-size:0.7rem">' + lastScraped + '</td>'
|
||||
+ '</tr>';
|
||||
}).join('');
|
||||
} else {
|
||||
vbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:2rem;color:var(--text-dim)">No data yet</td></tr>';
|
||||
vbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:2rem;color:var(--text-dim)">No data yet</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// Recently restocked
|
||||
// ── Recently restocked ───────────────────────────────────────────────────
|
||||
var recentEl = el('stock-recent');
|
||||
if (recentEl) {
|
||||
if (d.recently_updated && d.recently_updated.length > 0) {
|
||||
@ -6390,6 +6470,33 @@ async function loadStock() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Multi-vendor price comparison ────────────────────────────────────────
|
||||
var pcbody = el('stock-price-compare-body');
|
||||
if (pcbody) {
|
||||
if (d.price_comparison && d.price_comparison.length > 0) {
|
||||
pcbody.innerHTML = d.price_comparison.slice(0, 20).map(function(r) {
|
||||
var spread = r.price_max && r.price_min
|
||||
? ' <span style="color:var(--text-dim);font-size:0.68rem">(Δ' + (Number(r.price_max) - Number(r.price_min)).toFixed(2) + ')</span>'
|
||||
: '';
|
||||
var vendorList = (r.vendor_names || []).map(function(vn, i) {
|
||||
var p = r.prices && r.prices[i] != null ? fmtPrice(r.prices[i], r.currencies && r.currencies[i]) : '';
|
||||
return '<span style="background:var(--surface2);border-radius:4px;padding:1px 5px;font-size:0.68rem">' + esc(vn) + (p ? ' <b>' + p + '</b>' : '') + '</span>';
|
||||
}).join(' ');
|
||||
return '<tr style="border-bottom:1px solid var(--border)">'
|
||||
+ '<td style="padding:5px 8px;font-family:monospace;font-size:0.72rem;color:var(--text-bright)">' + esc(r.part_number) + '</td>'
|
||||
+ '<td style="padding:5px 8px;color:var(--text-dim)">' + esc(r.form_factor || '—') + '</td>'
|
||||
+ '<td style="padding:5px 8px;text-align:right;font-weight:600">' + (r.vendor_count || '—') + '</td>'
|
||||
+ '<td style="padding:5px 8px;text-align:right;color:#22c55e">' + fmtPrice(r.price_min, r.currencies && r.currencies[0]) + '</td>'
|
||||
+ '<td style="padding:5px 8px;text-align:right;color:#ef4444">' + fmtPrice(r.price_max, r.currencies && r.currencies[0]) + spread + '</td>'
|
||||
+ '<td style="padding:5px 8px;text-align:right">' + fmtPrice(r.price_avg, r.currencies && r.currencies[0]) + '</td>'
|
||||
+ '<td style="padding:5px 8px">' + vendorList + '</td>'
|
||||
+ '</tr>';
|
||||
}).join('');
|
||||
} else {
|
||||
pcbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:2rem;color:var(--text-dim)">No multi-vendor data yet</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
stockLoaded = true;
|
||||
} catch(e) {
|
||||
console.error('loadStock error', e);
|
||||
|
||||
@ -49,22 +49,33 @@ interface TmgSearchResponse {
|
||||
|
||||
/** Key Nexus/Catalyst platform family IDs from the TMG API */
|
||||
const PLATFORM_FAMILIES = [
|
||||
{ id: 74, name: "N9300" }, // Nexus 9300 — 8,515 entries
|
||||
{ id: 77, name: "N9500" }, // Nexus 9500 — 2,266 entries
|
||||
{ id: 78, name: "N9200" }, // Nexus 9200 — 708 entries
|
||||
{ id: 661, name: "N9800" }, // Nexus 9800 — 238 entries
|
||||
{ id: 76, name: "C9300" }, // Catalyst 9300 — 260 entries
|
||||
{ id: 601, name: "C9300L" }, // Catalyst 9300L — 720 entries
|
||||
{ id: 1181, name: "C9300X" }, // Catalyst 9300X — 413 entries
|
||||
{ id: 8, name: "C9500" }, // Catalyst 9500 — 1,141 entries
|
||||
{ id: 521, name: "C9600" }, // Catalyst 9600 — 771 entries
|
||||
{ id: 7, name: "C9400" }, // Catalyst 9400 — 561 entries
|
||||
{ id: 341, name: "C9200" }, // Catalyst 9200 — 222 entries
|
||||
{ id: 83, name: "ASR9000" }, // ASR 9000 — 3,644 entries
|
||||
// ── Nexus Data Center ───────────────────────────────────────────────────
|
||||
{ id: 74, name: "N9300" }, // Nexus 9300 — 8,515 entries
|
||||
{ id: 77, name: "N9500" }, // Nexus 9500 — 2,266 entries
|
||||
{ id: 78, name: "N9200" }, // Nexus 9200 — 708 entries
|
||||
{ id: 661, name: "N9800" }, // Nexus 9800 — 238 entries
|
||||
// ── Catalyst Campus ─────────────────────────────────────────────────────
|
||||
{ id: 76, name: "C9300" }, // Catalyst 9300 — 260 entries
|
||||
{ id: 601, name: "C9300L" }, // Catalyst 9300L — 720 entries
|
||||
{ id: 1181, name: "C9300X" }, // Catalyst 9300X — 413 entries
|
||||
{ id: 8, name: "C9500" }, // Catalyst 9500 — 1,141 entries
|
||||
{ id: 521, name: "C9600" }, // Catalyst 9600 — 771 entries
|
||||
{ id: 7, name: "C9400" }, // Catalyst 9400 — 561 entries
|
||||
{ id: 341, name: "C9200" }, // Catalyst 9200 — 222 entries
|
||||
// ── Service Provider / High-Capacity ────────────────────────────────────
|
||||
{ id: 83, name: "ASR9000" }, // ASR 9000 — 3,644 entries
|
||||
{ id: 1021, name: "8000" }, // Cisco 8000 Series — 1,954 entries
|
||||
{ id: 20, name: "NCS5500" }, // NCS 5500 — 3,843 entries
|
||||
{ id: 121, name: "NCS540" }, // NCS 540 — 2,684 entries
|
||||
{ id: 141, name: "NCS560" }, // NCS 560 — 229 entries
|
||||
{ id: 17, name: "NCS1000" }, // NCS 1000 (optical) — 325 entries
|
||||
];
|
||||
|
||||
async function searchTmg(familyFilter: { id: number; name: string }): Promise<TmgSearchResponse> {
|
||||
const body = {
|
||||
function buildTmgBody(
|
||||
familyFilter?: { id: number; name: string },
|
||||
deviceIdFilter?: { id: number; name: string }
|
||||
): object {
|
||||
return {
|
||||
cableType: [],
|
||||
dataRate: [],
|
||||
formFactor: [],
|
||||
@ -73,14 +84,16 @@ async function searchTmg(familyFilter: { id: number; name: string }): Promise<Tm
|
||||
osType: [],
|
||||
transceiverProductFamily: [],
|
||||
transceiverProductID: [],
|
||||
networkDeviceProductFamily: [familyFilter],
|
||||
networkDeviceProductID: [],
|
||||
networkDeviceProductFamily: familyFilter ? [familyFilter] : [],
|
||||
networkDeviceProductID: deviceIdFilter ? [deviceIdFilter] : [],
|
||||
media: [],
|
||||
connectorType: [],
|
||||
caseTemperature: [],
|
||||
performanceMonitoring: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function searchTmg(familyFilter: { id: number; name: string }): Promise<TmgSearchResponse> {
|
||||
const res = await fetch(TMG_API, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@ -88,7 +101,28 @@ async function searchTmg(familyFilter: { id: number; name: string }): Promise<Tm
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify(buildTmgBody(familyFilter)),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`TMG API ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
|
||||
return res.json() as Promise<TmgSearchResponse>;
|
||||
}
|
||||
|
||||
/** Search for a specific switch by its TMG Product ID to get full compat data */
|
||||
async function searchTmgByDeviceId(deviceId: { id: number; name: string }): Promise<TmgSearchResponse> {
|
||||
const res = await fetch(TMG_API, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
body: JSON.stringify(buildTmgBody(undefined, deviceId)),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@ -149,51 +183,66 @@ export async function scrapeCiscoTmg(): Promise<void> {
|
||||
let totalCompat = 0;
|
||||
let totalTransceivers = 0;
|
||||
|
||||
/** Process one networkDevice compat response — writes switches+compat to DB */
|
||||
async function processDevices(
|
||||
devices: TmgDevice[],
|
||||
familyName: string
|
||||
): Promise<{ switches: number; transceivers: number; compat: number }> {
|
||||
let sw = 0; let tx = 0; let cp = 0;
|
||||
for (const device of devices) {
|
||||
for (const compat of device.networkAndTransceiverCompatibility) {
|
||||
if (!compat.productId) continue;
|
||||
const switchId = await upsertCiscoSwitch(ciscoVendorId, compat.productId, familyName);
|
||||
sw++;
|
||||
for (const t of compat.transceivers) {
|
||||
if (!t.productId) continue;
|
||||
tx++;
|
||||
const txResult = await pool.query(
|
||||
`SELECT id FROM transceivers WHERE part_number = $1 OR part_number = $2 LIMIT 1`,
|
||||
[t.productId, t.productId.replace(/-S$/, "")]
|
||||
);
|
||||
if (txResult.rows.length > 0) {
|
||||
await upsertCompatibility(switchId, txResult.rows[0].id, t.softReleaseMinVer, t.formFactor, t.reach, t.cableType, t.media, t.dataRate);
|
||||
cp++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { switches: sw, transceivers: tx, compat: cp };
|
||||
}
|
||||
|
||||
for (const family of PLATFORM_FAMILIES) {
|
||||
console.log(`\nFetching ${family.name}...`);
|
||||
|
||||
try {
|
||||
const data = await searchTmg(family);
|
||||
console.log(` ${family.name}: ${data.totalCount} total entries, ${data.networkDevices.length} device groups`);
|
||||
// Step 1: Fetch family-level response to get all device IDs in the filter
|
||||
const familyData = await searchTmg(family);
|
||||
const deviceIdFilter = familyData.filters
|
||||
?.find((f) => f.name === "Network Device Product ID")
|
||||
?.values ?? [];
|
||||
|
||||
for (const device of data.networkDevices) {
|
||||
for (const compat of device.networkAndTransceiverCompatibility) {
|
||||
if (!compat.productId) continue;
|
||||
console.log(` ${family.name}: ${deviceIdFilter.length} switch models`);
|
||||
|
||||
const switchId = await upsertCiscoSwitch(
|
||||
ciscoVendorId,
|
||||
compat.productId,
|
||||
device.productFamily
|
||||
);
|
||||
totalSwitches++;
|
||||
if (deviceIdFilter.length === 0) {
|
||||
// Fallback: process whatever family-level search returned
|
||||
const r = await processDevices(familyData.networkDevices, family.name);
|
||||
totalSwitches += r.switches; totalTransceivers += r.transceivers; totalCompat += r.compat;
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const tx of compat.transceivers) {
|
||||
if (!tx.productId) continue;
|
||||
totalTransceivers++;
|
||||
|
||||
// Try to match transceiver in our DB by Cisco PID
|
||||
const txResult = await pool.query(
|
||||
`SELECT id FROM transceivers
|
||||
WHERE part_number = $1
|
||||
OR part_number = $2
|
||||
LIMIT 1`,
|
||||
[tx.productId, tx.productId.replace(/-S$/, "")]
|
||||
);
|
||||
|
||||
if (txResult.rows.length > 0) {
|
||||
await upsertCompatibility(
|
||||
switchId,
|
||||
txResult.rows[0].id,
|
||||
tx.softReleaseMinVer,
|
||||
tx.formFactor,
|
||||
tx.reach,
|
||||
tx.cableType,
|
||||
tx.media,
|
||||
tx.dataRate
|
||||
);
|
||||
totalCompat++;
|
||||
}
|
||||
// Step 2: Iterate every switch model by its specific TMG Product ID
|
||||
// (family search only returns 1 switch; per-device search returns full compat list)
|
||||
for (const dev of deviceIdFilter) {
|
||||
try {
|
||||
await new Promise((r) => setTimeout(r, 1000)); // 1s between requests
|
||||
const devData = await searchTmgByDeviceId({ id: dev.id, name: dev.name });
|
||||
const r = await processDevices(devData.networkDevices, family.name);
|
||||
totalSwitches += r.switches; totalTransceivers += r.transceivers; totalCompat += r.compat;
|
||||
if (totalSwitches % 20 === 0) {
|
||||
console.log(` ... ${totalSwitches} switches processed, ${totalCompat} compat matches`);
|
||||
}
|
||||
} catch (devErr) {
|
||||
console.warn(` Skip ${dev.name}: ${(devErr as Error).message.slice(0, 60)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user