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:
parent
261135c544
commit
9bf7da3fda
@ -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);
|
||||
|
||||
58
packages/api/src/routes/stock-competitor.ts
Normal file
58
packages/api/src/routes/stock-competitor.ts
Normal 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) });
|
||||
}
|
||||
});
|
||||
@ -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 ──────────────────────────────────────────────────────
|
||||
/**
|
||||
* Full observation history for one transceiver.
|
||||
|
||||
@ -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: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: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>
|
||||
</thead>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
@ -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() {
|
||||
+ '</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>'
|
||||
+ (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>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user