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
383 lines
15 KiB
TypeScript
383 lines
15 KiB
TypeScript
/**
|
||
* Stock Observations API
|
||
*
|
||
* Exposes warehouse stock data scraped from fs.com (DE-Lager, Global-Lager,
|
||
* Nachlieferung, units_sold, compatible_brands) and other vendors.
|
||
*
|
||
* Routes:
|
||
* GET /api/stock — Latest obs per transceiver × vendor (paginated)
|
||
* GET /api/stock/summary — Aggregate warehouse stats (totals, top movers)
|
||
* GET /api/stock/:transceiverIdOrSku — Full obs history for one transceiver
|
||
*/
|
||
import { Router, Request, Response } from "express";
|
||
import { pool } from "../db/client";
|
||
|
||
export const stockRouter = Router();
|
||
|
||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||
|
||
function intParam(req: Request, name: string, fallback: number): number {
|
||
const v = req.query[name];
|
||
const parsed = v ? parseInt(String(v), 10) : NaN;
|
||
return Number.isFinite(parsed) ? parsed : fallback;
|
||
}
|
||
|
||
// ─── GET /api/stock ──────────────────────────────────────────────────────────
|
||
/**
|
||
* Returns the most recent stock observation per (transceiver, vendor) pair.
|
||
* Query params:
|
||
* vendor_id — filter by source vendor UUID
|
||
* in_stock — "true" | "false"
|
||
* min_de — minimum DE-Lager quantity
|
||
* min_global — minimum Global-Lager quantity
|
||
* part_number — partial match on part_number
|
||
* limit — default 50, max 200
|
||
* offset — default 0
|
||
*/
|
||
stockRouter.get("/", async (req: Request, res: Response) => {
|
||
try {
|
||
const limit = Math.min(intParam(req, "limit", 50), 200);
|
||
const offset = intParam(req, "offset", 0);
|
||
const vendorId = req.query.vendor_id ? String(req.query.vendor_id) : null;
|
||
const inStock = req.query.in_stock === "true" ? true : req.query.in_stock === "false" ? false : null;
|
||
const minDe = req.query.min_de ? parseInt(String(req.query.min_de), 10) : null;
|
||
const minGlobal = req.query.min_global ? parseInt(String(req.query.min_global), 10) : null;
|
||
const partNumber = req.query.part_number ? String(req.query.part_number) : null;
|
||
|
||
const conditions: string[] = [];
|
||
const params: unknown[] = [];
|
||
let p = 1;
|
||
|
||
if (vendorId) { conditions.push(`so.source_vendor_id = $${p++}`); params.push(vendorId); }
|
||
if (inStock !== null) { conditions.push(`so.in_stock = $${p++}`); params.push(inStock); }
|
||
if (minDe !== null) { conditions.push(`so.warehouse_de_qty >= $${p++}`); params.push(minDe); }
|
||
if (minGlobal !== null) { conditions.push(`so.warehouse_global_qty >= $${p++}`); params.push(minGlobal); }
|
||
if (partNumber) { conditions.push(`t.part_number ILIKE $${p++}`); params.push(`%${partNumber}%`); }
|
||
|
||
const whereClause = conditions.length ? `AND ${conditions.join(" AND ")}` : "";
|
||
|
||
const sql = `
|
||
SELECT
|
||
so.time,
|
||
t.id AS transceiver_id,
|
||
t.part_number,
|
||
t.form_factor,
|
||
t.speed,
|
||
v.name AS vendor_name,
|
||
v.website AS vendor_website,
|
||
so.in_stock,
|
||
so.quantity_available,
|
||
so.warehouse_de_qty,
|
||
so.warehouse_de_delivery_date,
|
||
so.warehouse_global_qty,
|
||
so.warehouse_global_delivery_date,
|
||
so.backorder_qty,
|
||
so.backorder_estimated_date,
|
||
so.units_sold,
|
||
so.compatible_brands,
|
||
so.price_net,
|
||
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
|
||
ORDER BY transceiver_id, source_vendor_id, time DESC
|
||
) so
|
||
JOIN transceivers t ON t.id = so.transceiver_id
|
||
JOIN vendors v ON v.id = so.source_vendor_id
|
||
WHERE 1=1 ${whereClause}
|
||
ORDER BY so.time DESC
|
||
LIMIT $${p++} OFFSET $${p++}
|
||
`;
|
||
params.push(limit, offset);
|
||
|
||
const countSql = `
|
||
SELECT COUNT(*) FROM (
|
||
SELECT DISTINCT ON (transceiver_id, source_vendor_id) *
|
||
FROM stock_observations
|
||
ORDER BY transceiver_id, source_vendor_id, time DESC
|
||
) so
|
||
JOIN transceivers t ON t.id = so.transceiver_id
|
||
JOIN vendors v ON v.id = so.source_vendor_id
|
||
WHERE 1=1 ${whereClause}
|
||
`;
|
||
|
||
const [rows, countRow] = await Promise.all([
|
||
pool.query(sql, params),
|
||
pool.query(countSql, params.slice(0, params.length - 2)),
|
||
]);
|
||
|
||
res.json({
|
||
success: true,
|
||
data: rows.rows,
|
||
meta: {
|
||
total: parseInt(countRow.rows[0].count, 10),
|
||
limit,
|
||
offset,
|
||
},
|
||
});
|
||
} catch (err) {
|
||
console.error("GET /api/stock error:", err);
|
||
res.status(500).json({ success: false, error: "Internal server error" });
|
||
}
|
||
});
|
||
|
||
// ─── GET /api/stock/summary ──────────────────────────────────────────────────
|
||
/**
|
||
* Aggregate stats across all latest stock observations.
|
||
* Returns totals per warehouse tier, top sellers, and per-vendor breakdown.
|
||
*/
|
||
stockRouter.get("/summary", async (req: Request, res: Response) => {
|
||
try {
|
||
const [totals, topSellers, vendorBreakdown, recentlyUpdated, priceComparison] = await Promise.all([
|
||
// Overall totals from latest observations
|
||
pool.query(`
|
||
WITH latest AS (
|
||
SELECT DISTINCT ON (transceiver_id, source_vendor_id) *
|
||
FROM stock_observations
|
||
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,
|
||
-- 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
|
||
`),
|
||
|
||
// Top sellers by units_sold
|
||
pool.query(`
|
||
WITH latest AS (
|
||
SELECT DISTINCT ON (transceiver_id, source_vendor_id) *
|
||
FROM stock_observations
|
||
WHERE units_sold IS NOT NULL
|
||
ORDER BY transceiver_id, source_vendor_id, time DESC
|
||
)
|
||
SELECT
|
||
t.part_number,
|
||
t.form_factor,
|
||
t.speed,
|
||
v.name AS vendor_name,
|
||
so.units_sold,
|
||
so.warehouse_de_qty,
|
||
so.warehouse_global_qty,
|
||
so.price_net,
|
||
so.product_url
|
||
FROM latest so
|
||
JOIN transceivers t ON t.id = so.transceiver_id
|
||
JOIN vendors v ON v.id = so.source_vendor_id
|
||
ORDER BY so.units_sold DESC
|
||
LIMIT 20
|
||
`),
|
||
|
||
// Per-vendor stock breakdown (incl. confidence + currency breakdown)
|
||
pool.query(`
|
||
WITH latest AS (
|
||
SELECT DISTINCT ON (transceiver_id, source_vendor_id) *
|
||
FROM stock_observations
|
||
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,
|
||
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
|
||
ORDER BY product_count DESC
|
||
`),
|
||
|
||
// Recently restocked (stock appeared in last 24h)
|
||
pool.query(`
|
||
SELECT
|
||
t.part_number,
|
||
t.form_factor,
|
||
t.speed,
|
||
v.name AS vendor_name,
|
||
so.warehouse_de_qty,
|
||
so.warehouse_global_qty,
|
||
so.time AS observed_at
|
||
FROM stock_observations so
|
||
JOIN transceivers t ON t.id = so.transceiver_id
|
||
JOIN vendors v ON v.id = so.source_vendor_id
|
||
WHERE so.time >= NOW() - INTERVAL '24 hours'
|
||
AND so.in_stock = true
|
||
AND (so.warehouse_de_qty > 0 OR so.warehouse_global_qty > 0)
|
||
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({
|
||
success: true,
|
||
data: {
|
||
totals: totals.rows[0],
|
||
top_sellers: topSellers.rows,
|
||
vendor_breakdown: vendorBreakdown.rows,
|
||
recently_updated: recentlyUpdated.rows,
|
||
price_comparison: priceComparison.rows,
|
||
},
|
||
});
|
||
} catch (err) {
|
||
console.error("GET /api/stock/summary error:", err);
|
||
res.status(500).json({ success: false, error: "Internal server error" });
|
||
}
|
||
});
|
||
|
||
// ─── GET /api/stock/:id ──────────────────────────────────────────────────────
|
||
/**
|
||
* Full observation history for one transceiver.
|
||
* :id can be a UUID or a part_number (case-insensitive).
|
||
* Query params:
|
||
* vendor_id — filter by vendor UUID
|
||
* days — look-back window in days (default 30)
|
||
* limit — max observations returned (default 100)
|
||
*/
|
||
stockRouter.get("/:id", async (req: Request, res: Response) => {
|
||
try {
|
||
const id = String(req.params.id);
|
||
const days = intParam(req, "days", 30);
|
||
const limit = Math.min(intParam(req, "limit", 100), 500);
|
||
const vendorId = req.query.vendor_id ? String(req.query.vendor_id) : null;
|
||
|
||
// Resolve UUID vs part_number
|
||
let transceiverUuid: string | null = null;
|
||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||
if (uuidRegex.test(id)) {
|
||
transceiverUuid = id;
|
||
} else {
|
||
const r = await pool.query(
|
||
`SELECT id FROM transceivers WHERE part_number ILIKE $1 LIMIT 1`,
|
||
[id]
|
||
);
|
||
if (r.rows.length > 0) transceiverUuid = r.rows[0].id;
|
||
}
|
||
|
||
if (!transceiverUuid) {
|
||
res.status(404).json({ success: false, error: "Transceiver not found" });
|
||
return;
|
||
}
|
||
|
||
const params: unknown[] = [transceiverUuid, days, limit];
|
||
let vendorFilter = "";
|
||
if (vendorId) {
|
||
params.push(vendorId);
|
||
vendorFilter = `AND so.source_vendor_id = $${params.length}`;
|
||
}
|
||
|
||
const [transceiver, observations] = await Promise.all([
|
||
pool.query(
|
||
`SELECT t.*, v.name AS brand_name
|
||
FROM transceivers t LEFT JOIN vendors v ON v.id = t.brand_vendor_id
|
||
WHERE t.id = $1`,
|
||
[transceiverUuid]
|
||
),
|
||
pool.query(
|
||
`SELECT
|
||
so.time,
|
||
v.name AS vendor_name,
|
||
v.website AS vendor_website,
|
||
so.in_stock,
|
||
so.quantity_available,
|
||
so.warehouse_de_qty,
|
||
so.warehouse_de_delivery_date,
|
||
so.warehouse_global_qty,
|
||
so.warehouse_global_delivery_date,
|
||
so.backorder_qty,
|
||
so.backorder_estimated_date,
|
||
so.units_sold,
|
||
so.compatible_brands,
|
||
so.price_net,
|
||
so.product_url
|
||
FROM stock_observations so
|
||
JOIN vendors v ON v.id = so.source_vendor_id
|
||
WHERE so.transceiver_id = $1
|
||
AND so.time >= NOW() - ($2 || ' days')::INTERVAL
|
||
${vendorFilter}
|
||
ORDER BY so.time DESC
|
||
LIMIT $3`,
|
||
params
|
||
),
|
||
]);
|
||
|
||
if (!transceiver.rows[0]) {
|
||
res.status(404).json({ success: false, error: "Transceiver not found" });
|
||
return;
|
||
}
|
||
|
||
res.json({
|
||
success: true,
|
||
data: {
|
||
transceiver: transceiver.rows[0],
|
||
observations: observations.rows,
|
||
meta: {
|
||
count: observations.rows.length,
|
||
days_requested: days,
|
||
},
|
||
},
|
||
});
|
||
} catch (err) {
|
||
console.error("GET /api/stock/:id error:", err);
|
||
res.status(500).json({ success: false, error: "Internal server error" });
|
||
}
|
||
});
|