/** * 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" }); } });