# /home/Autopartes/pos/blueprints/catalog_bp.py """Catalog blueprint: TecDoc vehicle navigation with local stock enrichment. Endpoints (all under /pos/api/catalog): GET /brands — vehicle brands with parts GET /models?brand_id= — models for a brand GET /years?model_id= — years for a model GET /engines?model_id=&year_id= — engines for model+year GET /categories?mye_id= — part categories for vehicle GET /groups?mye_id=&category_id= — part subcategories for vehicle+category GET /parts?mye_id=&group_id= — parts with local stock enrichment GET /part/ — full part detail (stock + bodegas + alternatives) GET /search?q= — smart search (part number or text) """ from flask import Blueprint, request, jsonify, g from middleware import require_auth from tenant_db import get_master_conn, get_tenant_conn from services import catalog_service from services.vin_decoder import decode_vin from services.plate_lookup import search_plate, register_plate, is_valid_mexican_plate, normalize_plate from config import CATALOG_OEM_ENABLED catalog_bp = Blueprint('catalog', __name__, url_prefix='/pos/api/catalog') def _oem_blocked(): """Return a 403 response if OEM catalog is disabled.""" if not CATALOG_OEM_ENABLED: return jsonify({ 'error': 'Catálogo OEM no disponible', 'message': 'El catálogo OEM está en construcción. Por favor usa el modo Local o Shop Supplies.', 'oem_disabled': True, }), 403 return None def _with_conns(fn): """Helper: open master + tenant connections, call fn, close both. fn receives (master_conn, tenant_conn, branch_id). """ master = None tenant = None try: master = get_master_conn() tenant = get_tenant_conn(g.tenant_id) branch_id = request.args.get('branch_id', g.branch_id) return fn(master, tenant, branch_id) except Exception as e: return jsonify({'error': str(e)}), 500 finally: if master: try: master.close() except: pass if tenant: try: tenant.close() except: pass def _master_only(fn): """Helper: open only master connection for hierarchy endpoints.""" master = None try: master = get_master_conn() return fn(master) except Exception as e: return jsonify({'error': str(e)}), 500 finally: if master: try: master.close() except: pass # ─── Hierarchy navigation (master DB only) ─── @catalog_bp.route('/brands', methods=['GET']) @require_auth('catalog.view') def brands(): from services.catalog_modes import normalize_mode year_id = request.args.get('year_id', type=int) mode = normalize_mode(request.args.get('mode')) def _do(master): data = catalog_service.get_brands(master, year_id=year_id, mode=mode) return jsonify({'data': data, 'mode': mode}) return _master_only(_do) @catalog_bp.route('/models', methods=['GET']) @require_auth('catalog.view') def models(): brand_id = request.args.get('brand_id', type=int) year_id = request.args.get('year_id', type=int) if not brand_id: return jsonify({'error': 'brand_id required'}), 400 def _do(master): data = catalog_service.get_models(master, brand_id, year_id=year_id) return jsonify({'data': data}) return _master_only(_do) @catalog_bp.route('/years', methods=['GET']) @require_auth('catalog.view') def years(): model_id = request.args.get('model_id', type=int) if not model_id: return jsonify({'error': 'model_id required'}), 400 def _do(master): data = catalog_service.get_years(master, model_id) return jsonify({'data': data}) return _master_only(_do) @catalog_bp.route('/years-all', methods=['GET']) @require_auth('catalog.view') def years_all(): """Get all available years (for vehicle selector dropdown).""" def _do(master): cur = master.cursor() cur.execute("SELECT DISTINCT id_year, year_car FROM years ORDER BY year_car DESC") rows = cur.fetchall() cur.close() return jsonify({'data': [{'id_year': r[0], 'year_car': r[1]} for r in rows]}) return _master_only(_do) @catalog_bp.route('/engines', methods=['GET']) @require_auth('catalog.view') def engines(): model_id = request.args.get('model_id', type=int) year_id = request.args.get('year_id', type=int) if not model_id or not year_id: return jsonify({'error': 'model_id and year_id required'}), 400 def _do(master): data = catalog_service.get_engines(master, model_id, year_id) return jsonify({'data': data}) return _master_only(_do) @catalog_bp.route('/categories', methods=['GET']) @require_auth('catalog.view') def categories(): """Categories for a vehicle. OEM mode: TecDoc part_categories (id_part_category, name). Local mode: 14 Nexpart top-level groups, filtered by what's available for this vehicle. Returns 'slug' (string) instead of integer id. """ from services.catalog_modes import normalize_mode mye_id = request.args.get('mye_id', type=int) mode = normalize_mode(request.args.get('mode')) if not mye_id: return jsonify({'error': 'mye_id required'}), 400 def _do(master): if mode == 'local': data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id) else: data = catalog_service.get_categories(master, mye_id) return jsonify({'data': data, 'mode': mode}) return _master_only(_do) @catalog_bp.route('/groups', methods=['GET']) @require_auth('catalog.view') def groups(): """Subgroups for a vehicle + parent category. OEM mode: TecDoc part_groups within a TecDoc part_category (integer ids). Local mode: Nexpart subgroups within a Nexpart group (string slugs). """ from services.catalog_modes import normalize_mode mye_id = request.args.get('mye_id', type=int) category_id = request.args.get('category_id', type=int) category_slug = request.args.get('category_slug') mode = normalize_mode(request.args.get('mode')) if not mye_id: return jsonify({'error': 'mye_id required'}), 400 def _do(master): if mode == 'local': if not category_slug: return jsonify({'error': 'category_slug required for local mode'}), 400 data = catalog_service.get_nexpart_subgroups_for_vehicle(master, mye_id, category_slug) else: if not category_id: return jsonify({'error': 'category_id required for oem mode'}), 400 data = catalog_service.get_groups(master, mye_id, category_id) return jsonify({'data': data, 'mode': mode}) return _master_only(_do) # ─── Parts with stock enrichment (master + tenant) ─── @catalog_bp.route('/part-types', methods=['GET']) @require_auth('catalog.view') def part_types(): """Distinct part types (3rd subcategory level) for a vehicle + group/subgroup. OEM mode: distinct name_part values within a TecDoc part_group_id. Local mode: Nexpart Part Types within a Nexpart group + subgroup. """ from services.catalog_modes import normalize_mode mye_id = request.args.get('mye_id', type=int) group_id = request.args.get('group_id', type=int) group_slug = request.args.get('group_slug') # parent Nexpart group subgroup_slug = request.args.get('subgroup_slug') # current Nexpart subgroup mode = normalize_mode(request.args.get('mode')) if not mye_id: return jsonify({'error': 'mye_id required'}), 400 def _do(master): if mode == 'local': if not group_slug or not subgroup_slug: return jsonify({'error': 'group_slug and subgroup_slug required for local mode'}), 400 data = catalog_service.get_nexpart_part_types_for_vehicle( master, mye_id, group_slug, subgroup_slug ) else: if not group_id: return jsonify({'error': 'group_id required for oem mode'}), 400 data = catalog_service.get_part_types(master, mye_id, group_id) return jsonify({'data': data, 'mode': mode}) return _master_only(_do) @catalog_bp.route('/shop-supplies/groups', methods=['GET']) @require_auth('catalog.view') def shop_supplies_groups(): """Vehicle-independent groups (Chemicals + Tires/Tools).""" def _do(master): data = catalog_service.get_shop_supplies_groups() return jsonify({'data': data}) return _master_only(_do) @catalog_bp.route('/shop-supplies/subgroups', methods=['GET']) @require_auth('catalog.view') def shop_supplies_subgroups(): group_slug = request.args.get('group_slug') if not group_slug: return jsonify({'error': 'group_slug required'}), 400 def _do(master): data = catalog_service.get_shop_supplies_subgroups(master, group_slug) return jsonify({'data': data}) return _master_only(_do) @catalog_bp.route('/shop-supplies/part-types', methods=['GET']) @require_auth('catalog.view') def shop_supplies_part_types(): group_slug = request.args.get('group_slug') subgroup_slug = request.args.get('subgroup_slug') if not group_slug or not subgroup_slug: return jsonify({'error': 'group_slug and subgroup_slug required'}), 400 def _do(master): data = catalog_service.get_shop_supplies_part_types(master, group_slug, subgroup_slug) return jsonify({'data': data}) return _master_only(_do) @catalog_bp.route('/shop-supplies/parts', methods=['GET']) @require_auth('catalog.view') def shop_supplies_parts(): group_slug = request.args.get('group_slug') subgroup_slug = request.args.get('subgroup_slug') part_type_slug = request.args.get('part_type_slug') page = request.args.get('page', 1, type=int) per_page = request.args.get('per_page', 30, type=int) if not group_slug or not subgroup_slug or not part_type_slug: return jsonify({'error': 'group_slug, subgroup_slug, part_type_slug required'}), 400 def _do(master, tenant, branch_id): result = catalog_service.get_shop_supplies_parts( master, group_slug, subgroup_slug, part_type_slug, tenant, branch_id, page, per_page, ) return jsonify(result) return _with_conns(_do) @catalog_bp.route('/parts', methods=['GET']) @require_auth('catalog.view') def parts(): """Parts list for the deepest navigation level. Three call shapes (the endpoint chooses based on which params are present): A) OEM mode legacy: ?mode=oem&mye_id=&group_id=&part_type=... B) Local mode legacy (TecDoc-style): ?mode=local&mye_id=&group_id=&part_type=... C) Local mode Nexpart navigation (NEW): ?mode=local&mye_id=&nexpart_group=&nexpart_subgroup=&nexpart_part_type= """ from services.catalog_modes import normalize_mode mye_id = request.args.get('mye_id', type=int) group_id = request.args.get('group_id', type=int) part_type = request.args.get('part_type') # optional 3rd-level (legacy) # Nexpart navigation slugs (Local mode only) nexpart_group = request.args.get('nexpart_group') nexpart_subgroup = request.args.get('nexpart_subgroup') nexpart_part_type = request.args.get('nexpart_part_type') page = request.args.get('page', 1, type=int) per_page = request.args.get('per_page', 30, type=int) mode = normalize_mode(request.args.get('mode')) if not mye_id: return jsonify({'error': 'mye_id required'}), 400 use_nexpart_nav = mode == 'local' and nexpart_group and nexpart_subgroup and nexpart_part_type if not use_nexpart_nav and not group_id: return jsonify({'error': 'group_id (or nexpart_group + subgroup + part_type) required'}), 400 # Block OEM catalog if not enabled if mode != 'local' and not use_nexpart_nav: blocked = _oem_blocked() if blocked: return blocked def _do(master, tenant, branch_id): if use_nexpart_nav: result = catalog_service.get_parts_for_nexpart_triple( master, mye_id, nexpart_group, nexpart_subgroup, nexpart_part_type, tenant, branch_id, page, per_page, ) elif mode == 'local': result = catalog_service.get_parts_local( master, mye_id, group_id, tenant, branch_id, page, per_page, part_type=part_type, ) else: result = catalog_service.get_parts( master, mye_id, group_id, tenant, branch_id, page, per_page, part_type=part_type, ) return jsonify(result) return _with_conns(_do) @catalog_bp.route('/part/', methods=['GET']) @require_auth('catalog.view') def part_detail(part_id): # Part detail is available in both local and OEM modes # — it reads from the master parts DB and enriches with local stock. def _do(master, tenant, branch_id): result = catalog_service.get_part_detail(master, part_id, tenant, branch_id) if not result: return jsonify({'error': 'Part not found'}), 404 return jsonify(result) return _with_conns(_do) @catalog_bp.route('/search', methods=['GET']) @require_auth('catalog.view') def search(): # Search is available in both local and OEM modes # — it reads from the master parts DB and enriches with local stock. q = request.args.get('q', '').strip() if not q or len(q) < 2: return jsonify({'data': []}) limit = request.args.get('limit', 50, type=int) def _do(master, tenant, branch_id): data = catalog_service.smart_search(master, q, tenant, branch_id, limit) return jsonify({'data': data}) return _with_conns(_do) # ─── VIN Decoder ─── @catalog_bp.route('/vin/', methods=['GET']) @require_auth('catalog.view') def decode_vin_route(vin): """Decode a VIN and try to match to a brand/model/year in our catalog DB.""" vin = (vin or "").strip().upper() if len(vin) != 17: return jsonify({'error': 'VIN debe tener exactamente 17 caracteres.'}), 400 try: info = decode_vin(vin) except Exception as e: return jsonify({'error': f'Error al decodificar VIN: {str(e)}'}), 502 if info.get('error'): return jsonify(info), 200 # Return info even with partial errors # Try to match the decoded vehicle to our catalog DB db_match = None master = None try: master = get_master_conn() db_match = _match_vin_to_catalog(master, info) except Exception: pass finally: if master: try: master.close() except Exception: pass result = {**info} if db_match: result['catalog_match'] = db_match return jsonify(result) # ─── Plate Lookup ─── @catalog_bp.route('/plate/', methods=['GET']) @require_auth('catalog.view') def plate_lookup(plate): """Look up a vehicle by Mexican license plate in the local plate_vehicles table. If found, also tries to match the vehicle to the catalog DB. """ plate = (plate or '').strip() if not plate: return jsonify({'error': 'Placa requerida.'}), 400 if not is_valid_mexican_plate(plate): return jsonify({'error': 'Formato de placa no valido. Ej: ABC-1234 o AB-123-C'}), 400 tenant = None master = None try: tenant = get_tenant_conn(g.tenant_id) result = search_plate(tenant, plate) if not result: return jsonify({ 'found': False, 'plate': normalize_plate(plate), 'message': 'Placa no registrada.' }) # Try to match to catalog catalog_match = None try: master = get_master_conn() catalog_match = _match_plate_to_catalog(master, result) except Exception: pass finally: if master: try: master.close() except: pass master = None response = { 'found': True, 'plate': result['plate'], 'make': result['make'], 'model': result['model'], 'year': result['year'], 'vin': result['vin'], 'customer_id': result['customer_id'], } if catalog_match: response['catalog_match'] = catalog_match return jsonify(response) except Exception as e: return jsonify({'error': str(e)}), 500 finally: if tenant: try: tenant.close() except: pass if master: try: master.close() except: pass @catalog_bp.route('/plate', methods=['POST']) @require_auth('catalog.view') def plate_register(): """Register or update a plate-to-vehicle mapping.""" data = request.get_json() or {} plate = (data.get('plate') or '').strip() if not plate: return jsonify({'error': 'plate required'}), 400 if not is_valid_mexican_plate(plate): return jsonify({'error': 'Formato de placa no valido.'}), 400 tenant = None try: tenant = get_tenant_conn(g.tenant_id) rec_id = register_plate( tenant, plate, make=data.get('make'), model=data.get('model'), year=data.get('year'), vin=data.get('vin'), customer_id=data.get('customer_id'), ) return jsonify({'id': rec_id, 'message': 'Placa registrada.'}) except Exception as e: return jsonify({'error': str(e)}), 500 finally: if tenant: try: tenant.close() except: pass def _match_plate_to_catalog(master_conn, plate_info): """Try to match plate vehicle info to the catalog DB (same logic as VIN).""" return _match_vin_to_catalog(master_conn, { 'make': plate_info.get('make'), 'model': plate_info.get('model'), 'year': plate_info.get('year'), }) def _match_vin_to_catalog(master_conn, vin_info): """Try to find brand_id, model_id, year_id, mye_id from decoded VIN info.""" make = (vin_info.get('make') or '').upper().strip() model = (vin_info.get('model') or '').strip() year = vin_info.get('year') if not make: return None cur = master_conn.cursor() result = {} try: # Find brand (try exact, then LIKE) cur.execute( "SELECT id_brand, name_brand FROM brands WHERE UPPER(name_brand) = %s", (make,) ) brand_row = cur.fetchone() if not brand_row: cur.execute( "SELECT id_brand, name_brand FROM brands WHERE UPPER(name_brand) LIKE %s ORDER BY name_brand LIMIT 1", (f"%{make}%",) ) brand_row = cur.fetchone() if not brand_row: return None result['brand_id'] = brand_row[0] result['brand_name'] = brand_row[1] # Find model if model: cur.execute( """SELECT m.id_model, m.name_model FROM models m WHERE m.brand_id = %s AND UPPER(m.name_model) LIKE %s ORDER BY m.name_model LIMIT 5""", (brand_row[0], f"%{model.upper()}%") ) model_row = cur.fetchone() if model_row: result['model_id'] = model_row[0] result['model_name'] = model_row[1] # Find year if year: cur.execute( "SELECT id_year, year_car FROM years WHERE year_car = %s", (int(year),) ) year_row = cur.fetchone() if year_row: result['year_id'] = year_row[0] result['year_car'] = year_row[1] # Find MYE options cur.execute( """SELECT mye.id_mye, e.name_engine, mye.trim_level FROM model_year_engine mye JOIN engines e ON e.id_engine = mye.engine_id WHERE mye.model_id = %s AND mye.year_id = %s ORDER BY e.name_engine LIMIT 10""", (model_row[0], year_row[0]) ) mye_rows = cur.fetchall() if mye_rows: result['engines'] = [ {'id_mye': r[0], 'name_engine': r[1], 'trim_level': r[2]} for r in mye_rows ] # Auto-select if only one engine if len(mye_rows) == 1: result['id_mye'] = mye_rows[0][0] return result except Exception: return None finally: cur.close()