# /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 catalog_bp = Blueprint('catalog', __name__, url_prefix='/pos/api/catalog') 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(): year_id = request.args.get('year_id', type=int) def _do(master): data = catalog_service.get_brands(master, year_id=year_id) return jsonify({'data': data}) 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(): mye_id = request.args.get('mye_id', type=int) if not mye_id: return jsonify({'error': 'mye_id required'}), 400 def _do(master): data = catalog_service.get_categories(master, mye_id) return jsonify({'data': data}) return _master_only(_do) @catalog_bp.route('/groups', methods=['GET']) @require_auth('catalog.view') def groups(): mye_id = request.args.get('mye_id', type=int) category_id = request.args.get('category_id', type=int) if not mye_id or not category_id: return jsonify({'error': 'mye_id and category_id required'}), 400 def _do(master): data = catalog_service.get_groups(master, mye_id, category_id) return jsonify({'data': data}) return _master_only(_do) # ─── Parts with stock enrichment (master + tenant) ─── @catalog_bp.route('/parts', methods=['GET']) @require_auth('catalog.view') def parts(): mye_id = request.args.get('mye_id', type=int) group_id = request.args.get('group_id', type=int) page = request.args.get('page', 1, type=int) per_page = request.args.get('per_page', 30, type=int) if not mye_id or not group_id: return jsonify({'error': 'mye_id and group_id required'}), 400 def _do(master, tenant, branch_id): result = catalog_service.get_parts(master, mye_id, group_id, tenant, branch_id, page, per_page) return jsonify(result) return _with_conns(_do) @catalog_bp.route('/part/', methods=['GET']) @require_auth('catalog.view') def part_detail(part_id): 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(): 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()