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.
349 lines
13 KiB
TypeScript
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" });
|
|
});
|