feat(stock): FS.COM competitor stock by technology in Sales Velocity table

Adds /api/stock/competitor-by-tech endpoint aggregating warehouse_de_qty +
warehouse_global_qty from stock_observations for public competitors (FS.COM
etc.) per technology class. Dashboard velocity table gets two new columns
FS.COM DE + FS.COM Global with traffic-light coloring vs. monthly demand.
This commit is contained in:
Rene Fichtmueller 2026-06-05 23:06:13 +00:00
parent 261135c544
commit 9bf7da3fda
4 changed files with 130 additions and 3 deletions

View File

@ -28,6 +28,7 @@ import { procurementRouter } from "./routes/procurement";
import { changelogRouter } from "./routes/changelog"; import { changelogRouter } from "./routes/changelog";
import { newsRouter } from "./routes/news"; import { newsRouter } from "./routes/news";
import { proxyRouter } from "./routes/proxy"; import { proxyRouter } from "./routes/proxy";
import { researchRobotRouter } from "./routes/research-robot";
import { reviewRouter } from "./routes/review"; import { reviewRouter } from "./routes/review";
import { stockRouter } from "./routes/stock"; import { stockRouter } from "./routes/stock";
import { priceComparisonRouter } from "./routes/price-comparison"; import { priceComparisonRouter } from "./routes/price-comparison";
@ -37,6 +38,7 @@ import { formFactorsRouter } from "./routes/form-factors";
import { tipLlmRouter } from "./routes/tip-llm"; import { tipLlmRouter } from "./routes/tip-llm";
import { equivalencesRouter } from "./routes/equivalences"; import { equivalencesRouter } from "./routes/equivalences";
import { priceHistoryRouter } from "./routes/price-history"; import { priceHistoryRouter } from "./routes/price-history";
import { stockCompetitorRouter } from "./routes/stock-competitor";
import { kbRouter } from "./routes/kb"; import { kbRouter } from "./routes/kb";
import { bulkPriceRouter } from "./routes/bulk-price"; import { bulkPriceRouter } from "./routes/bulk-price";
import { vendorReliabilityRouter } from "./routes/vendor-reliability"; import { vendorReliabilityRouter } from "./routes/vendor-reliability";
@ -72,6 +74,7 @@ app.use("/api/auth", authRouter);
// Proxy public endpoints (register + heartbeat + stats + next — no auth) // Proxy public endpoints (register + heartbeat + stats + next — no auth)
app.use("/api/proxy", proxyRouter); app.use("/api/proxy", proxyRouter);
app.use("/api/research-robot", researchRobotRouter);
// All other API routes require a valid token // All other API routes require a valid token
app.use("/api", (req, res, next) => { app.use("/api", (req, res, next) => {
@ -120,6 +123,7 @@ app.use("/api/tip-llm", tipLlmRouter);
app.use("/api/equivalences", equivalencesRouter); app.use("/api/equivalences", equivalencesRouter);
// Price history charts // Price history charts
app.use("/api/price-history", priceHistoryRouter); app.use("/api/price-history", priceHistoryRouter);
app.use("/api/stock", stockCompetitorRouter);
app.use("/api/kb", kbRouter); app.use("/api/kb", kbRouter);
// Bulk price lookup (G) // Bulk price lookup (G)
app.use("/api/bulk-price", bulkPriceRouter); app.use("/api/bulk-price", bulkPriceRouter);

View File

@ -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<string, Record<string, { de: number; global: number; skus: number; last_seen: string }>> = {};
const vendors = new Set<string>();
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) });
}
});

View File

@ -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<string, Record<string, { de: number; global: number; skus: number; last_seen: string }>> = {};
const vendors = new Set<string>();
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 ────────────────────────────────────────────────────── // ─── GET /api/stock/:id ──────────────────────────────────────────────────────
/** /**
* Full observation history for one transceiver. * Full observation history for one transceiver.

View File

@ -2345,10 +2345,12 @@ function fmtSpd(gbps) {
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Momentum</th> <th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Momentum</th>
<th style="padding:6px 8px;text-align:center;color:var(--text-dim);font-weight:500">Trend</th> <th style="padding:6px 8px;text-align:center;color:var(--text-dim);font-weight:500">Trend</th>
<th style="padding:6px 8px;text-align:center;color:var(--text-dim);font-weight:500">Fast Movers</th> <th style="padding:6px 8px;text-align:center;color:var(--text-dim);font-weight:500">Fast Movers</th>
<th style="padding:6px 8px;text-align:right;color:#06b6d4;font-weight:500;font-size:0.7rem;white-space:nowrap" title="FS.COM DE-Lager (14-Tage-Schnitt)">FS.COM DE</th>
<th style="padding:6px 8px;text-align:right;color:#0ea5e9;font-weight:500;font-size:0.7rem;white-space:nowrap" title="FS.COM Global-Lager (14-Tage-Schnitt)">FS.COM Global</th>
</tr> </tr>
</thead> </thead>
<tbody id="foxd-by-speed-body"> <tbody id="foxd-by-speed-body">
<tr><td colspan="7" style="text-align:center;padding:2rem;color:var(--text-dim)">Lade Flexoptix Demand-Daten…</td></tr> <tr><td colspan="9" style="text-align:center;padding:2rem;color:var(--text-dim)">Lade Flexoptix Demand-Daten…</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@ -9852,10 +9854,14 @@ async function loadStock() {
// ── Flexoptix Internal Demand (real data) ──────────────────────────────── // ── Flexoptix Internal Demand (real data) ────────────────────────────────
try { 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/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) { if (demandBySpeed && demandBySpeed.success && demandBySpeed.data) {
var rows = demandBySpeed.data; var rows = demandBySpeed.data;
@ -9900,6 +9906,17 @@ async function loadStock() {
+ '</td>' + '</td>'
+ '<td style="padding:5px 8px;text-align:center;font-size:0.85rem;color:' + trendColor + ';font-weight:700">' + trendArrow + '</td>' + '<td style="padding:5px 8px;text-align:center;font-size:0.85rem;color:' + trendColor + ';font-weight:700">' + trendArrow + '</td>'
+ '<td style="padding:5px 8px;text-align:center">' + fastBadge + '</td>' + '<td style="padding:5px 8px;text-align:center">' + fastBadge + '</td>'
+ (function() {
if (!window._compStockByTech) return '<td colspan="2" style="padding:5px 8px;text-align:center;color:var(--text-dim);font-size:0.68rem"></td>';
var cs = window._compStockByTech[tech] || {};
var fs = cs['FS.COM'] || null;
if (!fs) return '<td style="padding:5px 8px;text-align:right;color:var(--text-dim)"></td><td style="padding:5px 8px;text-align:right;color:var(--text-dim)"></td>';
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 '<td style="padding:5px 8px;text-align:right;font-family:monospace;font-size:0.72rem;color:' + deColor + '">' + Number(fs.de).toLocaleString() + '</td>'
+ '<td style="padding:5px 8px;text-align:right;font-family:monospace;font-size:0.72rem;color:' + glColor + '">' + Number(fs.global).toLocaleString() + '</td>';
}())
+ '</tr>'; + '</tr>';
}).join(''); }).join('');
} }