/** * 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/velocity — Abverkauf velocity results (paginated, filterable) * GET /api/stock/velocity/:id — Velocity + event history for one transceiver * 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/velocity ───────────────────────────────────────────────── /** * Paginated Abverkauf velocity results from the stock_velocity table. * Query params: * vendor_id — filter by vendor UUID * confidence — "high" | "medium" | "low" | "insufficient" * stockout_days — only products with estimated_stockout_days <= N (0 = already out) * min_sell_rate — minimum avg_daily_sell_rate * part_number — partial match * limit — default 50, max 200 * offset — default 0 */ stockRouter.get("/velocity", 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 confidence = req.query.confidence ? String(req.query.confidence) : null; const stockoutDays = req.query.stockout_days !== undefined ? parseInt(String(req.query.stockout_days), 10) : null; const minSellRate = req.query.min_sell_rate ? parseFloat(String(req.query.min_sell_rate)) : 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(`sv.vendor_id = $${p++}`); params.push(vendorId); } if (confidence) { conditions.push(`sv.velocity_confidence = $${p++}`); params.push(confidence); } if (stockoutDays !== null && Number.isFinite(stockoutDays)) { conditions.push(`sv.estimated_stockout_days <= $${p++}`); params.push(stockoutDays); } if (minSellRate !== null && Number.isFinite(minSellRate)) { conditions.push(`sv.avg_daily_sell_rate >= $${p++}`); params.push(minSellRate); } if (partNumber) { conditions.push(`t.part_number ILIKE $${p++}`); params.push(`%${partNumber}%`); } const whereClause = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; const sql = ` SELECT sv.transceiver_id, sv.vendor_id, sv.computed_at, sv.window_start, sv.window_end, sv.obs_count, sv.avg_daily_sell_rate, sv.peak_daily_sell_rate, sv.total_sell_events, sv.total_units_sold_implied, sv.units_sold_counter_delta, sv.units_sold_daily_rate, sv.total_zulauf_events, sv.total_units_zulauf, sv.last_zulauf_at, sv.next_expected_delivery, sv.current_qty, sv.current_backorder_qty, sv.current_price_net, sv.estimated_stockout_days, sv.estimated_stockout_date, sv.velocity_confidence, t.part_number, t.form_factor, t.speed, v.name AS vendor_name, v.website AS vendor_website FROM stock_velocity sv JOIN transceivers t ON t.id = sv.transceiver_id JOIN vendors v ON v.id = sv.vendor_id ${whereClause} ORDER BY CASE sv.velocity_confidence WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 WHEN 'insufficient' THEN 4 ELSE 5 END, sv.avg_daily_sell_rate DESC NULLS LAST LIMIT $${p++} OFFSET $${p++} `; params.push(limit, offset); const countSql = ` SELECT COUNT(*) FROM stock_velocity sv JOIN transceivers t ON t.id = sv.transceiver_id JOIN vendors v ON v.id = sv.vendor_id ${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/velocity error:", err); res.status(500).json({ success: false, error: "Internal server error" }); } }); // ─── GET /api/stock/velocity/:id ───────────────────────────────────────────── /** * Velocity summary + raw event history for one transceiver. * :id can be a UUID or part_number (case-insensitive). * Query params: * vendor_id — filter to a specific vendor (optional; returns all vendors if omitted) * event_limit — max events returned per vendor (default 200) */ stockRouter.get("/velocity/:id", async (req: Request, res: Response) => { try { const id = String(req.params.id); const vendorId = req.query.vendor_id ? String(req.query.vendor_id) : null; const eventLimit = Math.min(intParam(req, "event_limit", 200), 1000); // 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 velocityParams: unknown[] = [transceiverUuid]; let vendorFilter = ""; if (vendorId) { velocityParams.push(vendorId); vendorFilter = `AND sv.vendor_id = $${velocityParams.length}`; } const eventParams: unknown[] = [transceiverUuid, eventLimit]; let eventVendorFilter = ""; if (vendorId) { eventParams.push(vendorId); eventVendorFilter = `AND sve.vendor_id = $${eventParams.length}`; } const [transceiver, velocity, events] = 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 sv.*, v.name AS vendor_name, v.website AS vendor_website FROM stock_velocity sv JOIN vendors v ON v.id = sv.vendor_id WHERE sv.transceiver_id = $1 ${vendorFilter} ORDER BY sv.velocity_confidence, sv.avg_daily_sell_rate DESC NULLS LAST`, velocityParams ), pool.query( `SELECT sve.event_at, sve.event_type, sve.units_delta, sve.daily_rate, sve.qty_before, sve.qty_after, sve.hours_elapsed, v.name AS vendor_name FROM stock_velocity_events sve JOIN vendors v ON v.id = sve.vendor_id WHERE sve.transceiver_id = $1 ${eventVendorFilter} ORDER BY sve.event_at DESC LIMIT $2`, eventParams ), ]); if (!transceiver.rows[0]) { res.status(404).json({ success: false, error: "Transceiver not found" }); return; } res.json({ success: true, data: { transceiver: transceiver.rows[0], velocity: velocity.rows, events: events.rows, meta: { velocity_count: velocity.rows.length, event_count: events.rows.length, }, }, }); } catch (err) { console.error("GET /api/stock/velocity/:id 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" }); } });