From da362e32a63b785c7fa599fe746f437ff20a5317 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Thu, 14 May 2026 22:35:01 +0000 Subject: [PATCH] feat(catalog): full vehicle selector flow in brand catalog Brand catalog now follows the same navigation as the regular catalog: 1. Brands -> 2. Models -> 3. Years -> 4. Engines -> 5. Categories -> 6. Parts Backend: - Add /mye-parts endpoint for MYE-specific parts with category filter - Uses existing /models, /years, /engines, /categories endpoints Frontend: - Complete rewrite of brand-catalog.js with breadcrumb navigation - State machine: brands -> models -> years -> engines -> categories -> parts - Search and pagination preserved at parts level - Breadcrumb allows jumping back to any previous step --- pos/blueprints/catalog_bp.py | 104 +++++++++++++++ pos/static/js/brand-catalog.js | 233 ++++++++++++++++++++++++++------- pos/templates/catalog.html | 2 +- 3 files changed, 292 insertions(+), 47 deletions(-) diff --git a/pos/blueprints/catalog_bp.py b/pos/blueprints/catalog_bp.py index 8835809..a466b82 100644 --- a/pos/blueprints/catalog_bp.py +++ b/pos/blueprints/catalog_bp.py @@ -763,3 +763,107 @@ def brand_parts(): finally: cur.close() return _with_conns(_query) + + +@catalog_bp.route('/mye-parts', methods=['GET']) +@require_auth('catalog.view') +def mye_parts(): + """Return parts for a specific MYE + category (brand-catalog flow). + + Skips the group/subgroup level and goes directly from category to parts. + """ + mye_id = request.args.get('mye_id', type=int) + category_id = request.args.get('category_id', type=int) + search = request.args.get('search', '').strip() + limit = request.args.get('limit', 50, type=int) + offset = request.args.get('offset', 0, type=int) + + if not mye_id: + return jsonify({'error': 'mye_id required'}), 400 + + def _query(master, tenant, branch_id): + cur = master.cursor() + try: + # Build dynamic filters + cat_filter = "" + search_filter = "" + params = [mye_id] + + if category_id: + cat_filter = "AND pc.id_part_category = %s" + params.append(category_id) + + if search: + search_filter = "AND (p.oem_part_number ILIKE %s OR COALESCE(NULLIF(p.name_es, ''), p.name_part) ILIKE %s)" + like_term = f"%{search}%" + params.extend([like_term, like_term]) + + # Get parts + query_params = list(params) + cur.execute(f""" + SELECT DISTINCT p.id_part, p.oem_part_number, + COALESCE(NULLIF(p.name_es, ''), p.name_part) as name, + pg.id_part_group, pg.name_part_group, + pc.id_part_category, pc.name_part_category + FROM vehicle_parts vp + JOIN parts p ON p.id_part = vp.part_id + JOIN part_groups pg ON pg.id_part_group = p.group_id + JOIN part_categories pc ON pc.id_part_category = pg.category_id + WHERE vp.model_year_engine_id = %s + {cat_filter} + {search_filter} + ORDER BY p.id_part + LIMIT %s OFFSET %s + """, query_params + [limit, offset]) + + part_rows = cur.fetchall() + part_ids = [r[0] for r in part_rows] + + # Count total + cur.execute(f""" + SELECT COUNT(DISTINCT p.id_part) + FROM vehicle_parts vp + JOIN parts p ON p.id_part = vp.part_id + JOIN part_groups pg ON pg.id_part_group = p.group_id + JOIN part_categories pc ON pc.id_part_category = pg.category_id + WHERE vp.model_year_engine_id = %s + {cat_filter} + {search_filter} + """, params) + total = cur.fetchone()[0] + + # Enrich with local stock if available + local_stock = {} + if tenant and part_ids: + try: + from services.catalog_service import _get_local_stock_bulk + local_stock = _get_local_stock_bulk(tenant, part_ids) + except Exception: + pass + + items = [] + for r in part_rows: + part_id = r[0] + stock_info = local_stock.get(part_id, {}) + items.append({ + 'id': part_id, + 'oem_part_number': r[1], + 'name': r[2], + 'group': {'id': r[3], 'name': r[4]}, + 'category': {'id': r[5], 'name': r[6]}, + 'local_stock': stock_info.get('stock', 0), + 'local_price': stock_info.get('price', None), + }) + + return jsonify({ + 'mye_id': mye_id, + 'category_id': category_id, + 'search': search, + 'items': items, + 'total': total, + 'limit': limit, + 'offset': offset, + }) + finally: + cur.close() + return _with_conns(_query) diff --git a/pos/static/js/brand-catalog.js b/pos/static/js/brand-catalog.js index b02c667..35c680a 100644 --- a/pos/static/js/brand-catalog.js +++ b/pos/static/js/brand-catalog.js @@ -1,14 +1,26 @@ (function() { const BrandCatalog = { - currentBrand: null, - currentCategory: null, state: 'brands', - _lastItems: [], _allBrands: [], + _lastItems: [], _offset: 0, _limit: 50, _total: 0, + // Navigation state + nav: { + brand: null, + brandId: null, + model: null, + modelId: null, + year: null, + yearId: null, + engine: null, + myeId: null, + category: null, + categoryId: null + }, + el: function(id) { return document.getElementById(id); }, _getToken: function() { @@ -49,11 +61,10 @@ }, reset: function() { - this.currentBrand = null; - this.currentCategory = null; this.state = 'brands'; - this._lastItems = []; + this.nav = { brand: null, brandId: null, model: null, modelId: null, year: null, yearId: null, engine: null, myeId: null, category: null, categoryId: null }; this._allBrands = []; + this._lastItems = []; this._offset = 0; this._total = 0; this.el('brandCatalogSearch').innerHTML = ''; @@ -75,10 +86,32 @@ this.el('brandCatalogBreadcrumb').innerHTML = html; }, + buildBreadcrumb: function() { + var parts = []; + parts.push('Marcas'); + if (this.nav.brand) { + parts.push('' + escapeHtml(this.nav.brand) + ''); + } + if (this.nav.model) { + parts.push('' + escapeHtml(this.nav.model) + ''); + } + if (this.nav.year) { + parts.push('' + this.nav.year + ''); + } + if (this.nav.engine) { + parts.push('' + escapeHtml(this.nav.engine) + ''); + } + if (this.nav.category) { + parts.push('' + escapeHtml(this.nav.category) + ''); + } + this.setBreadcrumb(parts.join(' › ')); + }, + // ---------- BRANDS ---------- loadBrands: function() { this.loading(true); this.state = 'brands'; + this.reset(); this.setBreadcrumb('Marcas de vehiculo'); this.setSearch( '' + + html += '
' + '
' + escapeHtml(b.name) + '
' + - '
' + (b.part_count || 0) + ' refacciones
' + '
'; }); this.setContent(html); @@ -131,21 +163,20 @@ this.renderBrandList(filtered); }, - selectBrand: function(brandName) { - this.currentBrand = brandName; - this.loadCategories(brandName); + selectBrand: function(brandName, brandId) { + this.nav.brand = brandName; + this.nav.brandId = brandId; + this.loadModels(brandId); }, - // ---------- CATEGORIES ---------- - loadCategories: function(brandName) { + // ---------- MODELS ---------- + loadModels: function(brandId) { this.loading(true); - this.state = 'categories'; + this.state = 'models'; this.setSearch(''); - this.setBreadcrumb( - 'Marcas' + escapeHtml(brandName) + '' - ); + this.buildBreadcrumb(); var self = this; - fetch('/pos/api/catalog/brand-categories?brand=' + encodeURIComponent(brandName), { headers: this._headers() }) + fetch('/pos/api/catalog/models?brand_id=' + encodeURIComponent(brandId), { headers: this._headers() }) .then(function(r) { if (!self._checkAuth(r)) return null; return r.json(); @@ -153,20 +184,137 @@ .then(function(data) { if (!data) return; self.loading(false); - if (!data.categories || !data.categories.length) { - self.setContent( - '
' + - '

No se encontraron categorias para ' + escapeHtml(brandName) + '.

' + - '' + - '
' - ); + var models = data.data || []; + if (!models.length) { + self.setContent('

No se encontraron modelos.

'); return; } var html = ''; - data.categories.forEach(function(c) { - html += '
' + + models.forEach(function(m) { + html += '
' + + '
' + escapeHtml(m.display_name || m.name_model) + '
' + + '
'; + }); + self.setContent(html); + }) + .catch(function(err) { + self.loading(false); + self.setContent('

Error al cargar modelos: ' + escapeHtml(err.message) + '

'); + }); + }, + + selectModel: function(modelId, modelName) { + this.nav.model = modelName; + this.nav.modelId = modelId; + this.loadYears(modelId); + }, + + // ---------- YEARS ---------- + loadYears: function(modelId) { + this.loading(true); + this.state = 'years'; + this.setSearch(''); + this.buildBreadcrumb(); + var self = this; + fetch('/pos/api/catalog/years?model_id=' + encodeURIComponent(modelId), { headers: this._headers() }) + .then(function(r) { + if (!self._checkAuth(r)) return null; + return r.json(); + }) + .then(function(data) { + if (!data) return; + self.loading(false); + var years = data.data || []; + if (!years.length) { + self.setContent('

No se encontraron años.

'); + return; + } + var html = ''; + years.forEach(function(y) { + html += '
' + + '
' + y.year_car + '
' + + '
'; + }); + self.setContent(html); + }) + .catch(function(err) { + self.loading(false); + self.setContent('

Error al cargar años: ' + escapeHtml(err.message) + '

'); + }); + }, + + selectYear: function(yearId, yearCar) { + this.nav.year = yearCar; + this.nav.yearId = yearId; + this.loadEngines(this.nav.modelId, yearId); + }, + + // ---------- ENGINES ---------- + loadEngines: function(modelId, yearId) { + this.loading(true); + this.state = 'engines'; + this.setSearch(''); + this.buildBreadcrumb(); + var self = this; + fetch('/pos/api/catalog/engines?model_id=' + encodeURIComponent(modelId) + '&year_id=' + encodeURIComponent(yearId), { headers: this._headers() }) + .then(function(r) { + if (!self._checkAuth(r)) return null; + return r.json(); + }) + .then(function(data) { + if (!data) return; + self.loading(false); + var engines = data.data || []; + if (!engines.length) { + self.setContent('

No se encontraron motores.

'); + return; + } + var html = ''; + engines.forEach(function(e) { + html += '
' + + '
' + escapeHtml(e.name_engine) + '
' + + '
' + escapeHtml(e.trim_level || '') + '
' + + '
'; + }); + self.setContent(html); + }) + .catch(function(err) { + self.loading(false); + self.setContent('

Error al cargar motores: ' + escapeHtml(err.message) + '

'); + }); + }, + + selectEngine: function(myeId, engineName) { + this.nav.engine = engineName; + this.nav.myeId = myeId; + this.loadCategories(myeId); + }, + + // ---------- CATEGORIES ---------- + loadCategories: function(myeId) { + this.loading(true); + this.state = 'categories'; + this.setSearch(''); + this.buildBreadcrumb(); + var self = this; + fetch('/pos/api/catalog/categories?mye_id=' + encodeURIComponent(myeId) + '&mode=oem', { headers: this._headers() }) + .then(function(r) { + if (!self._checkAuth(r)) return null; + return r.json(); + }) + .then(function(data) { + if (!data) return; + self.loading(false); + var categories = data.data || []; + if (!categories.length) { + self.setContent('

No se encontraron categorias.

'); + return; + } + var html = ''; + categories.forEach(function(c) { + html += '
' + '
' + escapeHtml(c.name) + '
' + - '
' + c.part_count + ' refacciones
' + + '
' + (c.part_count || 0) + ' refacciones
' + '
'; }); self.setContent(html); @@ -178,20 +326,17 @@ }, selectCategory: function(catId, catName) { - this.currentCategory = { id: catId, name: catName }; + this.nav.category = catName; + this.nav.categoryId = catId; this._offset = 0; - this.loadParts(this.currentBrand, catId, ''); + this.loadParts(this.nav.myeId, catId, ''); }, // ---------- PARTS ---------- - loadParts: function(brandName, categoryId, searchTerm) { + loadParts: function(myeId, categoryId, searchTerm) { this.loading(true); this.state = 'parts'; - this.setBreadcrumb( - 'Marcas › ' + - '' + escapeHtml(brandName) + ' › ' + - '' + escapeHtml(this.currentCategory.name) + '' - ); + this.buildBreadcrumb(); this.setSearch( '
' + 'Limpiar' + '
' ); - var url = '/pos/api/catalog/brand-parts?brand=' + encodeURIComponent(brandName) + '&category_id=' + encodeURIComponent(categoryId) + + var url = '/pos/api/catalog/mye-parts?mye_id=' + encodeURIComponent(myeId) + '&category_id=' + encodeURIComponent(categoryId) + '&limit=' + this._limit + '&offset=' + this._offset; if (searchTerm) { url += '&search=' + encodeURIComponent(searchTerm); @@ -219,11 +364,7 @@ self._lastItems = data.items || []; self._total = data.total || 0; self._offset = data.offset || 0; - if (!data.items || !data.items.length) { - self.renderPartsList([], searchTerm); - return; - } - self.renderPartsList(data.items, searchTerm); + self.renderPartsList(data.items || [], searchTerm); }) .catch(function(err) { self.loading(false); @@ -236,7 +377,7 @@ if (!items.length) { html += '
' + '

No se encontraron refacciones.

' + - '' + + '' + '
'; this.setContent(html); return; @@ -286,12 +427,12 @@ searchParts: function(term) { this._offset = 0; - this.loadParts(this.currentBrand, this.currentCategory.id, term); + this.loadParts(this.nav.myeId, this.nav.categoryId, term); }, clearPartsSearch: function() { this._offset = 0; - this.loadParts(this.currentBrand, this.currentCategory.id, ''); + this.loadParts(this.nav.myeId, this.nav.categoryId, ''); }, goToPage: function(newOffset) { @@ -299,7 +440,7 @@ this._offset = newOffset; var searchInput = document.getElementById('partsSearchInput'); var term = searchInput ? searchInput.value : ''; - this.loadParts(this.currentBrand, this.currentCategory.id, term); + this.loadParts(this.nav.myeId, this.nav.categoryId, term); }, addToCart: function(partId, event) { diff --git a/pos/templates/catalog.html b/pos/templates/catalog.html index 0a9f7b2..dda4078 100644 --- a/pos/templates/catalog.html +++ b/pos/templates/catalog.html @@ -308,6 +308,6 @@ } - +