618 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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