diff --git a/inventory-server/src/routes/import.js b/inventory-server/src/routes/import.js index a4cd58c..9f1d2e1 100644 --- a/inventory-server/src/routes/import.js +++ b/inventory-server/src/routes/import.js @@ -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 + // 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; diff --git a/inventory-server/src/routes/products.js b/inventory-server/src/routes/products.js index 0a1f037..e9844ac 100644 --- a/inventory-server/src/routes/products.js +++ b/inventory-server/src/routes/products.js @@ -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 diff --git a/inventory/src/components/product-editor/ProductSearch.tsx b/inventory/src/components/product-editor/ProductSearch.tsx index 395cea7..c87ff62 100644 --- a/inventory/src/components/product-editor/ProductSearch.tsx +++ b/inventory/src/components/product-editor/ProductSearch.tsx @@ -3,7 +3,6 @@ import axios from "axios"; import { toast } from "sonner"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Table, TableBody, @@ -20,7 +19,7 @@ import { import { Check, ChevronDown, Loader2, Search } from "lucide-react"; import type { SearchProduct } from "./types"; -const SEARCH_LIMIT = 50; +const SEARCH_LIMIT = 100; interface QuickSearchResult { pid: number; @@ -50,6 +49,7 @@ export function ProductSearch({ const [isSearching, setIsSearching] = useState(false); const [isLoadingProduct, setIsLoadingProduct] = useState(null); const [resultsOpen, setResultsOpen] = useState(false); + const [isFocused, setIsFocused] = useState(false); const handleSearch = useCallback(async () => { if (!searchTerm.trim()) return; @@ -108,104 +108,106 @@ export function ProductSearch({ const isTruncated = totalCount > SEARCH_LIMIT; return ( - - - Search Products - - -
- setSearchTerm(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSearch()} - /> - +
+ {(isFocused || searchResults.length === 0) && ( +

+ Search by name, item number, UPC, company, supplier, supplier id, notions #, line, subline, artist +

+ )} + + {searchResults.length > 0 && ( + +
+ + + + {unloadedCount > 0 && ( + )} - -
+ - {searchResults.length > 0 && ( - -
- - - - {unloadedCount > 0 && ( - - )} + +
+ + + + Name + Item Number + Brand + Line + + Price + + + + + {searchResults.map((product) => { + const isLoaded = loadedPids.has(Number(product.pid)); + return ( + + !isLoadingProduct && !isLoaded && handleSelect(product) + } + > + + {isLoadingProduct === product.pid && ( + + )} + {isLoaded && ( + + )} + {product.title} + + {product.sku} + {product.brand} + {product.line} + + $ + {Number(product.regular_price)?.toFixed(2) ?? + product.regular_price} + + + ); + })} + +
- - -
- - - - Name - SKU - Brand - Line - - Price - - - - - {searchResults.map((product) => { - const isLoaded = loadedPids.has(Number(product.pid)); - return ( - - !isLoadingProduct && !isLoaded && handleSelect(product) - } - > - - {isLoadingProduct === product.pid && ( - - )} - {isLoaded && ( - - )} - {product.title} - - {product.sku} - {product.brand} - {product.line} - - $ - {Number(product.regular_price)?.toFixed(2) ?? - product.regular_price} - - - ); - })} - -
-
- {isTruncated && ( -

- Showing top {SEARCH_LIMIT} of {totalCount} matches. Refine your search to find specific products. -

- )} -
- - )} - - + {isTruncated && ( +

+ Showing only the first {SEARCH_LIMIT} of {totalCount} matches. Refine your search to find specific products. +

+ )} +
+ + )} +
); } diff --git a/inventory/src/pages/ProductEditor.tsx b/inventory/src/pages/ProductEditor.tsx index 90decbd..7717f20 100644 --- a/inventory/src/pages/ProductEditor.tsx +++ b/inventory/src/pages/ProductEditor.tsx @@ -1,8 +1,9 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import axios from "axios"; import { toast } from "sonner"; -import { Loader2 } from "lucide-react"; +import { Loader2, RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; @@ -20,10 +21,117 @@ import { ProductSearch } from "@/components/product-editor/ProductSearch"; import { ProductEditForm, LAYOUT_ICONS } from "@/components/product-editor/ProductEditForm"; import type { LayoutMode } from "@/components/product-editor/ProductEditForm"; import type { SearchProduct, FieldOptions, FieldOption, LineOption, LandingExtra } from "@/components/product-editor/types"; +import { ExternalLink } from "lucide-react"; const PER_PAGE = 20; const PROD_IMG_HOST = "https://sbing.com"; +interface FilterSummaryItem { + type: string; + op: string; + v1: string | null; + v2: string | null; + v1Label: string | null; + v2Label: string | null; +} + +const OP_SYMBOLS: Record = { + equals: "=", + notequals: "≠", + greater: ">", + greater_equals: "≥", + less: "<", + less_equals: "≤", + contains: "contains", + notcontains: "excludes", + begins: "starts with", +}; + +function formatFilterBadges(filters: FilterSummaryItem[]): { key: string; text: string }[] { + // Group same type+op rows so "line=5 OR line=7" becomes "Line = Lawn Fawn, Carta Bella" + const groups = new Map(); + for (const f of filters) { + const key = `${f.type}:${f.op}`; + const display = f.v1Label ?? f.v1; + if (groups.has(key)) { + if (display) groups.get(key)!.v1s.push(display); + } else { + groups.set(key, { type: f.type, op: f.op, v1s: display ? [display] : [], v2: f.v2Label ?? f.v2 }); + } + } + return Array.from(groups.entries()).map(([key, g]) => { + const label = FILTER_LABELS[g.type] ?? g.type.replace(/_/g, " "); + let text: string; + if (["true", "true1"].includes(g.op)) { + text = label; + } else if (g.op === "false") { + text = `not ${label}`; + } else if (g.op === "isnull") { + text = `${label} is empty`; + } else if (g.op === "between" && g.v2) { + text = `${label}: ${g.v1s[0] ?? ""}–${g.v2}`; + } else { + const sym = OP_SYMBOLS[g.op] ?? g.op; + text = g.v1s.length ? `${label} ${sym} ${g.v1s.join(", ")}` : label; + } + return { key, text }; + }); +} + +const FILTER_LABELS: Record = { + company: "company", + line: "line", + subline: "subline", + no_company: "no company", + no_line: "no line", + no_subline: "no subline", + artist: "artist", + price: "price", + default_price: "default price", + price_for_sort: "price for sort", + salepercent_for_sort: "sale % for sort", + "salepercent_for_sort__clearance": "sale % for sort (clearance)", + weight: "weight", + weight_price_ratio: "weight/price ratio", + price_weight_ratio: "price/weight ratio", + length: "length", + width: "width", + height: "height", + no_dim: "no dimensions", + size_cat: "size category", + dimension: "dimension", + yarn_weight: "yarn weight", + material: "material", + hide: "hidden", + hide_in_shop: "hidden in shop", + discontinued: "discontinued", + force_flag: "force flag", + exclusive: "exclusive", + lock_quantity: "lock qty", + show_notify: "show notify", + downloadable: "downloadable", + usa_only: "usa only", + not_clearance: "not clearance", + stat_stop: "stats stopped", + notnew: "not new", + not_backinstock: "not back-in-stock", + reorder: "reorder", + score: "score", + sold_view_score: "sold/view score", + visibility_score: "visibility score", + health_score: "health score", + tax_code: "tax code", + investor: "investor", + category: "category", + theme: "theme", + pid: "product id", + basket: "basket", + vendor: "vendor", + vendor_reference: "supplier id", + notions_reference: "notions id", + itemnumber: "item number", +}; + /** Strip all HTML except , , and
tags */ function sanitizeHtml(html: string): string { return html.replace(/<\/?(?!b>|br\s*\/?>)[^>]*>/gi, ""); @@ -40,15 +148,14 @@ export default function ProductEditor() { const [isLoadingOptions, setIsLoadingOptions] = useState(true); const [isLoadingProducts, setIsLoadingProducts] = useState(false); const [layoutMode, setLayoutMode] = useState("full"); - const [page, _setPage] = useState(1); - const topRef = useRef(null); - const setPage = useCallback((v: number | ((p: number) => number)) => { - _setPage(v); - setTimeout(() => topRef.current?.scrollIntoView({ behavior: "smooth" }), 0); - }, []); + const [page, setPage] = useState(1); const [activeTab, setActiveTab] = useState("new"); const [loadedTab, setLoadedTab] = useState(null); + // Query picker state + const [queryId, setQueryId] = useState(""); + const [queryStatus, setQueryStatus] = useState<{ id: string; name: string; count: number; filters: FilterSummaryItem[]; unsupported: string[] } | null>(null); + // Line picker state const [lineCompany, setLineCompany] = useState(""); const [lineLine, setLineLine] = useState(""); @@ -222,6 +329,8 @@ export default function ProductEditor() { // Auto-load when switching tabs const handleTabChange = useCallback((tab: string) => { setActiveTab(tab); + setQueryStatus(null); + setQueryId(""); if (tab === "new" && loadedTab !== "new") { setLoadedTab("new"); loadFeedProducts("new-products", "new"); @@ -233,7 +342,7 @@ export default function ProductEditor() { } else if (tab === "hidden" && loadedTab !== "hidden") { setLoadedTab("hidden"); loadFeedProducts("hidden-new-products", "hidden"); - } else if (tab === "search" || tab === "by-line") { + } else if (tab === "search" || tab === "by-line" || tab === "by-query") { abortRef.current?.abort(); setAllProducts([]); setPage(1); @@ -261,6 +370,40 @@ export default function ProductEditor() { } }, [lineCompany, lineLine, lineSubline]); + const loadQueryProducts = useCallback(async () => { + const qid = queryId.trim(); + if (!qid || isNaN(Number(qid))) return; + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + setAllProducts([]); + setQueryStatus(null); + setIsLoadingProducts(true); + try { + const res = await axios.get("/api/import/query-products", { + params: { query_id: qid }, + signal: controller.signal, + }); + const { results, filters, unsupported } = res.data; + setAllProducts(results); + setPage(1); + setQueryStatus({ + id: qid, + name: res.headers["x-query-name"] || "", + count: results.length, + filters: filters ?? [], + unsupported: unsupported ?? [], + }); + if (unsupported?.length) { + toast.warning(`Query #${qid}: ${unsupported.length} unsupported filter(s) removed — results may be broader than expected`); + } + } catch (e) { + if (!axios.isCancel(e)) toast.error("Failed to load query products"); + } finally { + setIsLoadingProducts(false); + } + }, [queryId]); + const renderLandingExtras = (tabKey: string) => { const extras = landingExtras[tabKey]; if (!extras || extras.length === 0) return null; @@ -412,6 +555,7 @@ export default function ProductEditor() { Pre-Order Hidden (New) By Line + By Query Search @@ -424,6 +568,64 @@ export default function ProductEditor() { /> + +
+ setQueryId(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") loadQueryProducts(); }} + className="w-52" + /> + + {queryStatus && ( + + )} +
+ {isLoadingProducts && ( +
+ + Loading products... +
+ )} + {queryStatus && !isLoadingProducts && ( +
+
+ Showing {queryStatus.count} product{queryStatus.count !== 1 ? "s" : ""} from query {queryStatus.id} + {queryStatus.name ? ` — ${queryStatus.name}` : ""}.{" "} + + Open in Product Tool + +
+ {queryStatus.filters.length > 0 && ( +
+ Filters: + {formatFilterBadges(queryStatus.filters).map(({ key, text }) => ( + + {text} + + ))} +
+ )} + {queryStatus.unsupported.length > 0 && ( +
+ {queryStatus.unsupported.length} filter type{queryStatus.unsupported.length !== 1 ? "s" : ""} not supported ({queryStatus.unsupported.join(", ")}). Results may be broader than expected. +
+ )} +
+ )} +
+ {isLoadingExtras && !landingExtras["new"] && (
@@ -504,10 +706,15 @@ export default function ProductEditor() { Load
+ {isLoadingProducts && ( +
+ + Loading line products... +
+ )}
-
{renderPagination()} {products.length > 0 && fieldOptions && (