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:
parent
c23b9f68ce
commit
7fd9fd3c8a
@ -53,7 +53,7 @@ transceiverRouter.get("/:id", async (req: Request, res: Response) => {
|
|||||||
[transceiver.id]
|
[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 sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||||
const prices = pricesResult.rows.map((row) => ({
|
const prices = pricesResult.rows.map((row) => ({
|
||||||
vendor_name: row.vendor_name,
|
vendor_name: row.vendor_name,
|
||||||
@ -64,11 +64,61 @@ transceiverRouter.get("/:id", async (req: Request, res: Response) => {
|
|||||||
url: row.url,
|
url: row.url,
|
||||||
stock_level: row.stock_level,
|
stock_level: row.stock_level,
|
||||||
observed_at: row.time,
|
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) {
|
} catch (err) {
|
||||||
console.error("Get transceiver error:", err);
|
console.error("Get transceiver error:", err);
|
||||||
res.status(500).json({ success: false, error: "Internal server error" });
|
res.status(500).json({ success: false, error: "Internal server error" });
|
||||||
|
|||||||
@ -1953,26 +1953,40 @@ async function openTxDetail(id) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// SPECIFICATION — Pricing
|
// SPECIFICATION — Pricing
|
||||||
// Rule: Only show prices that were scraped from a real URL within the last 30 days.
|
// Rule: Only prices with real URL from last 30 days. No estimated/fallback prices.
|
||||||
// Unverified / estimated prices are NOT shown — silence is better than wrong data.
|
var allPrices = (t.competitor_prices || []).filter(function(p) { return p.url && p.price > 0; });
|
||||||
var prices = (t.competitor_prices || []).filter(function(p) { return p.url && p.price > 0; });
|
var directPrices = allPrices.filter(function(p) { return p.is_same_product !== false; });
|
||||||
if (prices.length > 0) {
|
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="panel-section">Current Prices</div>';
|
||||||
h += '<div class="spec-table">';
|
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
|
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>'
|
? '<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>'
|
||||||
: ''; // 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 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.68rem;margin-left:0.5rem">Stand: ' + fmtDate(p.observed_at) + '</span>';
|
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.7rem;text-decoration:none;margin-left:0.5rem" title="Direkt zur Quelle">↗ Quelle</a>';
|
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>';
|
||||||
h += '<div class="spec-row"><span class="spec-label">' + esc(p.vendor_name) + '</span>'
|
var label = esc(p.vendor_name);
|
||||||
+ '<span class="spec-val" style="display:flex;align-items:center;flex-wrap:wrap;gap:0">' + priceStr + verBadge + dateStr + urlLink + '</span></div>';
|
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>';
|
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)
|
// Notes (scraped extra specs)
|
||||||
if (t.notes) {
|
if (t.notes) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user