Rene Fichtmueller f8809d999f feat(scraper+api): warehouse stock data pipeline — FS.com v2, SmartOptics v2, Stock API
Scraper changes:
- fs-com.ts v2: Playwright stealth patches + www.fs.com/de/ URL fix (de.fs.com DNS NXDOMAIN).
  Extracts DE-Lager, Global-Lager, Nachlieferung, units_sold, compatible_brands, price_net.
  Mac-side runner (run-fs-scraper-mac.sh) via SSH tunnel for residential IP access.
  Fast-fail connectivity check on datacenter IPs that are blocked by Cloudflare.
- smartoptics.ts v2: WooCommerce REST API fallback + 8 catalog categories + relative URL fix.
  Was finding only 8 products, now discovers 18+ with multi-category crawl.

DB layer:
- db.ts: add upsertStockObservation() — writes 10 new stock_observations columns
  (warehouse_de_qty, warehouse_global_qty, backorder_qty, units_sold, compatible_brands,
  price_net, product_url, delivery dates) with dedup check.

API:
- routes/stock.ts: GET /api/stock, /api/stock/summary, /api/stock/:id
  Warehouse breakdowns per transceiver/vendor with top-sellers and vendor summary.
- routes/review.ts: equivalence review queue (approve/reject/bulk-approve).
- index.ts: register /api/stock and /api/review routes.

Dashboard:
- index.html: 🏭 Stock tab with stat cards (DE-Lager, Global-Lager, Nachlieferung totals),
  top-sellers table, vendor breakdown, recently-restocked events, part-number lookup.

SQL migrations:
- 034: blog-review-tag, 035: price-observations is_anomalous, 036: transceiver-equivalences.
2026-04-17 10:45:59 +02:00

349 lines
13 KiB
TypeScript

/**
* Manual Review API — Transceiver Equivalence Review Queue
*
* GET /api/review/equivalences — list (filter by status)
* GET /api/review/equivalences/stats — pending/approved/rejected counts
* POST /api/review/equivalences/:id/approve — approve + set competitor_verified
* POST /api/review/equivalences/:id/reject — reject with optional reason
* PATCH /api/review/equivalences/:id — edit match_notes
* POST /api/review/run-matcher — trigger equivalence job immediately
*/
import { Router, Request, Response } from "express";
import { pool } from "../db/client";
/** Promote to fully_verified if all 4 flags are set — shared logic */
async function checkAndSetFullyVerified(transceiverId: string): Promise<boolean> {
const result = await pool.query(
`UPDATE transceivers
SET fully_verified = true,
fully_verified_at = COALESCE(fully_verified_at, NOW())
WHERE id = $1
AND price_verified = true AND image_verified = true
AND details_verified = true AND competitor_verified = true
AND (fully_verified IS NULL OR fully_verified = false)
RETURNING id`,
[transceiverId]
);
return (result.rowCount ?? 0) > 0;
}
export const reviewRouter = Router();
// ── GET /api/review/equivalences ──────────────────────────────────────────────
reviewRouter.get("/equivalences", async (req: Request, res: Response) => {
const status = (req.query.status as string) || "pending";
const page = Math.max(1, parseInt(req.query.page as string, 10) || 1);
const limit = Math.min(100, parseInt(req.query.limit as string, 10) || 50);
const offset = (page - 1) * limit;
const validStatuses = ["pending", "approved", "rejected", "auto_approved", "all", "needs_research"];
if (!validStatuses.includes(status)) {
res.status(400).json({ success: false, error: "Invalid status filter" });
return;
}
let where: string;
let params: unknown[];
let limitIdx: number;
let offsetIdx: number;
if (status === "all") {
where = "";
params = [limit, offset];
limitIdx = 1; offsetIdx = 2;
} else if (status === "needs_research") {
where = `WHERE eq.status IN ('approved','auto_approved') AND eq.re_research_due_at IS NOT NULL AND eq.re_research_due_at <= NOW()`;
params = [limit, offset];
limitIdx = 1; offsetIdx = 2;
} else {
where = `WHERE eq.status = $1`;
params = [status, limit, offset];
limitIdx = 2; offsetIdx = 3;
}
const rows = await pool.query(`
SELECT
eq.id,
eq.confidence,
eq.match_basis,
eq.match_notes,
eq.status,
eq.reviewed_by,
eq.reviewed_at,
eq.reject_reason,
eq.re_research_due_at,
eq.re_researched_at,
eq.created_at,
eq.updated_at,
-- Flexoptix transceiver
fx.id AS fx_id,
fx.part_number AS fx_part_number,
fx.standard_name AS fx_standard_name,
fx.form_factor AS fx_form_factor,
fx.speed AS fx_speed,
fx.speed_gbps AS fx_speed_gbps,
fx.fiber_type AS fx_fiber_type,
fx.reach_meters AS fx_reach_meters,
fx.reach_label AS fx_reach_label,
fx.wavelengths AS fx_wavelengths,
fx.connector AS fx_connector,
fx.product_page_url AS fx_url,
fxv.name AS fx_vendor,
-- Competitor transceiver
cp.id AS cp_id,
cp.part_number AS cp_part_number,
cp.standard_name AS cp_standard_name,
cp.form_factor AS cp_form_factor,
cp.speed AS cp_speed,
cp.speed_gbps AS cp_speed_gbps,
cp.fiber_type AS cp_fiber_type,
cp.reach_meters AS cp_reach_meters,
cp.reach_label AS cp_reach_label,
cp.wavelengths AS cp_wavelengths,
cp.connector AS cp_connector,
cp.product_page_url AS cp_url,
cpv.name AS cp_vendor,
-- Latest competitor price
(SELECT po.price FROM price_observations po
WHERE po.transceiver_id = cp.id
AND po.time > NOW() - INTERVAL '30 days'
ORDER BY po.time DESC LIMIT 1) AS cp_latest_price,
(SELECT po.currency FROM price_observations po
WHERE po.transceiver_id = cp.id
AND po.time > NOW() - INTERVAL '30 days'
ORDER BY po.time DESC LIMIT 1) AS cp_latest_currency
FROM transceiver_equivalences eq
JOIN transceivers fx ON fx.id = eq.flexoptix_id
JOIN vendors fxv ON fxv.id = fx.vendor_id
JOIN transceivers cp ON cp.id = eq.competitor_id
JOIN vendors cpv ON cpv.id = cp.vendor_id
${where}
ORDER BY eq.confidence DESC, eq.created_at DESC
LIMIT $${limitIdx} OFFSET $${offsetIdx}
`, params);
const countResult = await pool.query(
`SELECT COUNT(*) FROM transceiver_equivalences eq ${where}`,
(status === "all" || status === "needs_research") ? [] : [status]
);
res.json({
success: true,
data: rows.rows,
total: parseInt(countResult.rows[0].count, 10),
page,
limit,
});
});
// ── GET /api/review/equivalences/stats ────────────────────────────────────────
reviewRouter.get("/equivalences/stats", async (_req: Request, res: Response) => {
const result = await pool.query(`
SELECT
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) AS approved,
SUM(CASE WHEN status = 'auto_approved' THEN 1 ELSE 0 END) AS auto_approved,
SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) AS rejected,
SUM(CASE WHEN status IN ('approved','auto_approved')
AND re_research_due_at IS NOT NULL
AND re_research_due_at <= NOW() THEN 1 ELSE 0 END) AS needs_research,
COUNT(*) AS total
FROM transceiver_equivalences
`);
const row = result.rows[0];
res.json({
success: true,
stats: {
pending: parseInt(row.pending, 10) || 0,
approved: parseInt(row.approved, 10) || 0,
auto_approved: parseInt(row.auto_approved, 10) || 0,
rejected: parseInt(row.rejected, 10) || 0,
needs_research: parseInt(row.needs_research, 10) || 0,
total: parseInt(row.total, 10) || 0,
},
});
});
// ── POST /api/review/equivalences/:id/approve ─────────────────────────────────
reviewRouter.post("/equivalences/:id/approve", async (req: Request, res: Response) => {
const { id } = req.params;
const reviewer = (req.body as { reviewer?: string }).reviewer || "manual";
// Fetch the equivalence to get flexoptix_id
const eq = await pool.query(
`SELECT * FROM transceiver_equivalences WHERE id = $1`,
[id]
);
if (!eq.rows[0]) {
res.status(404).json({ success: false, error: "Not found" });
return;
}
const { flexoptix_id } = eq.rows[0] as { flexoptix_id: string };
// Mark approved
await pool.query(`
UPDATE transceiver_equivalences
SET status = 'approved', reviewed_by = $2, reviewed_at = NOW()
WHERE id = $1
`, [id, reviewer]);
// Set competitor_verified on the Flexoptix transceiver
await pool.query(`
UPDATE transceivers
SET competitor_verified = true,
competitor_verified_at = NOW()
WHERE id = $1
`, [flexoptix_id]);
// Promote to fully_verified if all 4 flags are now set
const fullyVerifiedEarned = await checkAndSetFullyVerified(flexoptix_id);
res.json({
success: true,
fully_verified_earned: fullyVerifiedEarned,
});
});
// ── POST /api/review/equivalences/:id/reject ──────────────────────────────────
reviewRouter.post("/equivalences/:id/reject", async (req: Request, res: Response) => {
const { id } = req.params;
const { reason, reviewer } = req.body as { reason?: string; reviewer?: string };
const result = await pool.query(`
UPDATE transceiver_equivalences
SET status = 'rejected',
reject_reason = $2,
reviewed_by = $3,
reviewed_at = NOW()
WHERE id = $1
RETURNING id
`, [id, reason || null, reviewer || "manual"]);
if (!result.rowCount) {
res.status(404).json({ success: false, error: "Not found" });
return;
}
res.json({ success: true });
});
// ── PATCH /api/review/equivalences/:id ────────────────────────────────────────
reviewRouter.patch("/equivalences/:id", async (req: Request, res: Response) => {
const { id } = req.params;
const { match_notes } = req.body as { match_notes?: string };
if (match_notes === undefined) {
res.status(400).json({ success: false, error: "match_notes required" });
return;
}
const result = await pool.query(`
UPDATE transceiver_equivalences
SET match_notes = $2, updated_at = NOW()
WHERE id = $1
RETURNING id
`, [id, match_notes]);
if (!result.rowCount) {
res.status(404).json({ success: false, error: "Not found" });
return;
}
res.json({ success: true });
});
// ── POST /api/review/equivalences/approve-all ─────────────────────────────────
// Approve ALL pending equivalences regardless of confidence.
// Low-confidence ones (< 0.73) get re_research_due_at = NOW() so the nightly
// re-research job will re-verify them one by one.
reviewRouter.post("/equivalences/approve-all", async (req: Request, res: Response) => {
const reviewer = (req.body as { reviewer?: string }).reviewer || "approve-all";
const RE_RESEARCH_THRESHOLD = 0.73;
const candidates = await pool.query(`
SELECT id, flexoptix_id, confidence FROM transceiver_equivalences WHERE status = 'pending'
`);
let approved = 0;
let fullyVerified = 0;
let scheduledReSearch = 0;
for (const row of candidates.rows) {
const needsReSearch = parseFloat(row.confidence) < RE_RESEARCH_THRESHOLD;
await pool.query(`
UPDATE transceiver_equivalences
SET status = 'approved',
reviewed_by = $2,
reviewed_at = NOW(),
re_research_due_at = $3,
re_researched_at = NULL
WHERE id = $1
`, [row.id, reviewer, needsReSearch ? new Date() : null]);
await pool.query(`
UPDATE transceivers
SET competitor_verified = true, competitor_verified_at = NOW()
WHERE id = $1 AND competitor_verified = false
`, [row.flexoptix_id]);
const earned = await checkAndSetFullyVerified(row.flexoptix_id);
if (earned) fullyVerified++;
if (needsReSearch) scheduledReSearch++;
approved++;
}
res.json({ success: true, approved, fully_verified_earned: fullyVerified, scheduled_re_research: scheduledReSearch });
});
// ── POST /api/review/equivalences/bulk-approve ────────────────────────────────
// Bulk-approve all pending equivalences with confidence >= threshold (default 0.73)
reviewRouter.post("/equivalences/bulk-approve", async (req: Request, res: Response) => {
const threshold = Math.max(0, Math.min(1, parseFloat((req.body as { threshold?: string }).threshold as string) || 0.73));
const reviewer = (req.body as { reviewer?: string }).reviewer || "bulk-dashboard";
// Fetch all pending records above threshold
const candidates = await pool.query(`
SELECT id, flexoptix_id
FROM transceiver_equivalences
WHERE status = 'pending' AND confidence >= $1
`, [threshold]);
let approved = 0;
let fullyVerified = 0;
for (const row of candidates.rows) {
await pool.query(`
UPDATE transceiver_equivalences
SET status = 'approved', reviewed_by = $2, reviewed_at = NOW()
WHERE id = $1
`, [row.id, reviewer]);
await pool.query(`
UPDATE transceivers
SET competitor_verified = true, competitor_verified_at = NOW()
WHERE id = $1 AND competitor_verified = false
`, [row.flexoptix_id]);
const earned = await checkAndSetFullyVerified(row.flexoptix_id);
if (earned) fullyVerified++;
approved++;
}
res.json({ success: true, approved, fully_verified_earned: fullyVerified, threshold });
});
// ── POST /api/review/run-matcher ──────────────────────────────────────────────
// Trigger the equivalence matcher immediately (admin action)
reviewRouter.post("/run-matcher", async (_req: Request, res: Response) => {
// Queue the job via pg-boss — import from scraper's db util won't work here,
// so we fire directly via DB insert into pg-boss queue
await pool.query(`
INSERT INTO pgboss.job (name, data, priority)
VALUES ('maintenance:find-equivalences', '{}', 0)
ON CONFLICT DO NOTHING
`);
res.json({ success: true, message: "Equivalence matcher queued" });
});