|
|
|
@@ -0,0 +1,270 @@
|
|
|
|
|
const express = require('express');
|
|
|
|
|
const router = express.Router();
|
|
|
|
|
|
|
|
|
|
const MAX_MATCHES = 500;
|
|
|
|
|
const DESCRIPTION_SAMPLE_LIMIT = 8;
|
|
|
|
|
|
|
|
|
|
// GET /api/spec-lookup?company=...&term=...
|
|
|
|
|
// Returns aggregated specs across products matching company (brand) and term (title).
|
|
|
|
|
router.get('/', async (req, res) => {
|
|
|
|
|
const company = typeof req.query.company === 'string' ? req.query.company.trim() : '';
|
|
|
|
|
const term = typeof req.query.term === 'string' ? req.query.term.trim() : '';
|
|
|
|
|
|
|
|
|
|
if (!company && !term) {
|
|
|
|
|
return res.status(400).json({ error: 'company or term is required' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const pool = req.app.locals.pool;
|
|
|
|
|
const conditions = [];
|
|
|
|
|
const params = [];
|
|
|
|
|
|
|
|
|
|
if (company) {
|
|
|
|
|
params.push(`%${company}%`);
|
|
|
|
|
conditions.push(`brand ILIKE $${params.length}`);
|
|
|
|
|
}
|
|
|
|
|
if (term) {
|
|
|
|
|
params.push(`%${term}%`);
|
|
|
|
|
conditions.push(`title ILIKE $${params.length}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
params.push(MAX_MATCHES);
|
|
|
|
|
const limitParam = `$${params.length}`;
|
|
|
|
|
|
|
|
|
|
const sql = `
|
|
|
|
|
SELECT
|
|
|
|
|
pid::TEXT AS pid,
|
|
|
|
|
title, sku, brand, vendor, artist,
|
|
|
|
|
country_of_origin, harmonized_tariff_code,
|
|
|
|
|
description, categories,
|
|
|
|
|
cost_price, regular_price,
|
|
|
|
|
moq, weight, length, width, height,
|
|
|
|
|
created_at
|
|
|
|
|
FROM products
|
|
|
|
|
WHERE ${conditions.join(' AND ')}
|
|
|
|
|
ORDER BY created_at DESC NULLS LAST
|
|
|
|
|
LIMIT ${limitParam}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const { rows } = await pool.query(sql, params);
|
|
|
|
|
|
|
|
|
|
// Resolve category cat_ids → names. products.categories is a comma-separated cat_id string.
|
|
|
|
|
const catIds = new Set();
|
|
|
|
|
for (const r of rows) {
|
|
|
|
|
if (!r.categories) continue;
|
|
|
|
|
for (const tok of String(r.categories).split(',')) {
|
|
|
|
|
const trimmed = tok.trim();
|
|
|
|
|
if (trimmed && /^\d+$/.test(trimmed)) catIds.add(trimmed);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Map cat_id → {name, type}. Types 10-13 are Section/Category/Subcategory/Sub-Subcategory; 20-21 are Theme/Subtheme.
|
|
|
|
|
const catIdToInfo = new Map();
|
|
|
|
|
if (catIds.size > 0) {
|
|
|
|
|
const { rows: catRows } = await pool.query(
|
|
|
|
|
`SELECT cat_id::TEXT AS cat_id, name, type FROM categories WHERE cat_id = ANY($1::bigint[])`,
|
|
|
|
|
[Array.from(catIds)],
|
|
|
|
|
);
|
|
|
|
|
for (const c of catRows) catIdToInfo.set(c.cat_id, { name: c.name, type: Number(c.type) });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const products = rows.map(r => ({
|
|
|
|
|
pid: Number(r.pid),
|
|
|
|
|
title: r.title,
|
|
|
|
|
sku: r.sku,
|
|
|
|
|
brand: r.brand,
|
|
|
|
|
vendor: r.vendor,
|
|
|
|
|
artist: r.artist,
|
|
|
|
|
country_of_origin: r.country_of_origin,
|
|
|
|
|
harmonized_tariff_code: r.harmonized_tariff_code,
|
|
|
|
|
description: r.description,
|
|
|
|
|
categories: r.categories,
|
|
|
|
|
cost_price: toNumberOrNull(r.cost_price),
|
|
|
|
|
regular_price: toNumberOrNull(r.regular_price),
|
|
|
|
|
moq: toNumberOrNull(r.moq),
|
|
|
|
|
weight: toNumberOrNull(r.weight),
|
|
|
|
|
length: toNumberOrNull(r.length),
|
|
|
|
|
width: toNumberOrNull(r.width),
|
|
|
|
|
height: toNumberOrNull(r.height),
|
|
|
|
|
created_at: r.created_at,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
company,
|
|
|
|
|
term,
|
|
|
|
|
total: products.length,
|
|
|
|
|
truncated: products.length === MAX_MATCHES,
|
|
|
|
|
products,
|
|
|
|
|
aggregates: {
|
|
|
|
|
numeric: {
|
|
|
|
|
cost_price: numericAggregate(products, 'cost_price'),
|
|
|
|
|
regular_price: numericAggregate(products, 'regular_price'),
|
|
|
|
|
moq: numericAggregate(products, 'moq'),
|
|
|
|
|
weight: numericAggregate(products, 'weight'),
|
|
|
|
|
length: numericAggregate(products, 'length'),
|
|
|
|
|
width: numericAggregate(products, 'width'),
|
|
|
|
|
height: numericAggregate(products, 'height'),
|
|
|
|
|
},
|
|
|
|
|
categorical: {
|
|
|
|
|
artist: categoricalAggregate(products, 'artist'),
|
|
|
|
|
country_of_origin: categoricalAggregate(products, 'country_of_origin'),
|
|
|
|
|
harmonized_tariff_code: categoricalAggregate(products, 'harmonized_tariff_code'),
|
|
|
|
|
},
|
|
|
|
|
categories: groupedAggregate(products, catIdToInfo, new Set([10, 11, 12, 13])),
|
|
|
|
|
themes: groupedAggregate(products, catIdToInfo, new Set([20, 21])),
|
|
|
|
|
description: descriptionAggregate(products),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error in spec-lookup:', error);
|
|
|
|
|
res.status(500).json({ error: 'Failed to compute spec lookup' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function toNumberOrNull(v) {
|
|
|
|
|
if (v === null || v === undefined) return null;
|
|
|
|
|
const n = Number(v);
|
|
|
|
|
return Number.isFinite(n) ? n : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Aggregate a numeric field. Treats null/0 as unset since 0 is the codebase's "no value" sentinel.
|
|
|
|
|
// `products` is assumed to be ordered most-recent-first (created_at DESC) so the head of the
|
|
|
|
|
// list is also the recency window we use for trend detection.
|
|
|
|
|
function numericAggregate(products, field) {
|
|
|
|
|
const values = [];
|
|
|
|
|
// Iterate products in order so we know which values came from the most-recent rows.
|
|
|
|
|
for (const p of products) {
|
|
|
|
|
const v = p[field];
|
|
|
|
|
if (typeof v === 'number' && Number.isFinite(v) && v > 0) values.push(v);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!values.length) {
|
|
|
|
|
return { count: 0, sample_size: products.length, distribution: [] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sorted = [...values].sort((a, b) => a - b);
|
|
|
|
|
const sum = values.reduce((s, v) => s + v, 0);
|
|
|
|
|
const avg = sum / values.length;
|
|
|
|
|
const mid = Math.floor(sorted.length / 2);
|
|
|
|
|
const median = sorted.length % 2 === 0
|
|
|
|
|
? (sorted[mid - 1] + sorted[mid]) / 2
|
|
|
|
|
: sorted[mid];
|
|
|
|
|
const variance = values.reduce((s, v) => s + (v - avg) ** 2, 0) / values.length;
|
|
|
|
|
const stddev = Math.sqrt(variance);
|
|
|
|
|
|
|
|
|
|
const counts = new Map();
|
|
|
|
|
for (const v of values) {
|
|
|
|
|
const key = roundForKey(v);
|
|
|
|
|
counts.set(key, (counts.get(key) || 0) + 1);
|
|
|
|
|
}
|
|
|
|
|
const distribution = Array.from(counts.entries())
|
|
|
|
|
.map(([value, count]) => ({ value, count }))
|
|
|
|
|
.sort((a, b) => b.count - a.count || a.value - b.value);
|
|
|
|
|
|
|
|
|
|
const mode = distribution[0]?.value ?? null;
|
|
|
|
|
const mode_count = distribution[0]?.count ?? 0;
|
|
|
|
|
|
|
|
|
|
// Trend detection: scan only the most-recent N values. N adapts to sample size so this
|
|
|
|
|
// can never look at more than ~20% of the data when the sample is small.
|
|
|
|
|
const recentN = Math.min(20, Math.max(5, Math.floor(values.length / 4)));
|
|
|
|
|
const recentValues = values.slice(0, recentN);
|
|
|
|
|
let recent_mode = null;
|
|
|
|
|
let recent_mode_count = 0;
|
|
|
|
|
let trending = false;
|
|
|
|
|
if (recentValues.length >= 3) {
|
|
|
|
|
const recentCounts = new Map();
|
|
|
|
|
for (const v of recentValues) {
|
|
|
|
|
const key = roundForKey(v);
|
|
|
|
|
recentCounts.set(key, (recentCounts.get(key) || 0) + 1);
|
|
|
|
|
}
|
|
|
|
|
const recentSorted = Array.from(recentCounts.entries()).sort((a, b) => b[1] - a[1] || a[0] - b[0]);
|
|
|
|
|
recent_mode = recentSorted[0][0];
|
|
|
|
|
recent_mode_count = recentSorted[0][1];
|
|
|
|
|
// Trend = recent mode differs from overall AND dominates the window AND has min absolute support.
|
|
|
|
|
const majority = recent_mode_count >= Math.ceil(recentValues.length * 0.6);
|
|
|
|
|
const minSupport = recent_mode_count >= 3;
|
|
|
|
|
trending = recent_mode !== mode && majority && minSupport;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
count: values.length,
|
|
|
|
|
sample_size: products.length,
|
|
|
|
|
avg,
|
|
|
|
|
median,
|
|
|
|
|
min: sorted[0],
|
|
|
|
|
max: sorted[sorted.length - 1],
|
|
|
|
|
stddev,
|
|
|
|
|
mode,
|
|
|
|
|
mode_count,
|
|
|
|
|
recent_mode,
|
|
|
|
|
recent_mode_count,
|
|
|
|
|
recent_window: recentValues.length,
|
|
|
|
|
trending,
|
|
|
|
|
distribution,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Round to 4 decimals so JS-FP noise doesn't fragment the histogram.
|
|
|
|
|
function roundForKey(v) {
|
|
|
|
|
return Math.round(v * 10000) / 10000;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function categoricalAggregate(products, field) {
|
|
|
|
|
const counts = new Map();
|
|
|
|
|
for (const p of products) {
|
|
|
|
|
const v = p[field];
|
|
|
|
|
if (v === null || v === undefined) continue;
|
|
|
|
|
const key = String(v).trim();
|
|
|
|
|
if (!key) continue;
|
|
|
|
|
counts.set(key, (counts.get(key) || 0) + 1);
|
|
|
|
|
}
|
|
|
|
|
return Array.from(counts.entries())
|
|
|
|
|
.map(([value, count]) => ({ value, count }))
|
|
|
|
|
.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Aggregate cat_id token counts, including only entries whose category type is in `acceptedTypes`.
|
|
|
|
|
function groupedAggregate(products, catIdToInfo, acceptedTypes) {
|
|
|
|
|
const counts = new Map();
|
|
|
|
|
for (const p of products) {
|
|
|
|
|
if (!p.categories) continue;
|
|
|
|
|
const tokens = String(p.categories).split(',').map(t => t.trim()).filter(Boolean);
|
|
|
|
|
for (const t of tokens) {
|
|
|
|
|
const info = catIdToInfo.get(t);
|
|
|
|
|
if (!info || !acceptedTypes.has(info.type)) continue;
|
|
|
|
|
counts.set(info.name, (counts.get(info.name) || 0) + 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return Array.from(counts.entries())
|
|
|
|
|
.map(([value, count]) => ({ value, count }))
|
|
|
|
|
.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function descriptionAggregate(products) {
|
|
|
|
|
const counts = new Map();
|
|
|
|
|
for (const p of products) {
|
|
|
|
|
if (!p.description) continue;
|
|
|
|
|
const key = String(p.description).trim();
|
|
|
|
|
if (!key) continue;
|
|
|
|
|
counts.set(key, (counts.get(key) || 0) + 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const duplicates = Array.from(counts.entries())
|
|
|
|
|
.filter(([, count]) => count > 1)
|
|
|
|
|
.map(([value, count]) => ({ value, count }))
|
|
|
|
|
.sort((a, b) => b.count - a.count);
|
|
|
|
|
|
|
|
|
|
// Recent unique samples (products are already ordered by created_at DESC).
|
|
|
|
|
const seen = new Set();
|
|
|
|
|
const samples = [];
|
|
|
|
|
for (const p of products) {
|
|
|
|
|
const desc = (p.description || '').trim();
|
|
|
|
|
if (!desc || seen.has(desc)) continue;
|
|
|
|
|
seen.add(desc);
|
|
|
|
|
samples.push({ value: desc, title: p.title, pid: p.pid, sku: p.sku });
|
|
|
|
|
if (samples.length >= DESCRIPTION_SAMPLE_LIMIT) break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { duplicates, samples };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = router;
|