feat: competitor price comparison in transceiver detail

- API: also returns comparable_prices from technically equivalent products
  (same form_factor + speed_gbps + reach ±25%, different vendor, last 30 days)
- Dashboard: direct prices shown first, then separator + comparable products
- Comparable entries show vendor + exact part number scraped from their site
- Verified badge = real URL + observed within 7 days (strict)
This commit is contained in:
Rene Fichtmueller 2026-04-01 21:08:09 +02:00
parent 91ec2b0daa
commit be90984905
2 changed files with 83 additions and 19 deletions

View File

@ -53,7 +53,7 @@ transceiverRouter.get("/:id", async (req: Request, res: Response) => {
[transceiver.id]
);
// Flag: price is only "verified" if also observed within 7 days
// Flag: price is only "verified" if observed within 7 days with real URL
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const prices = pricesResult.rows.map((row) => ({
vendor_name: row.vendor_name,
@ -64,11 +64,61 @@ transceiverRouter.get("/:id", async (req: Request, res: Response) => {
url: row.url,
stock_level: row.stock_level,
observed_at: row.time,
// Verified = scraped from real URL + observed within 7 days
is_verified: row.url && new Date(row.time) > sevenDaysAgo,
is_verified: !!(row.url && new Date(row.time) > sevenDaysAgo),
is_same_product: true,
}));
res.json({ success: true, data: { ...transceiver, competitor_prices: prices } });
// Market comparison: technically equivalent products from other vendors
// Same form_factor + speed_gbps + reach within 20% tolerance, different vendor
// Only real scraped prices (url IS NOT NULL), last 30 days, max 1 per source vendor
const comparableResult = await pool.query(
`SELECT DISTINCT ON (po.source_vendor_id)
po.price, po.currency, po.url, po.time,
sv.name AS vendor_name, sv.type AS vendor_type,
t2.part_number, t2.standard_name, t2.id AS comparable_id
FROM transceivers t1
JOIN transceivers t2 ON (
t2.form_factor = t1.form_factor
AND t2.speed_gbps = t1.speed_gbps
AND (
t1.reach_meters IS NULL OR t2.reach_meters IS NULL
OR ABS(t2.reach_meters - t1.reach_meters) <= GREATEST(t1.reach_meters, 1) * 0.25
)
AND t2.id != t1.id
)
JOIN price_observations po ON po.transceiver_id = t2.id
JOIN vendors sv ON po.source_vendor_id = sv.id
WHERE t1.id = $1
AND po.time > NOW() - INTERVAL '30 days'
AND po.price > 0
AND po.url IS NOT NULL
-- Exclude vendors that already appear in direct prices
AND sv.id NOT IN (
SELECT source_vendor_id FROM price_observations
WHERE transceiver_id = $1 AND time > NOW() - INTERVAL '30 days'
)
ORDER BY po.source_vendor_id, po.time DESC
LIMIT 10`,
[transceiver.id]
);
const comparablePrices = comparableResult.rows.map((row) => ({
vendor_name: row.vendor_name,
vendor_type: row.vendor_type,
price: parseFloat(row.price),
currency: row.currency,
url: row.url,
observed_at: row.time,
is_verified: !!(row.url && new Date(row.time) > sevenDaysAgo),
is_same_product: false, // different SKU, same spec class
comparable_part: row.part_number || row.standard_name,
comparable_id: row.comparable_id,
}));
res.json({
success: true,
data: { ...transceiver, competitor_prices: [...prices, ...comparablePrices] },
});
} catch (err) {
console.error("Get transceiver error:", err);
res.status(500).json({ success: false, error: "Internal server error" });

View File

@ -1953,26 +1953,40 @@ async function openTxDetail(id) {
]);
// SPECIFICATION — Pricing
// Rule: Only show prices that were scraped from a real URL within the last 30 days.
// Unverified / estimated prices are NOT shown — silence is better than wrong data.
var prices = (t.competitor_prices || []).filter(function(p) { return p.url && p.price > 0; });
if (prices.length > 0) {
// Rule: Only prices with real URL from last 30 days. No estimated/fallback prices.
var allPrices = (t.competitor_prices || []).filter(function(p) { return p.url && p.price > 0; });
var directPrices = allPrices.filter(function(p) { return p.is_same_product !== false; });
var comparPrices = allPrices.filter(function(p) { return p.is_same_product === false; });
if (allPrices.length > 0) {
h += '<div class="panel-section">Current Prices</div>';
h += '<div class="spec-table">';
prices.forEach(function(p) {
// Verified badge only if observed within 7 days AND has a real URL
function renderPriceRow(p) {
var verBadge = p.is_verified === true
? '<span style="color:#2d6a4f;font-size:0.7rem;font-weight:600;margin-left:0.5rem" title="Price scraped from official vendor page within the last 7 days">✓ Verified Price</span>'
: ''; // No badge for older observations — not wrong, just not shown as verified
var priceStr = '<strong>' + p.currency + '\u00a0' + parseFloat(p.price).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + '</strong>';
var dateStr = '<span style="color:#aaa;font-size:0.68rem;margin-left:0.5rem">Stand: ' + fmtDate(p.observed_at) + '</span>';
var urlLink = '<a href="' + esc(p.url) + '" target="_blank" rel="noopener" style="color:var(--accent);font-size:0.7rem;text-decoration:none;margin-left:0.5rem" title="Direkt zur Quelle">↗ Quelle</a>';
h += '<div class="spec-row"><span class="spec-label">' + esc(p.vendor_name) + '</span>'
+ '<span class="spec-val" style="display:flex;align-items:center;flex-wrap:wrap;gap:0">' + priceStr + verBadge + dateStr + urlLink + '</span></div>';
});
? '<span style="color:#2d6a4f;font-size:0.68rem;font-weight:600;margin-left:0.5rem" title="Scraped from official vendor page, max. 7 days old">✓ Verified</span>'
: '';
var priceStr = '<strong style="font-size:0.9rem">' + p.currency + '\u00a0' + parseFloat(p.price).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + '</strong>';
var dateStr = '<span style="color:#aaa;font-size:0.67rem;margin-left:0.5rem">Stand: ' + fmtDate(p.observed_at) + '</span>';
var urlLink = '<a href="' + esc(p.url) + '" target="_blank" rel="noopener" style="color:var(--accent);font-size:0.68rem;text-decoration:none;margin-left:0.5rem"></a>';
var label = esc(p.vendor_name);
if (p.is_same_product === false && p.comparable_part) {
label += '<div style="font-size:0.62rem;color:#aaa;font-weight:400;margin-top:1px" title="Equivalent product, different SKU">' + esc(p.comparable_part) + '</div>';
}
return '<div class="spec-row"><span class="spec-label">' + label + '</span>'
+ '<span class="spec-val" style="display:flex;align-items:center;flex-wrap:wrap">' + priceStr + verBadge + dateStr + urlLink + '</span></div>';
}
directPrices.forEach(function(p) { h += renderPriceRow(p); });
if (comparPrices.length > 0) {
h += '<div style="font-size:0.7rem;color:#888;margin:0.5rem 0 0.25rem;padding-top:0.4rem;border-top:1px solid var(--border)">Vergleichbare Produkte anderer Hersteller (gleiche Spezifikation)</div>';
comparPrices.forEach(function(p) { h += renderPriceRow(p); });
}
h += '</div>';
}
// No price_observations → show nothing. Never display estimated or unverifiable prices.
// No price_observations → show nothing. Never display estimated prices.
// Notes (scraped extra specs)
if (t.notes) {