Add loading products from PT query to editor, product editor search enhancements

This commit is contained in:
2026-03-18 15:29:00 -04:00
parent 1b836567cd
commit f8b81d2111
4 changed files with 1090 additions and 113 deletions

View File

@@ -1895,4 +1895,772 @@ router.get('/product-categories/:pid', async (req, res) => {
}
});
module.exports = router;
// Build a PIDs-from-query SQL using product_query_filter rows (mirrors productquery.class.php logic)
// NOTE: PRODUCT_SELECT uses aliases: products→p, current_inventory→ci, supplier_item_data→sid
// All filter conditions must use those aliases. Extra JOINs (category index, etc.) are appended.
//
// Constants sourced from registry.class.php / product_category.class.php (ACOT store id confirmed by user):
// ACOT_STORE=0, SRC_ACOT=10, SRC_PREORDER=11, SRC_NOTIONS=13
// CAT types: section=10, cat=11, subcat=12, subsubcat=13, theme=20, subtheme=21, digitheme=30
function buildQueryFilterSql(filters) {
// filterGroups: Map<key, { conditions: string[], isNot: boolean, ororor: boolean }>
// ororor=true means this group is OR-connected to the previous group (PHP filter_or mechanism)
const filterGroups = new Map();
const joinTables = new Map(); // alias -> JOIN clause
const unsupported = [];
function addGroup(key, condition, isNot = false, ororor = false) {
if (!filterGroups.has(key)) filterGroups.set(key, { conditions: [], isNot, ororor });
filterGroups.get(key).conditions.push(condition);
}
function addJoin(alias, clause) {
if (!joinTables.has(alias)) joinTables.set(alias, clause);
}
// INNER JOIN on product_category_index (product must have this category to appear)
function ciJoin(alias) {
addJoin(alias, `JOIN product_category_index AS ${alias} ON (p.pid=${alias}.pid)`);
}
// Translate operator string to SQL operator
function toSqlOp(op) {
const map = {
equals: '=', notequals: '<>', greater: '>', greater_equals: '>=',
less: '<', less_equals: '<=', between: ' BETWEEN ',
contains: ' LIKE ', notcontains: ' NOT LIKE ', begins: ' LIKE ',
true: '<>0', true1: '=1', false: '=0', isnull: ' IS NULL',
};
return map[op] || '=';
}
// Proper MySQL string escaping (mirrors mysql_real_escape_string order)
function strVal(v) {
return String(v)
.replace(/\\/g, '\\\\')
.replace(/\0/g, '\\0')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/'/g, "\\'")
.replace(/\x1a/g, '\\Z');
}
// URL-decode then escape — mirrors PHP: safefor_query(urldecode($row['filter_text1']))
function decode(v) {
if (!v) return '';
try { return decodeURIComponent(String(v)); } catch { return String(v); }
}
// Numeric field with BETWEEN support (appends AND t2 when operator is BETWEEN)
function numFilt(field, sqlOp, t1, t2) {
if (sqlOp.trim() === 'BETWEEN' && t2) return `${field} BETWEEN ${parseFloat(t1) || 0} AND ${parseFloat(t2) || 0}`;
return `${field}${sqlOp}${parseFloat(t1) || 0}`;
}
// Date/string field with BETWEEN support
function dateFilt(field, sqlOp, t1, t2) {
if (sqlOp.trim() === 'BETWEEN' && t2) return `${field} BETWEEN '${strVal(t1)}' AND '${strVal(t2)}'`;
return `${field}${sqlOp}'${strVal(t1)}'`;
}
// Store/source constants (registry.class.php; ACOT store id confirmed by user)
const ACOT_STORE = 0;
const SRC_ACOT = 10;
const SRC_PREORDER = 11;
const SRC_NOTIONS = 13;
// Category type constants (product_category.class.php)
const CAT_THEMES = '20,21';
const CAT_CATEGORIES_NO_SECTION = '11,12,13';
for (const row of filters) {
const sqlOp = toSqlOp(row.filter_operator);
const isNot = sqlOp === '<>' || row.filter_operator === 'notequals' || row.filter_operator === 'notcontains';
const t1 = decode(row.filter_text1);
const t2 = decode(row.filter_text2);
const filterOr = Boolean(row.filter_or);
switch (row.filter_type) {
// ── products table (aliased as p) ──────────────────────────────────────
case 'company':
if (t1 && t2) {
const filt = `(p.company=${parseFloat(t1)||0} AND p.line=${parseFloat(t2)||0})`;
addGroup('company' + sqlOp, isNot ? `NOT ${filt}` : filt, isNot);
} else {
addGroup('company' + sqlOp, `p.company${sqlOp}${parseFloat(t1)||0}`, isNot);
}
break;
case 'line': addGroup('line' + sqlOp, numFilt('p.line', sqlOp, t1, t2), isNot); break;
case 'subline': addGroup('subline' + sqlOp, numFilt('p.subline', sqlOp, t1, t2), isNot); break;
case 'no_company': addGroup('no_company', 'p.company=0'); break;
case 'no_line': addGroup('no_line', 'p.line=0'); break;
case 'no_subline': addGroup('no_subline', 'p.subline=0'); break;
case 'artist': addGroup('artist' + sqlOp, numFilt('p.artist', sqlOp, t1, t2), isNot); break;
case 'size_cat': addGroup('size_cat' + sqlOp, numFilt('p.size_cat', sqlOp, t1, t2), isNot); break;
case 'dimension': addGroup('dimension' + sqlOp, numFilt('p.dimension', sqlOp, t1, t2), isNot); break;
case 'yarn_weight': addGroup('yarn_weight' + sqlOp, numFilt('p.yarn_weight', sqlOp, t1, t2), isNot); break;
case 'material': addGroup('material' + sqlOp, numFilt('p.material', sqlOp, t1, t2), isNot); break;
case 'weight': addGroup('weight' + sqlOp, numFilt('p.weight', sqlOp, t1, t2)); break;
case 'weight_price_ratio': addGroup('weight' + sqlOp, numFilt('p.weight/p.price_for_sort', sqlOp, t1, t2)); break;
case 'price_weight_ratio': addGroup('weight' + sqlOp, numFilt('p.price_for_sort/p.weight', sqlOp, t1, t2)); break;
case 'length': addGroup('length' + sqlOp, numFilt('p.length', sqlOp, t1, t2)); break;
case 'width': addGroup('width' + sqlOp, numFilt('p.width', sqlOp, t1, t2)); break;
case 'height': addGroup('height' + sqlOp, numFilt('p.height', sqlOp, t1, t2)); break;
case 'no_dim': addGroup('no_dim', 'p.length=0 AND p.width=0 AND p.height=0'); break;
case 'hide': addGroup('hide', `p.hide${sqlOp}`); break;
case 'hide_in_shop':addGroup('hide_in_shop', `p.hide_in_shop${sqlOp}`); break;
case 'discontinued':addGroup('discontinued', `p.discontinued${sqlOp}`); break;
case 'force_flag': addGroup('force_flag', `p.force_flag${sqlOp}`); break;
case 'exclusive': addGroup('exclusive', `p.exclusive${sqlOp}`); break;
case 'lock_quantity': addGroup('lock_quantity', `p.lock_qty${sqlOp}`); break;
case 'show_notify': addGroup('show_notify', `p.show_notify${sqlOp}`); break;
case 'downloadable':addGroup('downloadable', `p.downloadable${sqlOp}`); break;
case 'usa_only': addGroup('usa_only', `p.usa_only${sqlOp}`); break;
case 'not_clearance': addGroup('not_clearance', `p.not_clearance${sqlOp}`); break;
case 'stat_stop': addGroup('stat_stop', `p.stat_stop${sqlOp}`); break;
case 'notnew': addGroup('notnew', `p.notnew${sqlOp}`); break;
case 'not_backinstock': addGroup('not_backinstock', `p.not_backinstock${sqlOp}`); break;
case 'reorder': addGroup('reorder', `p.reorder${sqlOp}${parseFloat(t1)||0}`); break;
case 'score': addGroup('score' + sqlOp, numFilt('p.score', sqlOp, t1, t2)); break;
case 'sold_view_score': addGroup('sold_view_score' + sqlOp, numFilt('p.sold_view_score', sqlOp, t1, t2)); break;
case 'visibility_score': addGroup('visibility_score' + sqlOp, numFilt('p.visibility_score', sqlOp, t1, t2)); break;
case 'health_score': addGroup('health_score' + sqlOp, `p.health_score${sqlOp}'${strVal(t1)}'`); break;
case 'tax_code': addGroup('tax_code', `p.tax_code${sqlOp}${parseFloat(t1)||0}`); break;
case 'investor': addGroup('investor' + sqlOp, `p.investorid${sqlOp}${parseFloat(t1)||0}`, isNot); break;
case 'price':
case 'default_price':
addGroup('default_price' + sqlOp, numFilt('p.sellingprice', sqlOp, t1, t2), isNot); break;
case 'price_for_sort':
addGroup('price_for_sort' + sqlOp, numFilt('p.price_for_sort', sqlOp, t1, t2)); break;
case 'salepercent_for_sort':
case 'salepercent_for_sort__clearance': {
// PHP divides by 100 if value > 1 (percentages stored as decimals)
const v1 = (parseFloat(t1)||0) > 1 ? (parseFloat(t1)/100) : (parseFloat(t1)||0);
const v2 = t2 ? ((parseFloat(t2)||0) > 1 ? (parseFloat(t2)/100) : (parseFloat(t2)||0)) : null;
const filt = (sqlOp.trim() === 'BETWEEN' && v2 != null)
? `p.salepercent_for_sort BETWEEN ${v1} AND ${v2}`
: `p.salepercent_for_sort${sqlOp}${v1}`;
addGroup(row.filter_type + sqlOp, filt); break;
}
case 'is_clearance':
addGroup('is_clearance' + sqlOp, `(p.clearance_date != '0000-00-00 00:00:00')${sqlOp}`); break;
case 'msrp': addGroup('msrp' + sqlOp, numFilt('p.msrp', sqlOp, t1, t2)); break;
case 'default_less_msrp':addGroup('default_less_msrp', '(p.sellingprice < p.msrp)'); break;
case 'default_more_msrp':addGroup('default_more_msrp', '(p.sellingprice > p.msrp)'); break;
case 'minimum_advertised_price':
addGroup('map', numFilt('p.minimum_advertised_price', sqlOp, t1, t2)); break;
case 'minimum_advertised_price_error':
addGroup('map_error', row.filter_operator !== 'false'
? 'p.minimum_advertised_price > p.sellingprice'
: 'p.minimum_advertised_price <= p.sellingprice');
break;
case 'wholesale_discount': {
const v1 = (parseFloat(t1)||0) > 1 ? (parseFloat(t1)/100) : (parseFloat(t1)||0);
const v2 = t2 ? ((parseFloat(t2)||0) > 1 ? (parseFloat(t2)/100) : (parseFloat(t2)||0)) : null;
const filt = (sqlOp.trim() === 'BETWEEN' && v2 != null)
? `p.wholesale_discount BETWEEN ${v1} AND ${v2}`
: `p.wholesale_discount${sqlOp}${v1}`;
addGroup('wholesale_discount' + sqlOp, filt); break;
}
case 'wholesale_unit_qty': addGroup('wholesale_unit_qty' + sqlOp, numFilt('p.wholesale_unit_qty', sqlOp, t1, t2)); break;
case 'points_multiplier': addGroup('points_multiplier' + sqlOp, numFilt('p.points_multiplier', sqlOp, t1, t2)); break;
case 'points_bonus': addGroup('points_bonus' + sqlOp, numFilt('p.points_bonus', sqlOp, t1, t2)); break;
case 'points_extra': addGroup('points_extra', '(p.points_multiplier>.5 OR p.points_bonus>0)'); break;
case 'auto_pricing_allowed': addGroup('auto_pricing', 'p.price_lock=0'); break;
case 'auto_pricing_disallowed': addGroup('auto_pricing', 'p.price_lock=1'); break;
case 'handling_fee': addGroup('handling_fee' + sqlOp, numFilt('p.handling_fee', sqlOp, t1, t2)); break;
case 'ship_tier': addGroup('ship_tier' + sqlOp, numFilt('p.shipping_tier', sqlOp, t1, t2)); break;
case 'shipping_restrictions': addGroup('ship_tier' + sqlOp, `p.shipping_restrictions${sqlOp}${parseFloat(t1)||0}`); break;
case 'min_qty_wanted': addGroup('min_qty_wanted', numFilt('p.min_qty_wanted', sqlOp, t1, t2)); break;
case 'qty_bundled': addGroup('min_qty_wanted', numFilt('p.qty_bundled', sqlOp, t1, t2)); break;
case 'no_40_percent_promo': addGroup('no_40_percent_promo', `p.no_40_percent_promo${sqlOp}`); break;
case 'exclude_google_feed': addGroup('exclude_google_feed', `p.exclude_google_feed${sqlOp}`); break;
case 'store': {
const bit = Math.pow(2, parseInt(t1) || 0);
addGroup('store_' + (parseInt(t1)||0), `p.store & ${bit}${sqlOp}${bit}`); break;
}
case 'no_store': addGroup('store_' + (parseInt(t1)||0), 'p.store=0'); break;
case 'location': {
let filt = `p.aisle${sqlOp}'${strVal(t1)}'`;
if (t2) filt += ` AND p.rack${sqlOp}'${strVal(t2)}'`;
addGroup('location' + sqlOp, filt); break;
}
case 'name':
addGroup('name' + sqlOp,
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
? `p.description${sqlOp}'%${strVal(t1)}%'`
: `p.description${sqlOp}'${strVal(t1)}'`, isNot);
break;
case 'short_description':
addGroup('short_description' + sqlOp,
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
? `p.description_short${sqlOp}'%${strVal(t1)}%'`
: `p.description_short${sqlOp}'${strVal(t1)}'`, isNot);
break;
case 'description':
addGroup('description' + sqlOp,
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
? `p.notes${sqlOp}'%${strVal(t1)}%'`
: `p.notes${sqlOp}'${strVal(t1)}'`, isNot);
break;
case 'description_char_count':
addGroup('description_char_count' + sqlOp, `CHAR_LENGTH(p.notes)${sqlOp}${parseFloat(t1)||0}`); break;
case 'description2':
addGroup('description2' + sqlOp,
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
? `p.notes2${sqlOp}'%${strVal(t1)}%'`
: `p.notes2${sqlOp}'${strVal(t1)}'`, isNot);
break;
case 'keyword':
addGroup('keyword' + sqlOp,
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
? `p.keyword1${sqlOp}'%${strVal(t1)}%'`
: `p.keyword1${sqlOp}'${strVal(t1)}'`, isNot);
break;
case 'notes':
addGroup('notes' + sqlOp,
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
? `p.priv_notes${sqlOp}'%${strVal(t1)}%'`
: `p.priv_notes${sqlOp}'${strVal(t1)}'`, isNot);
break;
case 'price_notes':
addGroup('price_notes' + sqlOp,
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
? `p.price_notes${sqlOp}'%${strVal(t1)}%'`
: `p.price_notes${sqlOp}'${strVal(t1)}'`);
break;
case 'itemnumber':
addGroup('itemnumber_' + sqlOp, `p.itemnumber${sqlOp}'${strVal(t1)}'`, isNot); break;
case 'pid':
case 'pid_auto': {
// filter_or=true means OR-connect this group to the previous one (PHP OROROR mechanism)
const key = 'pid' + sqlOp + (filterOr ? 'OROROR' : '');
addGroup(key, `p.pid${sqlOp}${parseInt(t1) || 0}`, isNot, filterOr);
break;
}
case 'upc':
addGroup('upc' + sqlOp, `p.upc${sqlOp}'${strVal(t1)}'`, isNot); break;
case 'size':
addGroup('size' + sqlOp,
row.filter_operator === 'begins' ? `p.size LIKE '${strVal(t1)}%'` : `p.size${sqlOp}'${strVal(t1)}'`, isNot);
break;
case 'country_of_origin':
addGroup('country_of_origin',
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
? `p.country_of_origin${sqlOp}'%${strVal(t1)}%'`
: `p.country_of_origin${sqlOp}'${strVal(t1)}'`);
break;
case 'date_in': addGroup('date_in' + sqlOp, dateFilt('DATE(p.datein)', sqlOp, t1, t2)); break;
case 'date_in_days':
case 'age': addGroup('date_in' + sqlOp, numFilt('DATEDIFF(NOW(),p.datein)', sqlOp, t1, t2)); break;
case 'date_created': addGroup('date_created' + sqlOp, dateFilt('p.date_created', sqlOp, t1, t2)); break;
case 'date_created_days': addGroup('date_created' + sqlOp, numFilt('DATEDIFF(NOW(),p.date_created)', sqlOp, t1, t2)); break;
case 'date_modified': addGroup('date_modified' + sqlOp, dateFilt('p.stamp', sqlOp, t1, t2)); break;
case 'date_modified_days': addGroup('date_modified' + sqlOp, numFilt('DATEDIFF(NOW(),p.stamp)', sqlOp, t1, t2)); break;
case 'date_refill': addGroup('date_refill' + sqlOp, dateFilt('p.date_refill', sqlOp, t1, t2)); break;
case 'date_refill_days': addGroup('date_refill' + sqlOp, numFilt('DATEDIFF(NOW(),p.date_refill)', sqlOp, t1, t2)); break;
case 'new':
addGroup('new', `DATEDIFF(NOW(),p.date_ol) <= ${parseInt(t1) || 45}`);
addGroup('notnew', 'p.notnew=0');
break;
case 'new_in':
addGroup('new2', `p.datein BETWEEN NOW()-INTERVAL ${parseInt(t1) || 30} DAY AND NOW()`);
addGroup('notnew', 'p.notnew=0');
break;
case 'backinstock':
addGroup('backinstock_1', `p.date_refill BETWEEN NOW()-INTERVAL ${parseInt(t1)||30} DAY AND NOW()`);
addGroup('backinstock_2', 'p.date_refill > p.datein');
addGroup('backinstock_3', 'NOT (p.datein BETWEEN NOW()-INTERVAL 30 DAY AND NOW())');
break;
case 'arrivals':
addGroup('arrivals', `(p.date_ol BETWEEN NOW()-INTERVAL ${parseInt(t1)||30} DAY AND NOW() AND p.notnew=0)`);
addGroup('arrivals', `(p.date_refill BETWEEN NOW()-INTERVAL ${parseInt(t1)||30} DAY AND NOW() AND p.date_refill > p.datein)`);
break;
// ── current_inventory (aliased as ci, already LEFT JOINed in PRODUCT_SELECT) ──
case 'count': addGroup('count' + sqlOp, numFilt('ci.available', sqlOp, t1, t2)); break;
case 'count_onhand': addGroup('count_onhand' + sqlOp, numFilt('(ci.count-ci.pending)', sqlOp, t1, t2)); break;
case 'count_shelf': addGroup('count_shelf' + sqlOp, numFilt('ci.count_shelf', sqlOp, t1, t2)); break;
case 'on_order': addGroup('on_order' + sqlOp, numFilt('ci.onorder', sqlOp, t1, t2)); break;
case 'on_preorder': addGroup('on_preorder' + sqlOp, numFilt('ci.onpreorder', sqlOp, t1, t2)); break;
case 'infinite': addGroup('infinite', `ci.infinite${sqlOp}`); break;
case 'pending': addGroup('pending', numFilt('ci.pending', sqlOp, t1, t2)); break;
case 'date_sold': addGroup('date_sold' + sqlOp, numFilt('DATEDIFF(NOW(),ci.lastsolddate)', sqlOp, t1, t2)); break;
case 'average_cost': addGroup('avg_cost' + sqlOp, numFilt('ci.avg_cost', sqlOp, t1, t2)); break;
case 'markup': addGroup('markup' + sqlOp, numFilt('(p.sellingprice/ci.avg_cost*100-100)', sqlOp, t1, t2)); break;
case 'total_sold': addGroup('total_sold' + sqlOp, numFilt('ci.totalsold', sqlOp, t1, t2)); break;
case 'inbaskets': addGroup('inbaskets', numFilt('ci.baskets', sqlOp, t1, t2)); break;
// ── supplier_item_data (aliased as sid, already LEFT JOINed in PRODUCT_SELECT) ──
case 'supplier':
addGroup('supplier' + sqlOp, `sid.supplier_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
case 'supplier_cost_each':
addGroup('supplier_cost_each' + sqlOp, numFilt('sid.supplier_cost_each', sqlOp, t1, t2)); break;
case 'notions_cost_each':
addGroup('notions_cost_each' + sqlOp, numFilt('sid.notions_cost_each', sqlOp, t1, t2)); break;
case 'supplier_qty_per_unit':
addGroup('supplier_qty_per_unit' + sqlOp, numFilt('sid.supplier_qty_per_unit', sqlOp, t1, t2)); break;
case 'notions_qty_per_unit':
addGroup('notions_qty_per_unit' + sqlOp, numFilt('sid.notions_qty_per_unit', sqlOp, t1, t2)); break;
case 'case_pack':
addGroup('case_pack', `sid.notions_case_pack${sqlOp}${parseFloat(t1)||0}`); break;
case 'notions_discontinued':
addGroup('notions_discontinued' + sqlOp, `sid.notions_discontinued${sqlOp}${parseFloat(t1)||0}`, isNot); break;
case 'missing_any_cost':
addGroup('missing_any_cost', 'sid.supplier_cost_each=0 OR sid.notions_cost_each=0 OR sid.supplier_cost_each IS NULL OR sid.notions_cost_each IS NULL OR (SELECT COUNT(*) FROM product_inventory WHERE product_inventory.pid=p.pid)=0');
break;
case 'notions_itemnumber': {
// Use a separate LEFT JOIN alias so IS NULL check works correctly
addJoin('sid_l', 'LEFT JOIN supplier_item_data AS sid_l ON (p.pid=sid_l.pid)');
let filt = `sid_l.notions_itemnumber${sqlOp}'${strVal(t1)}'`;
if (sqlOp === '=' && t1 === '') filt += ' OR sid_l.notions_itemnumber IS NULL';
addGroup('notions_itemnumber' + sqlOp, filt, isNot); break;
}
case 'supplier_itemnumber': {
addJoin('sid_l', 'LEFT JOIN supplier_item_data AS sid_l ON (p.pid=sid_l.pid)');
let filt = `sid_l.supplier_itemnumber${sqlOp}'${strVal(t1)}'`;
if (sqlOp === '=' && t1 === '') filt += ' OR sid_l.supplier_itemnumber IS NULL';
addGroup('supplier_itemnumber' + sqlOp, filt, isNot); break;
}
case 'supplier_cost_each_grouped':
addGroup('supplier_cost_each' + sqlOp, numFilt('sid.supplier_cost_each', sqlOp, t1, t2)); break;
// ── category index: extra INNER JOINs needed ───────────────────────────
case 'type':
case 'cat':
ciJoin('product_ci_cat');
addGroup('cat' + sqlOp, `product_ci_cat.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
case 'cat2':
ciJoin('product_ci_cat2');
addGroup('cat2' + sqlOp, `product_ci_cat2.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
case 'subtype':
case 'subcat':
ciJoin('product_ci_subcat');
addGroup('subcat' + sqlOp, `product_ci_subcat.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
case 'subsubcat':
ciJoin('product_ci_subsubcat');
addGroup('subsubcat' + sqlOp, `product_ci_subsubcat.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
case 'section':
ciJoin('product_ci_section');
addGroup('section' + sqlOp, `product_ci_section.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
case 'theme':
ciJoin('product_ci_theme');
addGroup('themes' + sqlOp, `product_ci_theme.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
case 'subtheme':
ciJoin('product_ci_subtheme');
addGroup('subtheme' + sqlOp, `product_ci_subtheme.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
case 'digitheme':
ciJoin('product_ci_digitheme');
addGroup('digithemes' + sqlOp, `product_ci_digitheme.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
case 'all_categories':
if (sqlOp === '=') {
ciJoin('product_ci_allcats');
addGroup('allcategories=', `product_ci_allcats.cat_id=${parseFloat(t1)||0}`);
} else {
const alias = `not_ci_${parseInt(t1) || 0}`;
addJoin(alias, `LEFT JOIN product_category_index AS ${alias} ON (${alias}.pid=p.pid AND ${alias}.cat_id=${parseFloat(t1)||0})`);
addGroup('allcategories<>', `${alias}.cat_id IS NULL`);
}
break;
case 'not_section':
addGroup('not_section', `(SELECT 1 FROM product_category_index WHERE product_category_index.pid=p.pid AND cat_id=${parseInt(t1)||0}) IS NULL`);
break;
case 'all_themes':
if (sqlOp === '=') {
ciJoin('product_ci_allthemes');
addGroup('allthemes=', `product_ci_allthemes.cat_id=${parseFloat(t1)||0}`);
} else {
addJoin('product_ci_not_allthemes', `LEFT JOIN product_category_index AS product_ci_not_allthemes ON (p.pid=product_ci_not_allthemes.pid AND product_ci_not_allthemes.cat_id=${parseFloat(t1)||0})`);
addGroup('allthemes<>', 'product_ci_not_allthemes.pid IS NULL');
}
break;
case 'has_any_category':
addGroup('has_any_category', '(SELECT COUNT(*) FROM product_category_index WHERE product_category_index.pid=p.pid)>0'); break;
case 'has_themes':
addGroup('has_themes', `(SELECT COUNT(*) FROM product_category_index JOIN product_categories ON (product_category_index.cat_id=product_categories.cat_id AND product_categories.type IN (${CAT_THEMES})) WHERE product_category_index.pid=p.pid)>0`); break;
case 'no_themes':
addGroup('no_themes', `(SELECT COUNT(*) FROM product_category_index JOIN product_categories ON (product_category_index.cat_id=product_categories.cat_id AND product_categories.type IN (${CAT_THEMES})) WHERE product_category_index.pid=p.pid)=0`); break;
case 'no_categories':
addGroup('no_categories', `(SELECT COUNT(*) FROM product_category_index JOIN product_categories ON (product_category_index.cat_id=product_categories.cat_id AND product_categories.type IN (${CAT_CATEGORIES_NO_SECTION})) WHERE product_category_index.pid=p.pid)=0`); break;
// ── color: extra JOIN product_colors ──────────────────────────────────
case 'color':
addJoin('product_colors', 'JOIN product_colors ON (p.pid=product_colors.pid)');
addGroup('color' + sqlOp, `product_colors.color${sqlOp}${parseInt(t1)||0}`, isNot); break;
// ── product_inventory: extra JOIN (GROUP BY p.pid covers duplicates) ──
case 'cost':
addJoin('product_inventory', 'JOIN product_inventory ON (product_inventory.pid=p.pid)');
addGroup('cost' + sqlOp, numFilt('product_inventory.costeach', sqlOp, t1, t2)); break;
case 'original_cost':
addJoin('product_inventory', 'JOIN product_inventory ON (product_inventory.pid=p.pid)');
addGroup('orig_cost' + sqlOp, numFilt('product_inventory.orig_costeach', sqlOp, t1, t2)); break;
case 'total_product_value':
addGroup('total_product_value' + sqlOp,
`(SELECT SUM(product_inventory.costeach*product_inventory.count) FROM product_inventory WHERE product_inventory.pid=p.pid)${sqlOp}${parseFloat(t1)||0}`);
break;
// ── shop_inventory: extra LEFT JOIN ───────────────────────────────────
// All shop_* filters share alias 'shop_inv' so they add only one JOIN
case 'buyable':
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
addGroup('shop_inv_cond', `shop_inv.store=${ACOT_STORE} AND shop_inv.buyable=1`); break;
case 'not_buyable':
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
addGroup('shop_inv_cond', `shop_inv.store=${ACOT_STORE} AND shop_inv.buyable=0`); break;
case 'shop_available':
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
addGroup('shop_inv_store', `shop_inv.store=${ACOT_STORE}`);
addGroup('shop_inv_avail' + sqlOp, numFilt('shop_inv.available', sqlOp, t1, t2)); break;
case 'shop_available_local': {
addJoin('shop_inv_local', `LEFT JOIN shop_inventory AS shop_inv_local ON (p.pid=shop_inv_local.pid AND shop_inv_local.store=${ACOT_STORE})`);
const filt = sqlOp === '<'
? `(shop_inv_local.available_local${sqlOp}${parseFloat(t1)||0} OR shop_inv_local.available_local IS NULL)`
: numFilt('shop_inv_local.available_local', sqlOp, t1, t2);
addGroup('shop_avail_local' + sqlOp, filt); break;
}
case 'shop_show':
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.show=1`); break;
case 'shop_show_in':
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
addGroup('shop_inv_cond', `shop_inv.store IN (${strVal(t1)}) AND shop_inv.show=1`); break;
case 'shop_buyable':
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.buyable=1`); break;
case 'shop_preorder':
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.\`all\`=2`); break;
case 'shop_not_preorder':
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.\`all\`!=2`); break;
case 'shop_inventory_source_acot':
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.inventory_source=${SRC_ACOT}`); break;
case 'shop_inventory_source_acot_or_pre':
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.inventory_source IN (${SRC_ACOT},${SRC_PREORDER})`); break;
case 'shop_inventory_source_notions':
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.inventory_source=${SRC_NOTIONS}`); break;
case 'shop_inventory_source_not_notions':
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.inventory_source!=${SRC_NOTIONS}`); break;
case 'preorder_item':
addJoin('shop_inv_pre', `LEFT JOIN shop_inventory AS shop_inv_pre ON (p.pid=shop_inv_pre.pid AND shop_inv_pre.store=${ACOT_STORE})`);
addGroup('preorder_item', `shop_inv_pre.inventory_source=${SRC_PREORDER}`); break;
case 'not_preorder_item':
addJoin('shop_inv_pre', `LEFT JOIN shop_inventory AS shop_inv_pre ON (p.pid=shop_inv_pre.pid AND shop_inv_pre.store=${ACOT_STORE})`);
addGroup('preorder_item', `shop_inv_pre.inventory_source!=${SRC_PREORDER} OR shop_inv_pre.inventory_source IS NULL`); break;
// ── product_notions: extra JOIN ────────────────────────────────────────
case 'notions_use_inventory':
addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)');
addGroup('notions_use_inv', `product_notions.use_inventory${sqlOp}`); break;
case 'notions_inventory':
addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)');
addGroup('notions_inv', `product_notions.inventory${sqlOp}${parseInt(t1)||0}`); break;
case 'notions_sell_qty':
addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)');
addGroup('notions_sell_qty', `product_notions.sell_qty${sqlOp}${parseInt(t1)||0}`); break;
case 'notions_csc':
addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)');
addGroup('notions_csc',
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
? `product_notions.csc${sqlOp}'%${strVal(t1)}%'`
: `product_notions.csc${sqlOp}'${strVal(t1)}'`);
break;
case 'notions_top_sellers':
addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)');
addGroup('notions_top_sellers', 'product_notions.top_seller>0'); break;
case 'notions_cost_higher_than_selling_price':
addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)');
addGroup('notions_cost_vs_sell', '(product_notions.sell_cost*product_notions.sell_qty) > p.sellingprice'); break;
case 'notions_order_qty_conversion_our':
addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)');
addGroup('notion_oqc_our' + sqlOp, numFilt('product_notions.order_qty_conversion_our', sqlOp, t1, t2)); break;
case 'notions_order_qty_conversion_notions':
addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)');
addGroup('notion_oqc_notions' + sqlOp, numFilt('product_notions.order_qty_conversion_notions', sqlOp, t1, t2)); break;
// ── product_backorders: extra LEFT JOIN ────────────────────────────────
case 'backorder_qty_any':
addJoin('product_backorders', 'LEFT JOIN product_backorders ON (product_backorders.pid=p.pid)');
break; // join only, no WHERE condition
case 'backorder_qty_notions':
addJoin('product_backorders', 'LEFT JOIN product_backorders ON (product_backorders.pid=p.pid)');
addGroup('backorder_qty_notions', `product_backorders.qty${sqlOp}${parseInt(t1)||0} AND product_backorders.supplier=92`); break;
// ── product_related: extra JOIN ────────────────────────────────────────
case 'related':
addJoin('product_related', 'JOIN product_related ON (product_related.to_pid=p.pid)');
addGroup('related_pid', `product_related.pid${sqlOp}${parseInt(t1)||0}`);
if (t2) addGroup('related_type', `product_related.type=${parseInt(t2)||0}`);
break;
case 'no_relations':
addJoin('product_related_nr', 'LEFT JOIN product_related AS product_related_nr ON (product_related_nr.pid=p.pid)');
addGroup('no_relations', 'product_related_nr.to_pid IS NULL'); break;
// ── receivings / PO: extra INNER JOINs ────────────────────────────────
case 'receiving_id':
addJoin('receivings_products', 'JOIN receivings_products ON (p.pid=receivings_products.pid)');
addGroup('receiving_id' + sqlOp, `receivings_products.receiving_id${sqlOp}'${strVal(t1)}'`, isNot); break;
case 'po_id':
addJoin('po_products', 'JOIN po_products ON (p.pid=po_products.pid)');
addGroup('po_id' + sqlOp, `po_products.po_id${sqlOp}'${strVal(t1)}'`, isNot); break;
// ── subquery filters ───────────────────────────────────────────────────
case 'missing_images':
addGroup('missing_images',
(row.filter_operator === 'true' || row.filter_operator === 'true1')
? '(SELECT COUNT(*) FROM product_images WHERE product_images.pid=p.pid)=0'
: '(SELECT COUNT(*) FROM product_images WHERE product_images.pid=p.pid)>0');
break;
case 'current_price':
addGroup('current_price' + sqlOp,
`(SELECT MIN(price_each) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1)${sqlOp}${parseFloat(t1)||0}`);
break;
case 'current_price_sale_percent': {
const v1 = (parseFloat(t1)||0) > 1 ? (parseFloat(t1)/100) : (parseFloat(t1)||0);
const pe = '(SELECT MIN(price_each) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1)';
addGroup('current_price_sale_pct' + sqlOp, `(1 - ${pe} / p.sellingprice)${sqlOp}${v1}`); break;
}
case 'one_current_price':
addGroup('one_current_price', '(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1)=1'); break;
case 'multiple_current_prices':
addGroup('multiple_current_prices', '(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1)>1'); break;
case 'current_price_is_missing':
addGroup('current_price_is_missing', '(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1)=0'); break;
case 'current_price_not_one_buyable':
addGroup('current_price_not_one_buyable',
'(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND qty_buy=1)=0 AND (SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1)>0');
break;
case 'current_price_min_buy': {
const cond = t2
? `(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND is_min_qty_buy=1 AND qty_buy${sqlOp}${parseFloat(t1)||0} AND ${parseFloat(t2)||0})>0`
: `(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND is_min_qty_buy=1 AND qty_buy${sqlOp}${parseFloat(t1)||0})>0`;
addGroup('current_price_min_buy' + sqlOp, cond); break;
}
case 'current_price_each_buy': {
const cond = t2
? `(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND is_min_qty_buy=0 AND qty_buy${sqlOp}${parseFloat(t1)||0} AND ${parseFloat(t2)||0})>0`
: `(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND is_min_qty_buy=0 AND qty_buy${sqlOp}${parseFloat(t1)||0})>0`;
addGroup('current_price_each_buy' + sqlOp, cond); break;
}
case 'current_price_max_qty': {
const cond = t2
? `(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND qty_limit${sqlOp}${parseFloat(t1)||0} AND ${parseFloat(t2)||0})>0`
: `(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND qty_limit${sqlOp}${parseFloat(t1)||0})>0`;
addGroup('current_price_max_qty' + sqlOp, cond); break;
}
case 'current_price_is_checkout_offer':
addGroup('current_price_checkout',
`(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND checkout_offer${sqlOp})>0`);
break;
case 'current_price_has_extra':
addJoin('cp_extras', 'JOIN (SELECT pid,count(*) ct FROM product_current_prices WHERE active=1 GROUP BY pid,qty_buy HAVING ct>1) AS cp_extras ON (cp_extras.pid=p.pid)');
addGroup('cp_extra', 'cp_extras.ct>1'); break;
case 'has_video':
addGroup('has_video', '(SELECT COUNT(*) FROM product_media WHERE product_media.pid=p.pid)>0'); break;
case 'notions_created':
addGroup('notions_created',
sqlOp !== '=0'
? '(SELECT COUNT(*) FROM product_notions_created WHERE product_notions_created.pid=p.pid)>0'
: '(SELECT COUNT(*) FROM product_notions_created WHERE product_notions_created.pid=p.pid)=0');
break;
case 'notifications':
addJoin('pnc', 'JOIN (SELECT pid,COUNT(*) AS notifications_count FROM product_notify GROUP BY pid) pnc ON (pnc.pid=p.pid)');
addGroup('notifications', numFilt('pnc.notifications_count', sqlOp, t1, t2)); break;
case 'daily_deal':
addGroup('daily_deal', '(SELECT deal_id FROM product_daily_deals WHERE deal_date=CURDATE() AND product_daily_deals.pid=p.pid)>0');
break;
// ── amazon_price (store id 5, legacy) ─────────────────────────────────
case 'amazon_price': {
const AMAZON_STORE = 5;
addJoin('product_prices', 'LEFT JOIN product_prices ON (p.pid=product_prices.pid)');
let filt = `product_prices.store=${AMAZON_STORE} AND product_prices.price${sqlOp}${parseFloat(t1)||0}`;
if (t2) filt += ` AND ${parseFloat(t2)||0}`;
if ((sqlOp === '<=' || sqlOp === '=') && t1 === '0') filt += ' OR product_prices.price IS NULL';
addGroup('amazon_price' + sqlOp, filt); break;
}
// ── basket (cid must be in filter_text2; skip if not set) ─────────────
case 'basket':
if (!t2) { unsupported.push('basket(no-cid)'); break; }
addJoin('mybasket', 'JOIN mybasket ON (p.pid=mybasket.item)');
addGroup('basket_cid', `mybasket.cid=${parseInt(t2)||0}`);
addGroup('basket_sid', `mybasket.sid=0`); // ACOT store
addGroup('basket_bid', t1 ? `mybasket.bid=${parseInt(t1)||0}` : 'mybasket.bid=0');
if (t1 === '2') addGroup('basket_qty', 'mybasket.qty>0');
break;
// ── hot: recent sales aggregation ─────────────────────────────────────
case 'hot': {
const days = parseInt(t1) || 30;
const alias = `hot_${days}`;
addJoin(alias, `JOIN (SELECT prod_pid, COUNT(*) hot_ct, SUM(order_items.qty_ordered) hot_sm FROM order_items JOIN _order ON (_order.order_id=order_items.order_id AND _order.order_status>60 AND _order.date_placed BETWEEN NOW()-INTERVAL ${days} DAY AND NOW()) GROUP BY order_items.prod_pid) ${alias} ON (p.pid=${alias}.prod_pid)`);
break; // join alone filters to products with recent sales; no extra WHERE needed
}
// Intentionally skipped (require external services or missing runtime context):
// search/search_any (MeiliSearch API), basket without cid (per-session user),
// groupby (display aggregation, not a filter)
default:
unsupported.push(row.filter_type);
break;
}
}
// Assemble WHERE: groups are AND-connected by default.
// A group with ororor=true is OR-connected to the previous group instead (PHP filter_or mechanism).
let whereClause = '';
let i = 0;
for (const [, group] of filterGroups) {
const wrapped = group.conditions.map(c => `(${c})`);
const innerJoiner = group.isNot ? ' AND ' : ' OR ';
const part = `(${wrapped.join(innerJoiner)})`;
if (i > 0) {
whereClause += group.ororor ? ` OR ${part}` : ` AND ${part}`;
} else {
whereClause += part;
}
i++;
}
const joinClauses = [...joinTables.values()].join('\n ');
return { whereClause, joinClauses, unsupported };
}
// Load products matching a saved product_query by query_id
// Filter types whose v1/v2 values are cat_ids in product_categories
const CAT_ID_FILTER_TYPES = new Set([
'company', 'line', 'subline', 'artist', 'category', 'theme',
'size_cat', 'dimension', 'yarn_weight', 'material',
]);
async function resolveFilterLabels(connection, filters) {
const catIds = new Set();
const supplierIds = new Set();
const taxCodeIds = new Set();
for (const f of filters) {
const vals = [f.v1, f.v2].filter(v => v && !isNaN(Number(v)));
if (CAT_ID_FILTER_TYPES.has(f.type)) vals.forEach(v => catIds.add(Number(v)));
else if (f.type === 'investor') vals.forEach(v => supplierIds.add(Number(v)));
else if (f.type === 'tax_code') vals.forEach(v => taxCodeIds.add(Number(v)));
}
const catLookup = {};
const supplierLookup = {};
const taxCodeLookup = {};
if (catIds.size) {
const ids = [...catIds];
const [rows] = await connection.query(
`SELECT cat_id, name FROM product_categories WHERE cat_id IN (${ids.map(() => '?').join(',')})`, ids
);
for (const r of rows) catLookup[r.cat_id] = r.name;
}
if (supplierIds.size) {
const ids = [...supplierIds];
const [rows] = await connection.query(
`SELECT supplierid, companyname FROM suppliers WHERE supplierid IN (${ids.map(() => '?').join(',')})`, ids
);
for (const r of rows) supplierLookup[r.supplierid] = r.companyname;
}
if (taxCodeIds.size) {
const ids = [...taxCodeIds];
const [rows] = await connection.query(
`SELECT tax_code_id, name FROM product_tax_codes WHERE tax_code_id IN (${ids.map(() => '?').join(',')})`, ids
);
for (const r of rows) taxCodeLookup[r.tax_code_id] = r.name;
}
function labelFor(type, val) {
if (!val || isNaN(Number(val))) return null;
const id = Number(val);
if (CAT_ID_FILTER_TYPES.has(type)) return catLookup[id] ?? null;
if (type === 'investor') return supplierLookup[id] ?? null;
if (type === 'tax_code') return taxCodeLookup[id] ?? null;
return null;
}
return filters.map(f => ({
...f,
v1Label: labelFor(f.type, f.v1),
v2Label: labelFor(f.type, f.v2),
}));
}
router.get('/query-products', async (req, res) => {
const { query_id } = req.query;
if (!query_id || isNaN(parseInt(query_id))) {
return res.status(400).json({ error: 'Valid query_id is required' });
}
const qid = parseInt(query_id);
try {
const { connection } = await getDbConnection();
// Verify query exists
const [queryRows] = await connection.query(
'SELECT id, name FROM product_query WHERE id = ?', [qid]
);
if (!queryRows.length) {
return res.status(404).json({ error: `Query ${qid} not found` });
}
// Load all filters for this query
const [filterRows] = await connection.query(
'SELECT * FROM product_query_filter WHERE query_id = ? ORDER BY id', [qid]
);
if (!filterRows.length) {
return res.json({ results: [], filters: [] });
}
const { whereClause, joinClauses, unsupported } = buildQueryFilterSql(filterRows);
const uniqueUnsupported = [...new Set(unsupported)];
if (uniqueUnsupported.length) {
console.warn(`query-products: unsupported filter types for query ${qid}:`, uniqueUnsupported);
}
res.setHeader('X-Query-Name', queryRows[0].name || '');
function tryDecode(v) {
if (!v) return null;
try { return decodeURIComponent(String(v)); } catch { return String(v); }
}
const rawFilters = filterRows
.filter(r => !uniqueUnsupported.includes(r.filter_type))
.map(r => ({
type: r.filter_type,
op: r.filter_operator,
v1: tryDecode(r.filter_text1),
v2: tryDecode(r.filter_text2),
}));
const filters = await resolveFilterLabels(connection, rawFilters);
// If all filters were unsupported the WHERE clause is empty — return nothing
// rather than dumping the entire products table.
if (!whereClause) {
return res.json({ results: [], filters, unsupported: uniqueUnsupported });
}
const sql = `${PRODUCT_SELECT}
${joinClauses}
WHERE ${whereClause}
GROUP BY p.pid
ORDER BY p.description
LIMIT 2000`;
const [results] = await connection.query(sql);
res.json({ results, filters, unsupported: uniqueUnsupported });
} catch (error) {
console.error('Error loading query products:', error);
res.status(500).json({ error: 'Failed to load query products', details: error.message });
}
});
module.exports = router;

View File

@@ -463,14 +463,14 @@ router.get('/search', async (req, res) => {
try {
const terms = q.trim().split(/\s+/).filter(Boolean);
// Each term must match at least one of: title, sku, barcode, brand, vendor
// Each term must match at least one of: title, sku, barcode, brand, vendor, vendor_reference, notions_reference, line, subline, artist
const conditions = terms.map((_, i) => {
const p = i * 5;
return `(p.title ILIKE $${p + 1} OR p.sku ILIKE $${p + 2} OR p.barcode ILIKE $${p + 3} OR p.brand ILIKE $${p + 4} OR p.vendor ILIKE $${p + 5})`;
const p = i * 10;
return `(p.title ILIKE $${p + 1} OR p.sku ILIKE $${p + 2} OR p.barcode ILIKE $${p + 3} OR p.brand ILIKE $${p + 4} OR p.vendor ILIKE $${p + 5} OR p.vendor_reference ILIKE $${p + 6} OR p.notions_reference ILIKE $${p + 7} OR p.line ILIKE $${p + 8} OR p.subline ILIKE $${p + 9} OR p.artist ILIKE $${p + 10})`;
});
const params = terms.flatMap(t => {
const like = `%${t}%`;
return [like, like, like, like, like];
return [like, like, like, like, like, like, like, like, like, like];
});
const whereClause = conditions.join(' AND ');
@@ -488,7 +488,7 @@ router.get('/search', async (req, res) => {
ELSE 3
END,
p.total_sold DESC NULLS LAST
LIMIT 50
LIMIT 100
`, searchParams),
pool.query(`
SELECT COUNT(*)::int AS total