Compare commits
2 Commits
39b8faa208
...
f8b81d2111
| Author | SHA1 | Date | |
|---|---|---|---|
| f8b81d2111 | |||
| 1b836567cd |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -85,3 +85,5 @@ venv/
|
||||
venv/**
|
||||
**/venv/*
|
||||
**/venv/**
|
||||
|
||||
inventory-server/data/taxonomy-embeddings.json
|
||||
@@ -51,6 +51,10 @@ async function ensureInitialized() {
|
||||
...result.stats,
|
||||
groqEnabled: result.groqEnabled
|
||||
});
|
||||
|
||||
// Watch for taxonomy changes in the background (checks every hour)
|
||||
aiService.startBackgroundCheck(getDbConnection);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[AI Routes] Failed to initialize AI service:', error);
|
||||
@@ -431,4 +435,16 @@ router.post('/validate/sanity-check', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Kick off AI initialization in the background (no-op if already initialized).
|
||||
* Call once from server startup so the taxonomy embeddings are ready before
|
||||
* the first user request hits a taxonomy dropdown.
|
||||
*/
|
||||
function initInBackground() {
|
||||
ensureInitialized().catch(err =>
|
||||
console.error('[AI Routes] Background initialization failed:', err)
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
module.exports.initInBackground = initInBackground;
|
||||
|
||||
@@ -1895,4 +1895,772 @@ router.get('/product-categories/:pid', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -162,6 +162,8 @@ async function startServer() {
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[Server] Running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`);
|
||||
// Pre-warm AI service so taxonomy embeddings are ready before first user request
|
||||
aiRouter.initInBackground();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
|
||||
@@ -3,13 +3,26 @@
|
||||
*
|
||||
* Generates and caches embeddings for categories, themes, and colors.
|
||||
* Excludes "Black Friday", "Gifts", "Deals" categories and their children.
|
||||
*
|
||||
* Disk cache: embeddings are saved to data/taxonomy-embeddings.json and reused
|
||||
* across server restarts. Cache is invalidated by content hash — if the taxonomy
|
||||
* rows in MySQL change, the next check will detect it and regenerate automatically.
|
||||
*
|
||||
* Background check: after initialization, call startBackgroundCheck(getConnectionFn)
|
||||
* to poll for taxonomy changes on a configurable interval (default 1h).
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { findTopMatches } = require('./similarity');
|
||||
|
||||
// Categories to exclude (and all their children)
|
||||
const EXCLUDED_CATEGORY_NAMES = ['black friday', 'gifts', 'deals'];
|
||||
|
||||
// Disk cache config
|
||||
const CACHE_PATH = path.join(__dirname, '..', '..', '..', '..', 'data', 'taxonomy-embeddings.json');
|
||||
|
||||
class TaxonomyEmbeddings {
|
||||
constructor({ provider, logger }) {
|
||||
this.provider = provider;
|
||||
@@ -25,12 +38,18 @@ class TaxonomyEmbeddings {
|
||||
this.themeMap = new Map();
|
||||
this.colorMap = new Map();
|
||||
|
||||
// Content hash of the last successfully built taxonomy (from DB rows)
|
||||
this.contentHash = null;
|
||||
|
||||
this.initialized = false;
|
||||
this.initializing = false;
|
||||
this._checkInterval = null;
|
||||
this._regenerating = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize embeddings - fetch taxonomy and generate embeddings
|
||||
* Initialize embeddings — fetches raw taxonomy rows to compute a content hash,
|
||||
* then either loads the matching disk cache or generates fresh embeddings.
|
||||
*/
|
||||
async initialize(connection) {
|
||||
if (this.initialized) {
|
||||
@@ -48,42 +67,36 @@ class TaxonomyEmbeddings {
|
||||
this.initializing = true;
|
||||
|
||||
try {
|
||||
this.logger.info('[TaxonomyEmbeddings] Starting initialization...');
|
||||
// Always fetch raw rows first — cheap (~10ms), no OpenAI calls.
|
||||
// Used to compute a content hash for cache validation.
|
||||
const rawRows = await this._fetchRawRows(connection);
|
||||
const freshHash = this._computeContentHash(rawRows);
|
||||
|
||||
// Fetch raw taxonomy data
|
||||
const [categories, themes, colors] = await Promise.all([
|
||||
this._fetchCategories(connection),
|
||||
this._fetchThemes(connection),
|
||||
this._fetchColors(connection)
|
||||
]);
|
||||
const cached = this._loadCache();
|
||||
if (cached && cached.contentHash === freshHash) {
|
||||
this.categories = cached.categories;
|
||||
this.themes = cached.themes;
|
||||
this.colors = cached.colors;
|
||||
this.categoryMap = new Map(this.categories.map(c => [c.id, c]));
|
||||
this.themeMap = new Map(this.themes.map(t => [t.id, t]));
|
||||
this.colorMap = new Map(this.colors.map(c => [c.id, c]));
|
||||
this.contentHash = freshHash;
|
||||
this.initialized = true;
|
||||
this.logger.info(`[TaxonomyEmbeddings] Loaded from cache: ${this.categories.length} categories, ${this.themes.length} themes, ${this.colors.length} colors`);
|
||||
return { categories: this.categories.length, themes: this.themes.length, colors: this.colors.length };
|
||||
}
|
||||
|
||||
this.logger.info(`[TaxonomyEmbeddings] Fetched ${categories.length} categories, ${themes.length} themes, ${colors.length} colors`);
|
||||
|
||||
// Generate embeddings in parallel
|
||||
const [catEmbeddings, themeEmbeddings, colorEmbeddings] = await Promise.all([
|
||||
this._generateEmbeddings(categories, 'categories'),
|
||||
this._generateEmbeddings(themes, 'themes'),
|
||||
this._generateEmbeddings(colors, 'colors')
|
||||
]);
|
||||
|
||||
// Store with embeddings
|
||||
this.categories = catEmbeddings;
|
||||
this.themes = themeEmbeddings;
|
||||
this.colors = colorEmbeddings;
|
||||
|
||||
// Build lookup maps
|
||||
this.categoryMap = new Map(this.categories.map(c => [c.id, c]));
|
||||
this.themeMap = new Map(this.themes.map(t => [t.id, t]));
|
||||
this.colorMap = new Map(this.colors.map(c => [c.id, c]));
|
||||
if (cached) {
|
||||
this.logger.info('[TaxonomyEmbeddings] Taxonomy changed since cache was built, regenerating...');
|
||||
} else {
|
||||
this.logger.info('[TaxonomyEmbeddings] No cache — fetching taxonomy and generating embeddings...');
|
||||
}
|
||||
|
||||
await this._buildAndEmbed(rawRows, freshHash);
|
||||
this.initialized = true;
|
||||
this.logger.info('[TaxonomyEmbeddings] Initialization complete');
|
||||
|
||||
return {
|
||||
categories: this.categories.length,
|
||||
themes: this.themes.length,
|
||||
colors: this.colors.length
|
||||
};
|
||||
return { categories: this.categories.length, themes: this.themes.length, colors: this.colors.length };
|
||||
} catch (error) {
|
||||
this.logger.error('[TaxonomyEmbeddings] Initialization failed:', error);
|
||||
throw error;
|
||||
@@ -92,6 +105,47 @@ class TaxonomyEmbeddings {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a background interval that checks for taxonomy changes and regenerates
|
||||
* embeddings automatically if the content hash differs.
|
||||
*
|
||||
* @param {Function} getConnectionFn - async function returning { connection }
|
||||
* @param {number} intervalMs - check interval, default 1 hour
|
||||
*/
|
||||
startBackgroundCheck(getConnectionFn, intervalMs = 60 * 60 * 1000) {
|
||||
if (this._checkInterval) return;
|
||||
|
||||
this.logger.info(`[TaxonomyEmbeddings] Background taxonomy check started (every ${intervalMs / 60000} min)`);
|
||||
|
||||
this._checkInterval = setInterval(async () => {
|
||||
if (this._regenerating) return;
|
||||
|
||||
try {
|
||||
const { connection } = await getConnectionFn();
|
||||
const rawRows = await this._fetchRawRows(connection);
|
||||
const freshHash = this._computeContentHash(rawRows);
|
||||
|
||||
if (freshHash === this.contentHash) return;
|
||||
|
||||
this.logger.info('[TaxonomyEmbeddings] Taxonomy changed, regenerating embeddings in background...');
|
||||
this._regenerating = true;
|
||||
await this._buildAndEmbed(rawRows, freshHash);
|
||||
this.logger.info('[TaxonomyEmbeddings] Background regeneration complete');
|
||||
} catch (err) {
|
||||
this.logger.warn('[TaxonomyEmbeddings] Background taxonomy check failed:', err.message);
|
||||
} finally {
|
||||
this._regenerating = false;
|
||||
}
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
stopBackgroundCheck() {
|
||||
if (this._checkInterval) {
|
||||
clearInterval(this._checkInterval);
|
||||
this._checkInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find similar categories for a product embedding
|
||||
*/
|
||||
@@ -176,29 +230,74 @@ class TaxonomyEmbeddings {
|
||||
// Private Methods
|
||||
// ============================================================================
|
||||
|
||||
async _fetchCategories(connection) {
|
||||
// Fetch hierarchical categories (types 10-13)
|
||||
const [rows] = await connection.query(`
|
||||
SELECT cat_id, name, master_cat_id, type
|
||||
FROM product_categories
|
||||
WHERE type IN (10, 11, 12, 13)
|
||||
ORDER BY type, name
|
||||
`);
|
||||
/**
|
||||
* Fetch minimal raw rows from MySQL — used for content hash computation.
|
||||
* This is the cheap path: no path-building, no embeddings, just the raw data.
|
||||
*/
|
||||
async _fetchRawRows(connection) {
|
||||
const [[catRows], [themeRows], [colorRows]] = await Promise.all([
|
||||
connection.query('SELECT cat_id, name, master_cat_id, type FROM product_categories WHERE type IN (10, 11, 12, 13) ORDER BY cat_id'),
|
||||
connection.query('SELECT cat_id, name, master_cat_id, type FROM product_categories WHERE type IN (20, 21) ORDER BY cat_id'),
|
||||
connection.query('SELECT color, name, hex_color FROM product_color_list ORDER BY `order`')
|
||||
]);
|
||||
return { catRows, themeRows, colorRows };
|
||||
}
|
||||
|
||||
// Build lookup for hierarchy
|
||||
/**
|
||||
* Compute a stable SHA-256 hash of the taxonomy row content.
|
||||
* Any change to IDs, names, or parent relationships will produce a different hash.
|
||||
*/
|
||||
_computeContentHash({ catRows, themeRows, colorRows }) {
|
||||
const content = JSON.stringify({
|
||||
cats: catRows.map(r => [r.cat_id, r.name, r.master_cat_id]).sort((a, b) => a[0] - b[0]),
|
||||
themes: themeRows.map(r => [r.cat_id, r.name, r.master_cat_id]).sort((a, b) => a[0] - b[0]),
|
||||
colors: colorRows.map(r => [r.color, r.name]).sort()
|
||||
});
|
||||
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build full taxonomy objects and generate embeddings, then atomically swap
|
||||
* the in-memory state. Called on cache miss and on background change detection.
|
||||
*/
|
||||
async _buildAndEmbed(rawRows, contentHash) {
|
||||
const { catRows, themeRows, colorRows } = rawRows;
|
||||
|
||||
const categories = this._buildCategories(catRows);
|
||||
const themes = this._buildThemes(themeRows);
|
||||
const colors = this._buildColors(colorRows);
|
||||
|
||||
this.logger.info(`[TaxonomyEmbeddings] Generating embeddings for ${categories.length} categories, ${themes.length} themes, ${colors.length} colors`);
|
||||
|
||||
const [catEmbeddings, themeEmbeddings, colorEmbeddings] = await Promise.all([
|
||||
this._generateEmbeddings(categories, 'categories'),
|
||||
this._generateEmbeddings(themes, 'themes'),
|
||||
this._generateEmbeddings(colors, 'colors')
|
||||
]);
|
||||
|
||||
// Atomic in-memory swap (single-threaded JS — readers always see a consistent state)
|
||||
this.categories = catEmbeddings;
|
||||
this.themes = themeEmbeddings;
|
||||
this.colors = colorEmbeddings;
|
||||
this.categoryMap = new Map(this.categories.map(c => [c.id, c]));
|
||||
this.themeMap = new Map(this.themes.map(t => [t.id, t]));
|
||||
this.colorMap = new Map(this.colors.map(c => [c.id, c]));
|
||||
this.contentHash = contentHash;
|
||||
|
||||
this._saveCache();
|
||||
}
|
||||
|
||||
_buildCategories(rows) {
|
||||
const byId = new Map(rows.map(r => [r.cat_id, r]));
|
||||
|
||||
// Find IDs of excluded top-level categories and all their descendants
|
||||
const excludedIds = new Set();
|
||||
|
||||
// First pass: find excluded top-level categories
|
||||
for (const row of rows) {
|
||||
if (row.type === 10 && EXCLUDED_CATEGORY_NAMES.includes(row.name.toLowerCase())) {
|
||||
excludedIds.add(row.cat_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Multiple passes to find all descendants
|
||||
// Multiple passes to find all descendants of excluded categories
|
||||
let foundNew = true;
|
||||
while (foundNew) {
|
||||
foundNew = false;
|
||||
@@ -212,20 +311,14 @@ class TaxonomyEmbeddings {
|
||||
|
||||
this.logger.info(`[TaxonomyEmbeddings] Excluding ${excludedIds.size} categories (Black Friday, Gifts, Deals and children)`);
|
||||
|
||||
// Build category objects with full paths, excluding filtered ones
|
||||
const categories = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (excludedIds.has(row.cat_id)) {
|
||||
continue;
|
||||
}
|
||||
if (excludedIds.has(row.cat_id)) continue;
|
||||
|
||||
const path = [];
|
||||
const pathParts = [];
|
||||
let current = row;
|
||||
|
||||
// Walk up the tree to build full path
|
||||
while (current) {
|
||||
path.unshift(current.name);
|
||||
pathParts.unshift(current.name);
|
||||
current = current.master_cat_id ? byId.get(current.master_cat_id) : null;
|
||||
}
|
||||
|
||||
@@ -234,55 +327,37 @@ class TaxonomyEmbeddings {
|
||||
name: row.name,
|
||||
parentId: row.master_cat_id,
|
||||
type: row.type,
|
||||
fullPath: path.join(' > '),
|
||||
embeddingText: path.join(' ')
|
||||
fullPath: pathParts.join(' > '),
|
||||
embeddingText: pathParts.join(' ')
|
||||
});
|
||||
}
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
async _fetchThemes(connection) {
|
||||
// Fetch themes (types 20-21)
|
||||
const [rows] = await connection.query(`
|
||||
SELECT cat_id, name, master_cat_id, type
|
||||
FROM product_categories
|
||||
WHERE type IN (20, 21)
|
||||
ORDER BY type, name
|
||||
`);
|
||||
|
||||
_buildThemes(rows) {
|
||||
const byId = new Map(rows.map(r => [r.cat_id, r]));
|
||||
const themes = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const path = [];
|
||||
return rows.map(row => {
|
||||
const pathParts = [];
|
||||
let current = row;
|
||||
|
||||
while (current) {
|
||||
path.unshift(current.name);
|
||||
pathParts.unshift(current.name);
|
||||
current = current.master_cat_id ? byId.get(current.master_cat_id) : null;
|
||||
}
|
||||
|
||||
themes.push({
|
||||
return {
|
||||
id: row.cat_id,
|
||||
name: row.name,
|
||||
parentId: row.master_cat_id,
|
||||
type: row.type,
|
||||
fullPath: path.join(' > '),
|
||||
embeddingText: path.join(' ')
|
||||
});
|
||||
}
|
||||
|
||||
return themes;
|
||||
fullPath: pathParts.join(' > '),
|
||||
embeddingText: pathParts.join(' ')
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async _fetchColors(connection) {
|
||||
const [rows] = await connection.query(`
|
||||
SELECT color, name, hex_color
|
||||
FROM product_color_list
|
||||
ORDER BY \`order\`
|
||||
`);
|
||||
|
||||
_buildColors(rows) {
|
||||
return rows.map(row => ({
|
||||
id: row.color,
|
||||
name: row.name,
|
||||
@@ -301,9 +376,7 @@ class TaxonomyEmbeddings {
|
||||
const results = [...items];
|
||||
|
||||
// Process in batches
|
||||
let batchNum = 0;
|
||||
for await (const chunk of this.provider.embedBatchChunked(texts, { batchSize: 100 })) {
|
||||
batchNum++;
|
||||
for (let i = 0; i < chunk.embeddings.length; i++) {
|
||||
const globalIndex = chunk.startIndex + i;
|
||||
results[globalIndex] = {
|
||||
@@ -318,6 +391,43 @@ class TaxonomyEmbeddings {
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Disk Cache Methods
|
||||
// ============================================================================
|
||||
|
||||
_loadCache() {
|
||||
try {
|
||||
if (!fs.existsSync(CACHE_PATH)) return null;
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8'));
|
||||
if (!data.contentHash || !data.categories?.length || !data.themes?.length || !data.colors?.length) {
|
||||
this.logger.warn('[TaxonomyEmbeddings] Disk cache malformed or missing content hash, will regenerate');
|
||||
return null;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
this.logger.warn('[TaxonomyEmbeddings] Failed to load disk cache:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
_saveCache() {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(CACHE_PATH), { recursive: true });
|
||||
fs.writeFileSync(CACHE_PATH, JSON.stringify({
|
||||
generatedAt: new Date().toISOString(),
|
||||
contentHash: this.contentHash,
|
||||
categories: this.categories,
|
||||
themes: this.themes,
|
||||
colors: this.colors,
|
||||
}));
|
||||
this.logger.info(`[TaxonomyEmbeddings] Disk cache saved to ${CACHE_PATH}`);
|
||||
} catch (err) {
|
||||
this.logger.warn('[TaxonomyEmbeddings] Failed to save disk cache:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { TaxonomyEmbeddings };
|
||||
|
||||
@@ -124,6 +124,17 @@ function isReady() {
|
||||
return initialized && taxonomyEmbeddings?.isReady();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start background taxonomy change detection.
|
||||
* Call once after initialization, passing a function that returns { connection }.
|
||||
* @param {Function} getConnectionFn
|
||||
* @param {number} [intervalMs] - default 1 hour
|
||||
*/
|
||||
function startBackgroundCheck(getConnectionFn, intervalMs) {
|
||||
if (!initialized || !taxonomyEmbeddings) return;
|
||||
taxonomyEmbeddings.startBackgroundCheck(getConnectionFn, intervalMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build weighted product text for embedding.
|
||||
* Weights the product name heavily by repeating it, and truncates long descriptions
|
||||
@@ -362,6 +373,7 @@ module.exports = {
|
||||
initialize,
|
||||
isReady,
|
||||
getStatus,
|
||||
startBackgroundCheck,
|
||||
|
||||
// Embeddings (OpenAI)
|
||||
getProductEmbedding,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { useState, useMemo, useCallback, useLayoutEffect, useRef } from "react";
|
||||
import { Check, ChevronsUpDown, Sparkles, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { badgeVariants } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FieldOption } from "./types";
|
||||
import type { TaxonomySuggestion } from "@/components/product-import/steps/ValidationStep/store/types";
|
||||
|
||||
interface ColorOption extends FieldOption {
|
||||
hex?: string;
|
||||
@@ -34,6 +36,39 @@ function isWhite(hex: string) {
|
||||
return /^#?f{3,6}$/i.test(hex);
|
||||
}
|
||||
|
||||
function TruncatedBadge({ label, hex }: { label: string; hex?: string }) {
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = textRef.current;
|
||||
if (el) setIsTruncated(el.scrollWidth > el.clientWidth);
|
||||
}, [label]);
|
||||
|
||||
return (
|
||||
<Tooltip open={isTruncated ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn(badgeVariants({ variant: "secondary" }), "text-[11px] py-0 px-1.5 gap-1 font-normal max-w-full")}>
|
||||
{hex && (
|
||||
<span
|
||||
className={cn("inline-block h-2.5 w-2.5 rounded-full shrink-0", isWhite(hex) && "border border-black")}
|
||||
style={{ backgroundColor: hex }}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
ref={textRef}
|
||||
className="overflow-hidden whitespace-nowrap"
|
||||
style={{ direction: "rtl", textOverflow: "ellipsis" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditableMultiSelect({
|
||||
options,
|
||||
value,
|
||||
@@ -42,6 +77,9 @@ export function EditableMultiSelect({
|
||||
placeholder,
|
||||
searchPlaceholder,
|
||||
showColors,
|
||||
suggestions,
|
||||
isLoadingSuggestions,
|
||||
onOpen,
|
||||
}: {
|
||||
options: FieldOption[];
|
||||
value: string[];
|
||||
@@ -50,9 +88,17 @@ export function EditableMultiSelect({
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
showColors?: boolean;
|
||||
suggestions?: TaxonomySuggestion[];
|
||||
isLoadingSuggestions?: boolean;
|
||||
onOpen?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleOpenChange = useCallback((isOpen: boolean) => {
|
||||
setOpen(isOpen);
|
||||
if (isOpen) onOpen?.();
|
||||
}, [onOpen]);
|
||||
|
||||
const selectedLabels = useMemo(() => {
|
||||
return value.map((v) => {
|
||||
const opt = options.find((o) => String(o.value) === String(v));
|
||||
@@ -82,7 +128,7 @@ export function EditableMultiSelect({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
onClick={() => handleOpenChange(true)}
|
||||
className={cn(
|
||||
"flex flex-col h-auto w-full rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm text-left",
|
||||
"hover:border-input hover:bg-muted/50 transition-colors",
|
||||
@@ -98,22 +144,7 @@ export function EditableMultiSelect({
|
||||
) : (
|
||||
<span className="flex flex-wrap gap-1 w-full">
|
||||
{selectedLabels.map((s) => (
|
||||
<Badge
|
||||
key={s.value}
|
||||
variant="secondary"
|
||||
className="text-[11px] py-0 px-1.5 gap-1 shrink-0 font-normal"
|
||||
>
|
||||
{s.hex && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-2.5 w-2.5 rounded-full shrink-0",
|
||||
isWhite(s.hex) && "border border-black"
|
||||
)}
|
||||
style={{ backgroundColor: s.hex }}
|
||||
/>
|
||||
)}
|
||||
{s.label}
|
||||
</Badge>
|
||||
<TruncatedBadge key={s.value} label={s.label} hex={s.hex} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
@@ -126,7 +157,7 @@ export function EditableMultiSelect({
|
||||
{label && (
|
||||
<span className="text-xs text-muted-foreground mb-1 block">{label}</span>
|
||||
)}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -192,9 +223,54 @@ export function EditableMultiSelect({
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* AI Suggestions section */}
|
||||
{(suggestions && suggestions.length > 0) || isLoadingSuggestions ? (
|
||||
<CommandGroup>
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-purple-600 dark:text-purple-400 bg-purple-50/80 dark:bg-purple-950/40 border-b border-purple-100 dark:border-purple-900">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
<span>Suggested</span>
|
||||
{isLoadingSuggestions && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
</div>
|
||||
{suggestions?.slice(0, 5).map((suggestion) => {
|
||||
const isSelected = value.includes(String(suggestion.id));
|
||||
if (isSelected) return null;
|
||||
const similarityPercent = Math.round(suggestion.similarity * 100);
|
||||
const opt = options.find((o) => String(o.value) === String(suggestion.id)) as ColorOption | undefined;
|
||||
const hex = showColors && opt ? getHex(opt) : undefined;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`suggestion-${suggestion.id}`}
|
||||
value={`suggestion-${suggestion.name}`}
|
||||
onSelect={() => handleSelect(String(suggestion.id))}
|
||||
className="bg-purple-50/30 dark:bg-purple-950/20"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<Check className="h-4 w-4 flex-shrink-0 opacity-0" />
|
||||
{hex && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full mr-2 shrink-0",
|
||||
isWhite(hex) && "border border-black"
|
||||
)}
|
||||
style={{ backgroundColor: hex }}
|
||||
/>
|
||||
)}
|
||||
<span title={suggestion.fullPath || suggestion.name}>
|
||||
{showColors ? suggestion.name : (suggestion.fullPath || suggestion.name)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-purple-500 dark:text-purple-400 ml-2 flex-shrink-0">
|
||||
{similarityPercent}%
|
||||
</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
|
||||
{/* All options (excluding already-selected) */}
|
||||
<CommandGroup
|
||||
heading={value.length > 0 ? "All Options" : undefined}
|
||||
heading={value.length > 0 || (suggestions && suggestions.length > 0) ? "All Options" : undefined}
|
||||
>
|
||||
{options
|
||||
.filter((o) => !value.includes(String(o.value)))
|
||||
|
||||
@@ -16,6 +16,7 @@ import { submitProductEdit, submitImageChanges, submitTaxonomySet, type ImageCha
|
||||
import { EditableComboboxField } from "./EditableComboboxField";
|
||||
import { EditableInput } from "./EditableInput";
|
||||
import { EditableMultiSelect } from "./EditableMultiSelect";
|
||||
import { useProductSuggestions } from "./useProductSuggestions";
|
||||
import { ImageManager, MiniImagePreview } from "./ImageManager";
|
||||
import type {
|
||||
SearchProduct,
|
||||
@@ -503,6 +504,20 @@ export function ProductEditForm({
|
||||
}
|
||||
}, [getValues, fieldOptions, validateDescription, clearDescriptionResult]);
|
||||
|
||||
// --- Embedding-based taxonomy suggestions ---
|
||||
const {
|
||||
categories: categorySuggestions,
|
||||
themes: themeSuggestions,
|
||||
colors: colorSuggestions,
|
||||
isLoading: isSuggestionsLoading,
|
||||
triggerFetch: triggerSuggestions,
|
||||
} = useProductSuggestions({
|
||||
name: product.title,
|
||||
description: product.description,
|
||||
company_name: product.brand,
|
||||
line_name: product.line,
|
||||
});
|
||||
|
||||
const hasImageChanges = computeImageChanges() !== null;
|
||||
const changedCount = Object.keys(dirtyFields).length;
|
||||
|
||||
@@ -560,6 +575,11 @@ export function ProductEditForm({
|
||||
);
|
||||
}
|
||||
if (fc.type === "multiselect") {
|
||||
const fieldSuggestions =
|
||||
fc.key === "categories" ? categorySuggestions :
|
||||
fc.key === "themes" ? themeSuggestions :
|
||||
fc.key === "colors" ? colorSuggestions :
|
||||
undefined;
|
||||
return wrapSpan(
|
||||
<Controller
|
||||
key={fc.key}
|
||||
@@ -574,6 +594,9 @@ export function ProductEditForm({
|
||||
placeholder="—"
|
||||
searchPlaceholder={fc.searchPlaceholder}
|
||||
showColors={fc.showColors}
|
||||
suggestions={fieldSuggestions}
|
||||
isLoadingSuggestions={isSuggestionsLoading}
|
||||
onOpen={triggerSuggestions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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<number | null>(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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Search Products</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Search by name, SKU, UPC, brand..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
<Button onClick={handleSearch} disabled={isSearching}>
|
||||
{isSearching ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-4 w-4" />
|
||||
<div>
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
placeholder="Search products…"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
<Button onClick={handleSearch} disabled={isSearching}>
|
||||
{isSearching ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{(isFocused || searchResults.length === 0) && (
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-3">
|
||||
Search by name, item number, UPC, company, supplier, supplier id, notions #, line, subline, artist
|
||||
</p>
|
||||
)}
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
<Collapsible open={resultsOpen} onOpenChange={setResultsOpen} className="mt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-1 px-2 text-muted-foreground">
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${resultsOpen ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
{isTruncated
|
||||
? `Showing ${SEARCH_LIMIT} of ${totalCount} results`
|
||||
: `${totalCount} ${totalCount === 1 ? "result" : "results"}`}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
{unloadedCount > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={handleLoadAll}>
|
||||
Load all results
|
||||
</Button>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
<Collapsible open={resultsOpen} onOpenChange={setResultsOpen} className="mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-1 px-2 text-muted-foreground">
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${resultsOpen ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
{isTruncated
|
||||
? `Showing ${SEARCH_LIMIT} of ${totalCount} results`
|
||||
: `${totalCount} ${totalCount === 1 ? "result" : "results"}`}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
{unloadedCount > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={handleLoadAll}>
|
||||
Load all results
|
||||
</Button>
|
||||
)}
|
||||
<CollapsibleContent>
|
||||
<div className="border rounded-md mt-2 max-h-80 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="sticky top-0 bg-background">Name</TableHead>
|
||||
<TableHead className="sticky top-0 bg-background">Item Number</TableHead>
|
||||
<TableHead className="sticky top-0 bg-background">Brand</TableHead>
|
||||
<TableHead className="sticky top-0 bg-background">Line</TableHead>
|
||||
<TableHead className="sticky top-0 bg-background text-right">
|
||||
Price
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{searchResults.map((product) => {
|
||||
const isLoaded = loadedPids.has(Number(product.pid));
|
||||
return (
|
||||
<TableRow
|
||||
key={product.pid}
|
||||
className={`${isLoaded ? "opacity-50" : "cursor-pointer hover:bg-muted/50"} ${isLoadingProduct === product.pid ? "opacity-50" : ""}`}
|
||||
onClick={() =>
|
||||
!isLoadingProduct && !isLoaded && handleSelect(product)
|
||||
}
|
||||
>
|
||||
<TableCell className="max-w-[300px] truncate">
|
||||
{isLoadingProduct === product.pid && (
|
||||
<Loader2 className="h-3 w-3 animate-spin inline mr-2" />
|
||||
)}
|
||||
{isLoaded && (
|
||||
<Check className="h-3 w-3 inline mr-2 text-green-600" />
|
||||
)}
|
||||
{product.title}
|
||||
</TableCell>
|
||||
<TableCell>{product.sku}</TableCell>
|
||||
<TableCell>{product.brand}</TableCell>
|
||||
<TableCell>{product.line}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
$
|
||||
{Number(product.regular_price)?.toFixed(2) ??
|
||||
product.regular_price}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="border rounded-md mt-2 max-h-80 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="sticky top-0 bg-background">Name</TableHead>
|
||||
<TableHead className="sticky top-0 bg-background">SKU</TableHead>
|
||||
<TableHead className="sticky top-0 bg-background">Brand</TableHead>
|
||||
<TableHead className="sticky top-0 bg-background">Line</TableHead>
|
||||
<TableHead className="sticky top-0 bg-background text-right">
|
||||
Price
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{searchResults.map((product) => {
|
||||
const isLoaded = loadedPids.has(Number(product.pid));
|
||||
return (
|
||||
<TableRow
|
||||
key={product.pid}
|
||||
className={`${isLoaded ? "opacity-50" : "cursor-pointer hover:bg-muted/50"} ${isLoadingProduct === product.pid ? "opacity-50" : ""}`}
|
||||
onClick={() =>
|
||||
!isLoadingProduct && !isLoaded && handleSelect(product)
|
||||
}
|
||||
>
|
||||
<TableCell className="max-w-[300px] truncate">
|
||||
{isLoadingProduct === product.pid && (
|
||||
<Loader2 className="h-3 w-3 animate-spin inline mr-2" />
|
||||
)}
|
||||
{isLoaded && (
|
||||
<Check className="h-3 w-3 inline mr-2 text-green-600" />
|
||||
)}
|
||||
{product.title}
|
||||
</TableCell>
|
||||
<TableCell>{product.sku}</TableCell>
|
||||
<TableCell>{product.brand}</TableCell>
|
||||
<TableCell>{product.line}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
$
|
||||
{Number(product.regular_price)?.toFixed(2) ??
|
||||
product.regular_price}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{isTruncated && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Showing top {SEARCH_LIMIT} of {totalCount} matches. Refine your search to find specific products.
|
||||
</p>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{isTruncated && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Showing only the first {SEARCH_LIMIT} of {totalCount} matches. Refine your search to find specific products.
|
||||
</p>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
107
inventory/src/components/product-editor/useProductSuggestions.ts
Normal file
107
inventory/src/components/product-editor/useProductSuggestions.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* useProductSuggestions Hook
|
||||
*
|
||||
* Lazily fetches embedding-based taxonomy suggestions (categories, themes, colors)
|
||||
* for a product in the product editor.
|
||||
*
|
||||
* Mirrors the logic in AiSuggestionsContext but simplified for single-product use:
|
||||
* - Fetches once on first triggerFetch() call (no eager batch loading)
|
||||
* - Caches results in local state for the lifetime of the component
|
||||
* - Module-level init promise shared across all instances
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import type { TaxonomySuggestion, ProductSuggestions } from '@/components/product-import/steps/ValidationStep/store/types';
|
||||
|
||||
const API_BASE = '/api/ai';
|
||||
|
||||
// Module-level init promise — shared so we only call /initialize once
|
||||
let initPromise: Promise<boolean> | null = null;
|
||||
|
||||
async function ensureInitialized(): Promise<boolean> {
|
||||
if (!initPromise) {
|
||||
initPromise = fetch(`${API_BASE}/initialize`, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then((d) => Boolean(d.success))
|
||||
.catch(() => {
|
||||
initPromise = null; // allow retry on next call
|
||||
return false;
|
||||
});
|
||||
}
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
interface ProductInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
company_name?: string;
|
||||
line_name?: string;
|
||||
}
|
||||
|
||||
export interface ProductSuggestionResults {
|
||||
categories: TaxonomySuggestion[];
|
||||
themes: TaxonomySuggestion[];
|
||||
colors: TaxonomySuggestion[];
|
||||
isLoading: boolean;
|
||||
/** Call when a taxonomy dropdown opens to trigger a lazy fetch */
|
||||
triggerFetch: () => void;
|
||||
}
|
||||
|
||||
export function useProductSuggestions(product: ProductInput): ProductSuggestionResults {
|
||||
const [categories, setCategories] = useState<TaxonomySuggestion[]>([]);
|
||||
const [themes, setThemes] = useState<TaxonomySuggestion[]>([]);
|
||||
const [colors, setColors] = useState<TaxonomySuggestion[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Store current product in a ref so triggerFetch can read it without being re-created
|
||||
const productRef = useRef(product);
|
||||
productRef.current = product;
|
||||
|
||||
// Pre-warm: start initialization as soon as the form mounts so it's ready before
|
||||
// the first dropdown opens. With the disk cache this completes in < 1 second.
|
||||
useEffect(() => {
|
||||
ensureInitialized();
|
||||
}, []);
|
||||
|
||||
// Prevent duplicate fetches
|
||||
const hasFetchedRef = useRef(false);
|
||||
|
||||
const triggerFetch = useCallback(async () => {
|
||||
if (hasFetchedRef.current) return;
|
||||
const p = productRef.current;
|
||||
if (!p.name && !p.company_name) return;
|
||||
|
||||
hasFetchedRef.current = true;
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const ready = await ensureInitialized();
|
||||
if (!ready) {
|
||||
hasFetchedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/suggestions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ product: p }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
hasFetchedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const data: ProductSuggestions = await response.json();
|
||||
setCategories(data.categories ?? []);
|
||||
setThemes(data.themes ?? []);
|
||||
setColors(data.colors ?? []);
|
||||
} catch {
|
||||
hasFetchedRef.current = false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { categories, themes, colors, isLoading, triggerFetch };
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<string, { type: string; op: string; v1s: string[]; v2: string | null }>();
|
||||
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<string, string> = {
|
||||
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 <b>, </b>, and <br> 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<LayoutMode>("full");
|
||||
const [page, _setPage] = useState(1);
|
||||
const topRef = useRef<HTMLDivElement>(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<string | null>(null);
|
||||
|
||||
// Query picker state
|
||||
const [queryId, setQueryId] = useState<string>("");
|
||||
const [queryStatus, setQueryStatus] = useState<{ id: string; name: string; count: number; filters: FilterSummaryItem[]; unsupported: string[] } | null>(null);
|
||||
|
||||
// Line picker state
|
||||
const [lineCompany, setLineCompany] = useState<string>("");
|
||||
const [lineLine, setLineLine] = useState<string>("");
|
||||
@@ -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() {
|
||||
<TabsTrigger value="preorder">Pre-Order</TabsTrigger>
|
||||
<TabsTrigger value="hidden">Hidden (New)</TabsTrigger>
|
||||
<TabsTrigger value="by-line">By Line</TabsTrigger>
|
||||
<TabsTrigger value="by-query">By Query</TabsTrigger>
|
||||
<TabsTrigger value="search">Search</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -424,6 +568,64 @@ export default function ProductEditor() {
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="by-query" className="mt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
placeholder="Query ID..."
|
||||
value={queryId}
|
||||
onChange={(e) => setQueryId(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") loadQueryProducts(); }}
|
||||
className="w-52"
|
||||
/>
|
||||
<Button onClick={loadQueryProducts} disabled={!queryId.trim() || isNaN(Number(queryId)) || isLoadingProducts}>
|
||||
{isLoadingProducts && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||
Load
|
||||
</Button>
|
||||
{queryStatus && (
|
||||
<Button variant="outline" size="icon" onClick={loadQueryProducts} disabled={isLoadingProducts} title="Refresh query results">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{isLoadingProducts && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading products...
|
||||
</div>
|
||||
)}
|
||||
{queryStatus && !isLoadingProducts && (
|
||||
<div className="mt-3 text-sm text-muted-foreground space-y-1.5">
|
||||
<div>
|
||||
Showing {queryStatus.count} product{queryStatus.count !== 1 ? "s" : ""} from query {queryStatus.id}
|
||||
{queryStatus.name ? ` — ${queryStatus.name}` : ""}.{" "}
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product_tool/${queryStatus.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 underline hover:text-foreground"
|
||||
>
|
||||
Open in Product Tool <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
{queryStatus.filters.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 items-center">
|
||||
<span className="text-xs">Filters:</span>
|
||||
{formatFilterBadges(queryStatus.filters).map(({ key, text }) => (
|
||||
<span key={key} className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium">
|
||||
{text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{queryStatus.unsupported.length > 0 && (
|
||||
<div className="text-amber-600 dark:text-amber-400">
|
||||
{queryStatus.unsupported.length} filter type{queryStatus.unsupported.length !== 1 ? "s" : ""} not supported ({queryStatus.unsupported.join(", ")}). Results may be broader than expected.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="new" className="mt-4">
|
||||
{isLoadingExtras && !landingExtras["new"] && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-3">
|
||||
@@ -504,10 +706,15 @@ export default function ProductEditor() {
|
||||
Load
|
||||
</Button>
|
||||
</div>
|
||||
{isLoadingProducts && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading line products...
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div ref={topRef} />
|
||||
{renderPagination()}
|
||||
|
||||
{products.length > 0 && fieldOptions && (
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user