diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 4400735..130fc8d 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -28,6 +28,7 @@ import { procurementRouter } from "./routes/procurement"; import { changelogRouter } from "./routes/changelog"; import { newsRouter } from "./routes/news"; import { proxyRouter } from "./routes/proxy"; +import { researchRobotRouter } from "./routes/research-robot"; import { reviewRouter } from "./routes/review"; import { stockRouter } from "./routes/stock"; import { priceComparisonRouter } from "./routes/price-comparison"; @@ -37,6 +38,7 @@ import { formFactorsRouter } from "./routes/form-factors"; import { tipLlmRouter } from "./routes/tip-llm"; import { equivalencesRouter } from "./routes/equivalences"; import { priceHistoryRouter } from "./routes/price-history"; +import { stockCompetitorRouter } from "./routes/stock-competitor"; import { kbRouter } from "./routes/kb"; import { bulkPriceRouter } from "./routes/bulk-price"; import { vendorReliabilityRouter } from "./routes/vendor-reliability"; @@ -72,6 +74,7 @@ app.use("/api/auth", authRouter); // Proxy public endpoints (register + heartbeat + stats + next — no auth) app.use("/api/proxy", proxyRouter); +app.use("/api/research-robot", researchRobotRouter); // All other API routes require a valid token app.use("/api", (req, res, next) => { @@ -120,6 +123,7 @@ app.use("/api/tip-llm", tipLlmRouter); app.use("/api/equivalences", equivalencesRouter); // Price history charts app.use("/api/price-history", priceHistoryRouter); +app.use("/api/stock", stockCompetitorRouter); app.use("/api/kb", kbRouter); // Bulk price lookup (G) app.use("/api/bulk-price", bulkPriceRouter); diff --git a/packages/api/src/routes/stock-competitor.ts b/packages/api/src/routes/stock-competitor.ts new file mode 100644 index 0000000..ef79f16 --- /dev/null +++ b/packages/api/src/routes/stock-competitor.ts @@ -0,0 +1,58 @@ +import { Router, Request, Response } from "express"; +import { pool } from "../db/client"; + +export const stockCompetitorRouter = Router(); + +// GET /api/stock/competitor-by-tech +// Returns publicly-available competitor stock levels aggregated by technology (form_factor+speed_gbps). +// Used by Warehouse Stock Intelligence to benchmark Flexoptix demand against competitor availability. +stockCompetitorRouter.get("/competitor-by-tech", async (_req: Request, res: Response) => { + try { + const result = await pool.query(` + SELECT + t.form_factor, + t.speed_gbps::numeric AS speed_gbps, + sv.name AS vendor_name, + COUNT(DISTINCT so.transceiver_id)::int AS skus, + COALESCE(SUM(so.warehouse_de_qty),0)::int AS de_stock, + COALESCE(SUM(so.warehouse_global_qty),0)::int AS global_stock, + COALESCE(SUM(so.quantity_available),0)::int AS qty_available, + MAX(so.time)::date AS last_seen + FROM stock_observations so + JOIN transceivers t ON t.id = so.transceiver_id + JOIN vendors sv ON sv.id = so.source_vendor_id + WHERE so.time > NOW() - INTERVAL '14 days' + AND sv.name IN ('FS.COM','ATGBICS','FiberMall','NADDOD','Prolabs','Blue Optics','Skylane Optics') + GROUP BY t.form_factor, t.speed_gbps, sv.name + ORDER BY sv.name, global_stock DESC NULLS LAST + `); + + // Pivot: { "1G SFP": { "FS.COM": { de:..., global:... }, ... }, ... } + const byTech: Record> = {}; + const vendors = new Set(); + + for (const row of result.rows) { + const n = parseFloat(row.speed_gbps); + const spd = n >= 1000 + ? ((n / 1000 * 10) % 10 === 0 ? Math.round(n / 1000).toString() : (n / 1000).toFixed(1)) + "T" + : ((n * 10) % 10 === 0 ? Math.round(n).toString() : String(n)) + "G"; + const key = spd + " " + (row.form_factor || "?"); + if (!byTech[key]) byTech[key] = {}; + byTech[key][row.vendor_name] = { + de: parseInt(row.de_stock) || 0, + global: parseInt(row.global_stock) || parseInt(row.qty_available) || 0, + skus: parseInt(row.skus) || 0, + last_seen: row.last_seen, + }; + vendors.add(row.vendor_name); + } + + res.json({ + success: true, + vendors: [...vendors].sort(), + by_tech: byTech, + }); + } catch (err) { + res.status(500).json({ success: false, error: String(err) }); + } +}); diff --git a/packages/api/src/routes/stock.ts b/packages/api/src/routes/stock.ts index 00dde82..2d52ec9 100644 --- a/packages/api/src/routes/stock.ts +++ b/packages/api/src/routes/stock.ts @@ -518,6 +518,54 @@ stockRouter.get("/velocity/:id", async (req: Request, res: Response) => { } }); + +// ─── GET /api/stock/competitor-by-tech ─────────────────────────────────────── +// Competitor stock levels from publicly-scraped data, aggregated by technology. +stockRouter.get("/competitor-by-tech", async (_req: Request, res: Response) => { + try { + const result = await pool.query(` + SELECT + t.form_factor, + t.speed_gbps::numeric AS speed_gbps, + sv.name AS vendor_name, + COUNT(DISTINCT so.transceiver_id)::int AS skus, + COALESCE(SUM(so.warehouse_de_qty),0)::int AS de_stock, + COALESCE(SUM(so.warehouse_global_qty),0)::int AS global_stock, + COALESCE(SUM(so.quantity_available),0)::int AS qty_available, + MAX(so.time)::date AS last_seen + FROM stock_observations so + JOIN transceivers t ON t.id = so.transceiver_id + JOIN vendors sv ON sv.id = so.source_vendor_id + WHERE so.time > NOW() - INTERVAL '14 days' + AND sv.name IN ('FS.COM','ATGBICS','FiberMall','NADDOD','Prolabs','Blue Optics','Skylane Optics') + GROUP BY t.form_factor, t.speed_gbps, sv.name + ORDER BY sv.name, global_stock DESC NULLS LAST + `); + + const byTech: Record> = {}; + const vendors = new Set(); + for (const row of result.rows) { + const n = parseFloat(row.speed_gbps); + const spd = n >= 1000 + ? ((n / 1000 * 10) % 10 === 0 ? String(Math.round(n / 1000)) : (n / 1000).toFixed(1)) + "T" + : ((n * 10) % 10 === 0 ? String(Math.round(n)) : String(n)) + "G"; + const key = spd + " " + (row.form_factor || "?"); + if (!byTech[key]) byTech[key] = {}; + byTech[key][row.vendor_name] = { + de: parseInt(row.de_stock) || 0, + global: parseInt(row.global_stock) || parseInt(row.qty_available) || 0, + skus: parseInt(row.skus) || 0, + last_seen: row.last_seen, + }; + vendors.add(row.vendor_name); + } + res.json({ success: true, vendors: [...vendors].sort(), by_tech: byTech }); + } catch (err) { + console.error("stock/competitor-by-tech error:", err); + res.status(500).json({ success: false, error: String(err) }); + } +}); + // ─── GET /api/stock/:id ────────────────────────────────────────────────────── /** * Full observation history for one transceiver. diff --git a/packages/dashboard/index.html b/packages/dashboard/index.html index 083895b..8708bc9 100644 --- a/packages/dashboard/index.html +++ b/packages/dashboard/index.html @@ -2345,10 +2345,12 @@ function fmtSpd(gbps) { Momentum Trend Fast Movers + FS.COM DE + FS.COM Global - Lade Flexoptix Demand-Daten… + Lade Flexoptix Demand-Daten… @@ -9852,10 +9854,14 @@ async function loadStock() { // ── Flexoptix Internal Demand (real data) ──────────────────────────────── try { - var [demandBySpeed, demandVelocity] = await Promise.all([ + var [demandBySpeed, demandVelocity, compStockResp] = await Promise.all([ api('/api/internal/demand/by-speed').catch(function() { return null; }), - api('/api/internal/demand/velocity').catch(function() { return null; }) + api('/api/internal/demand/velocity').catch(function() { return null; }), + api('/api/stock/competitor-by-tech').catch(function() { return null; }) ]); + if (compStockResp && compStockResp.success) { + window._compStockByTech = compStockResp.by_tech || {}; + } if (demandBySpeed && demandBySpeed.success && demandBySpeed.data) { var rows = demandBySpeed.data; @@ -9900,6 +9906,17 @@ async function loadStock() { + '' + '' + trendArrow + '' + '' + fastBadge + '' + + (function() { + if (!window._compStockByTech) return '—'; + var cs = window._compStockByTech[tech] || {}; + var fs = cs['FS.COM'] || null; + if (!fs) return '——'; + var demandMonthly = Number(r.total_demand_12m || 0) / 12; + var deColor = fs.de > demandMonthly * 3 ? '#22c55e' : fs.de > demandMonthly ? '#f59e0b' : '#ef4444'; + var glColor = fs.global > demandMonthly * 6 ? '#22c55e' : fs.global > demandMonthly * 2 ? '#f59e0b' : '#ef4444'; + return '' + Number(fs.de).toLocaleString() + '' + + '' + Number(fs.global).toLocaleString() + ''; + }()) + ''; }).join(''); }