diff --git a/packages/api/src/routes/stock.ts b/packages/api/src/routes/stock.ts index 96a9f6b..989a476 100644 --- a/packages/api/src/routes/stock.ts +++ b/packages/api/src/routes/stock.ts @@ -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) { diff --git a/packages/dashboard/index.html b/packages/dashboard/index.html index e145c73..adf15e3 100644 --- a/packages/dashboard/index.html +++ b/packages/dashboard/index.html @@ -775,6 +775,7 @@ switches standards articles + stock obs
@@ -840,6 +841,15 @@
+ + +
Vector Collections
@@ -1705,13 +1715,13 @@

๐Ÿญ Warehouse Stock Intelligence

-

Real-time DE-Lager ยท Global-Lager ยท Nachlieferung ยท Verkauft โ€” scraped from fs.com and other vendors

+

Real-time DE-Lager ยท Global-Lager ยท Nachlieferung ยท Verkauft โ€” scraped from fs.com ยท QSFPTEK ยท NADDOD & more

- +
-
+
๐Ÿ“ฆ
Total SKUs tracked
@@ -1737,6 +1747,11 @@
In Nachlieferung
โ€”
+
+
๐Ÿ”€
+
Multi-Vendor SKUs
+
โ€”
+
@@ -1775,11 +1790,12 @@ DE-Lager Global Backorder + Quality Last Scraped - No data yet + No data yet
@@ -1792,6 +1808,29 @@
No recent restock events
+ +
+
๐Ÿ”€ Multi-Vendor Price Comparison โ€” SKUs tracked by 2+ vendors
+
+ + + + + + + + + + + + + + + +
Part NumberFormVendorsMin PriceMax PriceAvg PriceVendors (low โ†’ high)
No multi-vendor data yet
+
+
+
๐Ÿ” Lookup Stock by Part Number
@@ -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 '
' + + '
' + si.icon + '
' + + '
' + si.val + '
' + + '
' + si.label + '
' + + '
'; + }).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 '๐ŸŸข L3'; + if (c >= 1.5) return '๐ŸŸก L2'; + return 'โšช L1'; + } + + /** 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 + ? '' + esc(r.part_number) + '' + : '' + esc(r.part_number) + ''; return '' - + '' + esc(r.part_number) + '' + + '' + pn + '' + '' + esc(r.form_factor || 'โ€”') + '' + '' + Number(r.units_sold || 0).toLocaleString() + '' + '' + (r.warehouse_de_qty != null ? Number(r.warehouse_de_qty).toLocaleString() : 'โ€”') + '' + '' + (r.warehouse_global_qty != null ? Number(r.warehouse_global_qty).toLocaleString() : 'โ€”') + '' - + '' + (r.price_net != null ? 'โ‚ฌ' + Number(r.price_net).toFixed(2) : 'โ€”') + '' + + '' + fmtPrice(r.price_net, r.price_currency) + '' + ''; }).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() { + '' + Number(r.total_de_qty || 0).toLocaleString() + '' + '' + Number(r.total_global_qty || 0).toLocaleString() + '' + '' + Number(r.total_backorder || 0).toLocaleString() + '' + + '' + confBadge(r.avg_confidence) + '' + '' + lastScraped + '' + ''; }).join(''); } else { - vbody.innerHTML = 'No data yet'; + vbody.innerHTML = 'No data yet'; } } - // 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 + ? ' (ฮ”' + (Number(r.price_max) - Number(r.price_min)).toFixed(2) + ')' + : ''; + 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 '' + esc(vn) + (p ? ' ' + p + '' : '') + ''; + }).join(' '); + return '' + + '' + esc(r.part_number) + '' + + '' + esc(r.form_factor || 'โ€”') + '' + + '' + (r.vendor_count || 'โ€”') + '' + + '' + fmtPrice(r.price_min, r.currencies && r.currencies[0]) + '' + + '' + fmtPrice(r.price_max, r.currencies && r.currencies[0]) + spread + '' + + '' + fmtPrice(r.price_avg, r.currencies && r.currencies[0]) + '' + + '' + vendorList + '' + + ''; + }).join(''); + } else { + pcbody.innerHTML = 'No multi-vendor data yet'; + } + } + stockLoaded = true; } catch(e) { console.error('loadStock error', e); diff --git a/packages/scraper/src/scrapers/cisco-tmg.ts b/packages/scraper/src/scrapers/cisco-tmg.ts index dc1c5e1..e0d71bb 100644 --- a/packages/scraper/src/scrapers/cisco-tmg.ts +++ b/packages/scraper/src/scrapers/cisco-tmg.ts @@ -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 { - 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 { const res = await fetch(TMG_API, { method: "POST", headers: { @@ -88,7 +101,28 @@ async function searchTmg(familyFilter: { id: number; name: string }): Promise; +} + +/** Search for a specific switch by its TMG Product ID to get full compat data */ +async function searchTmgByDeviceId(deviceId: { id: number; name: string }): Promise { + 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 { 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)}`); } }