618 lines
23 KiB
TypeScript
618 lines
23 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/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" });
|
||
}
|
||
});
|