from flask import Flask, jsonify, request, send_from_directory, redirect, g, abort from sqlalchemy import create_engine, text from sqlalchemy.orm import sessionmaker from sqlalchemy.exc import IntegrityError import os import sys import json as json_module import re import base64 import contextlib import uuid import time import urllib.request from datetime import datetime, timedelta _base = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(_base, '..', 'pos')) # pos/ for auth, services sys.path.insert(0, os.path.join(_base, '..')) # root config.py (has DB_URL) from config import DB_URL from auth import hash_password, check_password, create_access_token, create_refresh_token, decode_token, require_auth from tenant_db import get_tenant_conn from services.translations import translate_part_name, translate_category sys.path.insert(0, os.path.join(_base, '..', 'pos')) from json_provider import OrjsonProvider app = Flask(__name__, static_folder='.') app.json = OrjsonProvider(app) engine = create_engine(DB_URL, pool_pre_ping=True, pool_size=5, max_overflow=10) Session = sessionmaker(bind=engine) # ============================================================================ # Prometheus metrics (módulo compartido con el POS, sin dependencias extra) # ============================================================================ from services.prom_metrics import CONTENT_TYPE as PROM_CONTENT_TYPE, metrics as prom_metrics from services.prom_metrics import normalize_path as prom_normalize_path prom_metrics.configure('dashboard') @app.before_request def _metrics_request_start(): if request.path != '/metrics': g._metrics_t0 = time.perf_counter() prom_metrics.request_started() @app.after_request def _metrics_request_end(response): t0 = getattr(g, '_metrics_t0', None) if t0 is not None: g._metrics_done = True rule = getattr(request.url_rule, 'rule', None) prom_metrics.request_finished( request.method, prom_normalize_path(rule, request.path), response.status_code, time.perf_counter() - t0, ) return response @app.teardown_request def _metrics_request_teardown(exc): if exc is not None: prom_metrics.record_exception() if getattr(g, '_metrics_t0', None) is not None and not getattr(g, '_metrics_done', False): prom_metrics.request_aborted() @app.route('/metrics') def prometheus_metrics(): from flask import Response resp = Response(prom_metrics.render(), mimetype='text/plain') resp.headers['Content-Type'] = PROM_CONTENT_TYPE resp.headers['Cache-Control'] = 'no-store' return resp # ============================================================================ # Admin API guard — exige token ADMIN en TODA la superficie /api/admin/* # ============================================================================ @app.before_request def _guard_admin_api(): """Solo unas pocas rutas /api/admin/* tenían @require_auth; este guard cierra todas las demás (mutaciones de catálogo, import, export, upload).""" if not request.path.startswith('/api/admin/'): return None auth_header = request.headers.get('Authorization', '') if not auth_header.startswith('Bearer '): return jsonify({'error': 'Missing or invalid Authorization header'}), 401 payload = decode_token(auth_header[7:]) if payload is None: return jsonify({'error': 'Invalid or expired token'}), 401 if payload.get('type') != 'access': return jsonify({'error': 'Invalid token type'}), 401 if payload.get('role') != 'ADMIN': return jsonify({'error': 'Insufficient permissions'}), 403 g.user = {'user_id': payload.get('user_id'), 'role': payload.get('role')} return None # ============================================================================ # Catálogo público — guard OEM (tablas TecDoc retiradas, jun 2026) # ============================================================================ # Espejo del guard `_oem_blocked` de pos/blueprints/catalog_bp.py:47. El # dashboard no lo tenía y por eso los endpoints públicos devolvían 500 al # consultar `parts` / `part_groups` / `vehicle_parts`, que ya no existen. CATALOG_OEM_ENABLED = os.environ.get('CATALOG_OEM_ENABLED', 'false').lower() == 'true' _TECDOC_TABLES_CACHE = {'checked': False, 'exists': False} # ─── Meilisearch (mismos nombres de variable que pos/config.py) ──────────── MEILI_URL = os.environ.get('MEILI_URL', 'http://localhost:7700') MEILI_API_KEY = os.environ.get('MEILI_API_KEY', '') MEILI_ENABLED = os.environ.get('MEILI_ENABLED', 'true').lower() == 'true' MEILI_CATALOG_INDEX = 'nexus_supplier_catalog' _meili_client = None def _tecdoc_available(): """True si la tabla TecDoc `parts` existe en la maestra (cacheado).""" if _TECDOC_TABLES_CACHE['checked']: return _TECDOC_TABLES_CACHE['exists'] exists = False session = Session() try: exists = session.execute(text( "SELECT 1 FROM pg_tables WHERE schemaname='public' AND tablename='parts' LIMIT 1" )).first() is not None except Exception: exists = False finally: session.close() _TECDOC_TABLES_CACHE['checked'] = True _TECDOC_TABLES_CACHE['exists'] = exists return exists def _is_retired_schema_error(e): """True si el error proviene de tablas/columnas del esquema TecDoc retirado. Las consultas del dashboard pasan por SQLAlchemy, que envuelve el error de psycopg2 (ProgrammingError.orig) — hay que mirar ambos. """ orig = getattr(e, 'orig', e) try: from psycopg2.errors import UndefinedTable, UndefinedColumn if isinstance(e, (UndefinedTable, UndefinedColumn)): return True if isinstance(orig, (UndefinedTable, UndefinedColumn)): return True except Exception: pass return 'does not exist' in str(orig) or 'does not exist' in str(e) def _oem_blocked(): """Devuelve (json, 403) si el catálogo OEM no está disponible; None si sí. Misma lógica que el POS: cuando el esquema TecDoc no existe o el OEM está deshabilitado, se responde con un mensaje claro en vez de un 500. """ if not CATALOG_OEM_ENABLED or not _tecdoc_available(): return jsonify({ 'error': 'Catálogo OEM no disponible', 'message': 'El catálogo OEM está en construcción. Usa el modo Local.', 'oem_disabled': True, 'mode': 'local', }), 403 return None def _catalog_mode(raw): """Modo efectivo del catálogo público. Local por defecto. El modo OEM sólo se respeta si CATALOG_OEM_ENABLED está activo; si además el esquema TecDoc no existe, el endpoint OEM responde con `_oem_blocked`. Así `?mode=oem` (el valor por defecto del frontend) degrada a Local en vez de romper con 500. """ cleaned = str(raw or '').strip().lower() if cleaned == 'oem' and CATALOG_OEM_ENABLED: return 'oem' return 'local' def _get_meili_client(): """Cliente Meilisearch o None si está apagado / sin API key.""" global _meili_client if not MEILI_ENABLED or not MEILI_API_KEY: return None if _meili_client is None: try: import meilisearch _meili_client = meilisearch.Client(MEILI_URL, MEILI_API_KEY) except Exception: return None return _meili_client def _search_catalog_live(q, limit=50, offset=0): """Búsqueda del catálogo vivo (supplier_catalog) vía Meilisearch. Fallback transparente a SQL (ILIKE) si Meilisearch está apagado, sin API key o falla. Nunca lanza: devuelve [] ante cualquier error. """ q = (q or '').strip() if len(q) < 2: return [] limit = max(1, min(int(limit or 50), 100)) offset = max(0, int(offset or 0)) ids = None client = _get_meili_client() if client is not None: try: res = client.index(MEILI_CATALOG_INDEX).search( q, {'limit': limit, 'offset': offset}) ids = [h.get('id') for h in res.get('hits', []) if h.get('id') is not None] except Exception: ids = None session = Session() try: if ids is not None: if not ids: return [] rows = session.execute(text(""" SELECT id, sku, name, category, description, image_url, supplier_name FROM supplier_catalog WHERE id = ANY(:ids) AND is_active """), {'ids': ids}).mappings().all() by_id = {r['id']: r for r in rows} ordered = [by_id[i] for i in ids if i in by_id] else: ordered = session.execute(text(""" SELECT id, sku, name, category, description, image_url, supplier_name FROM supplier_catalog WHERE is_active AND (sku ILIKE :like OR name ILIKE :like OR category ILIKE :like) ORDER BY CASE WHEN sku ILIKE :prefix THEN 0 WHEN name ILIKE :prefix THEN 1 ELSE 2 END, name LIMIT :limit OFFSET :offset """), {'like': f'%{q}%', 'prefix': f'{q}%', 'limit': limit, 'offset': offset}).mappings().all() except Exception: try: session.rollback() except Exception: pass return [] finally: session.close() return [{ 'id_part': r['id'], 'oem_part_number': r['sku'], 'name': r['name'], 'description': r['description'], 'image_url': r['image_url'], 'manufacturer': r['supplier_name'], 'group_name': r['category'], 'category_name': r['category'], 'vehicle_info': r['category'] or '', 'source': 'supplier_catalog', } for r in ordered] def _catalog_part_detail_live(part_id): """Detalle de parte desde el catálogo vivo (sin TecDoc). Devuelve (json, code).""" session = Session() try: row = session.execute(text(""" SELECT id, sku, name, category, description, image_url, supplier_name FROM supplier_catalog WHERE id = :pid AND is_active """), {'pid': part_id}).mappings().first() if not row: return jsonify({ 'error': 'Part not found', 'message': 'La parte no existe en el catálogo.', 'oem_disabled': True, 'source': 'supplier_catalog', }), 404 alternatives = [] try: xrows = session.execute(text(""" SELECT DISTINCT brand, part_number FROM supplier_catalog_interchange WHERE catalog_id = :pid LIMIT 50 """), {'pid': part_id}).mappings().all() alternatives = [{ 'part_number': x['part_number'], 'manufacturer': x['brand'] or 'Intercambio', 'name': None, 'type': 'cross_reference', } for x in xrows] except Exception: try: session.rollback() except Exception: pass return jsonify({ 'part': { 'id_part': row['id'], 'oem_part_number': row['sku'], 'name': row['name'], 'description': row['description'], 'image_url': row['image_url'], 'group_name': row['supplier_name'], 'category_name': row['category'], 'source': 'supplier_catalog', }, 'alternatives': alternatives, 'bodegas': [], 'oem_disabled': True, }) finally: session.close() def _live_categories(): """Categorías equivalentes cuando el árbol TecDoc ya no existe. Se degrada a los 14 grupos Nexpart del catálogo vivo, con la misma forma que consumen los frontends ({id, name, name_es, slug, children}). """ try: from services.nexpart_taxonomy import get_groups return [{ 'id': g['name'], 'name': g['name'], 'name_es': g['name_es'], 'slug': g['name'], 'icon_name': None, 'display_order': i, 'parent_id': None, 'children': [], } for i, g in enumerate(get_groups())] except Exception: return [] def _live_manufacturers(limit=200): """Fabricantes del catálogo vivo (supplier_catalog.supplier_name).""" session = Session() try: rows = session.execute(text(""" SELECT DISTINCT supplier_name FROM supplier_catalog WHERE is_active AND supplier_name IS NOT NULL AND supplier_name <> '' ORDER BY supplier_name LIMIT :limit """), {'limit': limit}).mappings().all() except Exception: try: session.rollback() except Exception: pass return [] finally: session.close() return [{'id': r['supplier_name'], 'name': r['supplier_name'], 'type': None, 'quality_tier': None, 'country': None, 'logo_url': None, 'website': None} for r in rows] def _live_aftermarket(page, per_page, offset): """Intercambios/aftermarket del catálogo vivo (supplier_catalog_interchange).""" empty = {'data': [], 'pagination': {'page': page, 'per_page': per_page, 'total': 0, 'total_pages': 1}, 'oem_disabled': True, 'message': 'Aftermarket OEM no disponible (esquema TecDoc retirado).'} session = Session() try: total = session.execute(text(""" SELECT COUNT(*) FROM supplier_catalog_interchange sci JOIN supplier_catalog sc ON sc.id = sci.catalog_id WHERE sc.is_active """)).scalar() or 0 rows = session.execute(text(""" SELECT sci.id, sci.part_number, sci.brand, sc.sku, sc.name FROM supplier_catalog_interchange sci JOIN supplier_catalog sc ON sc.id = sci.catalog_id WHERE sc.is_active ORDER BY sci.brand, sci.part_number LIMIT :limit OFFSET :offset """), {'limit': per_page, 'offset': offset}).mappings().all() except Exception: try: session.rollback() except Exception: pass return jsonify(empty), 200 finally: session.close() data = [{'id': r['id'], 'part_number': r['part_number'], 'name': r['name'], 'oem_part_number': r['sku'], 'manufacturer_name': r['brand'] or 'Intercambio', 'quality_tier': None, 'price_usd': None} for r in rows] total_pages = max(1, (total + per_page - 1) // per_page) return jsonify({'data': data, 'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}, 'oem_disabled': True, 'source': 'supplier_catalog_interchange'}), 200 # ============================================================================ # Helper Functions # ============================================================================ def get_all_brands(detailed=False, region_mask=None): session = Session() try: region_filter = "" params = {} if region_mask is not None: region_filter = " AND (b.region & :rmask) > 0" params['rmask'] = region_mask if detailed: sql = text(""" SELECT b.name_brand AS name, COUNT(DISTINCT m.name_model) AS model_count, COUNT(DISTINCT mye.id_mye) AS vehicle_count FROM brands b JOIN models m ON m.brand_id = b.id_brand JOIN model_year_engine mye ON mye.model_id = m.id_model WHERE 1=1""" + region_filter + """ GROUP BY b.name_brand ORDER BY b.name_brand LIMIT 1000 """) rows = session.execute(sql, params).mappings().all() return [{'name': r['name'], 'model_count': r['model_count'], 'vehicle_count': r['vehicle_count']} for r in rows] else: sql = text(""" SELECT DISTINCT b.name_brand AS name FROM brands b JOIN models m ON m.brand_id = b.id_brand JOIN model_year_engine mye ON mye.model_id = m.id_model WHERE 1=1""" + region_filter + """ ORDER BY b.name_brand LIMIT 1000 """) rows = session.execute(sql, params).mappings().all() return [r['name'] for r in rows] finally: session.close() def get_all_years(): session = Session() try: rows = session.execute(text( "SELECT DISTINCT year_car AS year FROM years ORDER BY year_car DESC LIMIT 200" )).mappings().all() return [r['year'] for r in rows] finally: session.close() def get_all_engines(): session = Session() try: rows = session.execute(text( "SELECT DISTINCT name_engine AS name FROM engines ORDER BY name_engine LIMIT 5000" )).mappings().all() return [r['name'] for r in rows] finally: session.close() def get_models_by_brand(brand_name=None): session = Session() try: if brand_name: sql = text(""" SELECT DISTINCT m.name_model AS name FROM models m JOIN brands b ON m.brand_id = b.id_brand JOIN model_year_engine mye ON mye.model_id = m.id_model WHERE b.name_brand ILIKE :brand ORDER BY m.name_model LIMIT 1000 """) rows = session.execute(sql, {'brand': brand_name}).mappings().all() else: sql = text(""" SELECT DISTINCT m.name_model AS name FROM models m JOIN model_year_engine mye ON mye.model_id = m.id_model ORDER BY m.name_model LIMIT 1000 """) rows = session.execute(sql).mappings().all() return [r['name'] for r in rows] finally: session.close() def search_vehicles(brand=None, model=None, year=None, engine_name=None, with_parts=False, page=1, per_page=50): session = Session() try: per_page = min(per_page, 100) offset = (page - 1) * per_page base_from = """ FROM model_year_engine mye JOIN models m ON mye.model_id = m.id_model JOIN brands b ON m.brand_id = b.id_brand JOIN years y ON mye.year_id = y.id_year JOIN engines e ON mye.engine_id = e.id_engine LEFT JOIN fuel_type ft ON e.id_fuel = ft.id_fuel LEFT JOIN drivetrain dt ON mye.id_drivetrain = dt.id_drivetrain LEFT JOIN transmission tr ON mye.id_transmission = tr.id_transmission """ if with_parts: base_from += " JOIN (SELECT DISTINCT model_year_engine_id FROM vehicle_parts) AS has_parts ON mye.id_mye = has_parts.model_year_engine_id" where = " WHERE 1=1" params = {} if brand: where += " AND b.name_brand ILIKE :brand" params['brand'] = brand if model: where += " AND m.name_model ILIKE :model" params['model'] = model if year: where += " AND y.year_car = :year" params['year'] = int(year) if engine_name: where += " AND e.name_engine = :engine" params['engine'] = engine_name count_sql = text("SELECT COUNT(*) AS total " + base_from + where) total_count = session.execute(count_sql, params).mappings().first()['total'] data_params = dict(params) data_params['limit'] = per_page data_params['offset'] = offset query = text(""" SELECT b.name_brand AS brand, m.name_model AS model, y.year_car AS year, e.name_engine AS engine, e.power_hp, e.torque_nm, e.displacement_cc, e.cylinders, ft.name_fuel AS fuel_type, mye.trim_level, dt.name_drivetrain AS drivetrain, tr.name_transmission AS transmission """ + base_from + where + " ORDER BY b.name_brand, m.name_model, y.year_car LIMIT :limit OFFSET :offset") rows = session.execute(query, data_params).mappings().all() vehicles = [] for r in rows: vehicles.append({ 'brand': r['brand'], 'model': r['model'], 'year': r['year'], 'engine': r['engine'], 'power_hp': r['power_hp'] or 0, 'torque_nm': r['torque_nm'] or 0, 'displacement_cc': r['displacement_cc'] or 0, 'cylinders': r['cylinders'] or 0, 'fuel_type': r['fuel_type'] or 'unknown', 'trim_level': r['trim_level'] or 'unknown', 'drivetrain': r['drivetrain'] or 'unknown', 'transmission': r['transmission'] or 'unknown' }) total_pages = (total_count + per_page - 1) // per_page return {'data': vehicles, 'pagination': {'page': page, 'per_page': per_page, 'total': total_count, 'total_pages': total_pages}} finally: session.close() # ============================================================================ # Static Routes # ============================================================================ @app.route('/') def index(): return send_from_directory('.', 'landing.html') # ── POS Proxy — forward /pos/* to POS server on port 5001 ── @app.route('/pos/', defaults={'path': ''}) @app.route('/pos/', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']) def pos_proxy(path): import requests as req target = f'http://127.0.0.1:5001/pos/{path}' # Forward the request try: # Forward all headers except hop-by-hop ones excluded_req = {'host', 'content-length'} headers = {k: v for k, v in request.headers if k.lower() not in excluded_req} resp = req.request( method=request.method, url=target, headers=headers, params=request.args, data=request.get_data(), timeout=30, allow_redirects=False ) # Build response — use resp.headers (not resp.raw.headers) for correct Content-Type from flask import Response excluded_resp = {'content-encoding', 'transfer-encoding', 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailers', 'upgrade'} resp_headers = [(k, v) for k, v in resp.headers.items() if k.lower() not in excluded_resp] return Response(resp.content, status=resp.status_code, headers=resp_headers) except Exception as e: return jsonify({'error': f'POS server unavailable: {str(e)}'}), 502 @app.route('/catalog') def public_catalog(): return send_from_directory('.', 'catalog-public.html') @app.route('/catalog-public.js') def catalog_public_js(): return send_from_directory('.', 'catalog-public.js') @app.route('/landing.js') def landing_js(): return send_from_directory('.', 'landing.js') @app.route('/static/') def static_files(path): return send_from_directory('static', path) @app.route('/shared.css') def shared_css(): return send_from_directory('.', 'shared.css') @app.route('/nav.js') def nav_js(): return send_from_directory('.', 'nav.js') @app.route('/dashboard.js') def dashboard_js(): return send_from_directory('.', 'dashboard.js') @app.route('/admin.js') def admin_js(): return send_from_directory('.', 'admin.js') @app.route('/enhanced-search.js') def enhanced_search_js(): return send_from_directory('.', 'enhanced-search.js') @app.route('/chat-public.js') def chat_public_js(): return send_from_directory('.', 'chat-public.js') @app.route('/chat-public.css') def chat_public_css(): return send_from_directory('.', 'chat-public.css') # ============================================================================ # Public Chat Endpoint (rate limited, no auth) # ============================================================================ # Simple in-memory rate limiting for public chat _chat_rate = {} # ip -> [timestamps] def _check_chat_rate(ip, max_per_min=10): """Return True if request is allowed, False if rate limited.""" import time now = time.time() if ip not in _chat_rate: _chat_rate[ip] = [] # Clean old entries _chat_rate[ip] = [t for t in _chat_rate[ip] if now - t < 60] if len(_chat_rate[ip]) >= max_per_min: return False _chat_rate[ip].append(now) return True @app.route('/api/chat', methods=['POST']) def public_chat(): """Public chatbot endpoint — searches the public catalog (no auth required).""" client_ip = request.remote_addr or '0.0.0.0' if not _check_chat_rate(client_ip): return jsonify({'error': 'Demasiadas solicitudes. Intenta en un minuto.'}), 429 body = request.get_json(force=True) if request.is_json else {} user_message = (body.get('message') or '').strip() if not user_message: return jsonify({'error': 'message required'}), 400 chat_history = body.get('history') or [] # Import AI chat service from services.ai_chat import chat as ai_chat_fn ai_response = ai_chat_fn(user_message, chat_history) search_results = [] try: search_query = ai_response.get('search_query') if search_query: session = Session() try: query_terms = [q.strip() for q in search_query.split('|') if q.strip()] for qt in query_terms[:3]: # Search parts in public catalog rows = session.execute(text(""" SELECT p.id_part, p.oem_part_number, p.name_part, pg.name_part_group, pc.name_part_category FROM parts p LEFT JOIN part_groups pg ON p.group_id = pg.id_part_group LEFT JOIN part_categories pc ON pg.category_id = pc.id_part_category WHERE p.name_part ILIKE :q OR p.oem_part_number ILIKE :q OR pg.name_part_group ILIKE :q ORDER BY p.name_part LIMIT 5 """), {'q': f'%{qt}%'}).mappings().all() for r in rows: search_results.append({ 'id_part': r['id_part'], 'oem_part_number': r['oem_part_number'], 'name_part': r['name_part'], 'name_es': r['name_part_group'], 'category': r['name_part_category'], 'source': 'catalog' }) finally: session.close() except Exception: pass # search failure is non-fatal return jsonify({ 'response': ai_response.get('message', ''), 'search_results': search_results, 'vehicle': ai_response.get('vehicle') }) # ============================================================================ # Public Catalog API — No auth required # ============================================================================ REGION_BRANDS = { 'north-america': ( 'ACURA', 'AUDI', 'BMW', 'BUICK', 'CADILLAC', 'CHEVROLET', 'CHRYSLER', 'DODGE', 'FIAT', 'FORD', 'GMC', 'HONDA', 'HYUNDAI', 'INFINITI', 'JAGUAR', 'JEEP', 'KIA', 'LAND ROVER', 'LEXUS', 'LINCOLN', 'MAZDA', 'MERCEDES-BENZ', 'MINI', 'MITSUBISHI', 'NISSAN', 'PEUGEOT', 'PORSCHE', 'RAM', 'RENAULT', 'SEAT', 'SUBARU', 'SUZUKI', 'TESLA', 'TOYOTA', 'VOLVO', 'VW', ), 'europe': ( 'ALFA ROMEO', 'ASTON MARTIN', 'AUDI', 'BENTLEY', 'BMW', 'CITROEN', 'DACIA', 'DS', 'FERRARI', 'FIAT', 'JAGUAR', 'LAMBORGHINI', 'LAND ROVER', 'MASERATI', 'MERCEDES-BENZ', 'MINI', 'OPEL', 'PEUGEOT', 'PORSCHE', 'RENAULT', 'ROLLS-ROYCE', 'SAAB', 'SEAT', 'SKODA', 'SMART', 'VAUXHALL', 'VOLVO', 'VW', ), 'asia': ( 'ACURA', 'DAIHATSU', 'HONDA', 'HYUNDAI', 'INFINITI', 'ISUZU', 'KIA', 'LEXUS', 'MAZDA', 'MITSUBISHI', 'NISSAN', 'SSANGYONG', 'SUBARU', 'SUZUKI', 'TOYOTA', ), } NORTH_AMERICA_BRANDS = REGION_BRANDS['north-america'] @app.route('/api/catalog/brands') def api_catalog_brands(): from services.catalog_modes import get_brands_for_mode region = request.args.get('region', 'north-america') year_id = request.args.get('year_id', type=int) mode = _catalog_mode(request.args.get('mode')) session = Session() try: params = {} year_filter = "" if year_id: year_filter = " AND mye.year_id = :year_id" params['year_id'] = year_id # 'local' mode overrides the region filter — curated bodega list only. if mode == 'local': params['brands'] = list(get_brands_for_mode('local')) rows = session.execute(text(""" SELECT DISTINCT b.id_brand, b.name_brand FROM brands b JOIN models m ON m.brand_id = b.id_brand JOIN model_year_engine mye ON mye.model_id = m.id_model WHERE b.name_brand = ANY(:brands)""" + year_filter + """ ORDER BY b.name_brand """), params).mappings().all() elif region == 'all': rows = session.execute(text(""" SELECT DISTINCT b.id_brand, b.name_brand FROM brands b JOIN models m ON m.brand_id = b.id_brand JOIN model_year_engine mye ON mye.model_id = m.id_model WHERE 1=1""" + year_filter + """ ORDER BY b.name_brand """), params).mappings().all() else: brand_list = list(REGION_BRANDS.get(region, NORTH_AMERICA_BRANDS)) params['brands'] = brand_list rows = session.execute(text(""" SELECT DISTINCT b.id_brand, b.name_brand FROM brands b JOIN models m ON m.brand_id = b.id_brand JOIN model_year_engine mye ON mye.model_id = m.id_model WHERE b.name_brand = ANY(:brands)""" + year_filter + """ ORDER BY b.name_brand """), params).mappings().all() return jsonify([{'id_brand': r['id_brand'], 'name_brand': r['name_brand']} for r in rows]) finally: session.close() @app.route('/api/catalog/models') def api_catalog_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 session = Session() try: params = {'brand_id': brand_id} year_filter = "" if year_id: year_filter = " AND mye.year_id = :year_id" params['year_id'] = year_id rows = session.execute(text(""" SELECT DISTINCT m.id_model, m.name_model FROM models m JOIN model_year_engine mye ON mye.model_id = m.id_model WHERE m.brand_id = :brand_id""" + year_filter + """ ORDER BY m.name_model """), params).mappings().all() return jsonify([{'id_model': r['id_model'], 'name_model': r['name_model']} for r in rows]) finally: session.close() @app.route('/api/catalog/years') def api_catalog_years(): model_id = request.args.get('model_id', type=int) if not model_id: return jsonify({'error': 'model_id required'}), 400 session = Session() try: rows = session.execute(text(""" SELECT DISTINCT y.id_year, y.year_car FROM years y JOIN model_year_engine mye ON mye.year_id = y.id_year WHERE mye.model_id = :model_id ORDER BY y.year_car DESC """), {'model_id': model_id}).mappings().all() return jsonify([{'id_year': r['id_year'], 'year_car': r['year_car']} for r in rows]) finally: session.close() @app.route('/api/catalog/engines') def api_catalog_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 session = Session() try: rows = session.execute(text(""" 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 = :model_id AND mye.year_id = :year_id ORDER BY e.name_engine, mye.trim_level """), {'model_id': model_id, 'year_id': year_id}).mappings().all() return jsonify([{'id_mye': r['id_mye'], 'name_engine': r['name_engine'], 'trim_level': r['trim_level'] or ''} for r in rows]) finally: session.close() def _nexpart_master_conn(): """Return a raw psycopg2 connection for passing to services.catalog_service Nexpart functions (they expect .cursor() / .commit() / .close() DBAPI). Uses SQLAlchemy's connection pool so we don't open a new socket per call. """ return engine.raw_connection() @app.route('/api/catalog/categories') def api_catalog_categories(): """Categories for a vehicle. OEM mode: TecDoc part_categories (integer ids). Local mode: 14 Nexpart top-level groups filtered by what has parts for this vehicle (strings as slugs). """ mye_id = request.args.get('mye_id', type=int) mode = _catalog_mode(request.args.get('mode')) if not mye_id: return jsonify({'error': 'mye_id required'}), 400 if mode == 'local': from services.catalog_service import get_nexpart_groups_for_vehicle conn = _nexpart_master_conn() try: data = get_nexpart_groups_for_vehicle(conn, mye_id) finally: conn.close() return jsonify({'data': data, 'mode': 'local'}) # OEM mode (original behavior) — degrada a Local si el esquema no está. blocked = _oem_blocked() if blocked: return blocked session = Session() try: rows = session.execute(text(""" SELECT pc.id_part_category, COALESCE(pc.name_es, pc.name_part_category) AS name, sub.cnt AS part_count FROM ( SELECT pg.category_id, COUNT(*) AS cnt 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 WHERE vp.model_year_engine_id = :mye_id GROUP BY pg.category_id ) sub JOIN part_categories pc ON pc.id_part_category = sub.category_id ORDER BY name """), {'mye_id': mye_id}).mappings().all() return jsonify([{'id_part_category': r['id_part_category'], 'name': translate_category(r['name']), 'part_count': r['part_count']} for r in rows]) except Exception as e: try: session.rollback() except Exception: pass if _is_retired_schema_error(e): from services.catalog_service import get_nexpart_groups_for_vehicle conn = _nexpart_master_conn() try: data = get_nexpart_groups_for_vehicle(conn, mye_id) except Exception: data = [] finally: conn.close() return jsonify({'data': data, 'mode': 'local', 'oem_disabled': True, 'message': 'Catálogo OEM no disponible; se muestra el modo Local.'}), 200 return jsonify({'data': [], 'error': str(e)}), 200 finally: session.close() @app.route('/api/catalog/groups') def api_catalog_groups(): """Subgroups for a vehicle + parent category. OEM mode: TecDoc part_groups (integer category_id). Local mode: Nexpart subgroups within a Nexpart group (category_slug string). """ 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 = _catalog_mode(request.args.get('mode')) if not mye_id: return jsonify({'error': 'mye_id required'}), 400 if mode == 'local': if not category_slug: return jsonify({'error': 'category_slug required for local mode'}), 400 from services.catalog_service import get_nexpart_subgroups_for_vehicle conn = _nexpart_master_conn() try: data = get_nexpart_subgroups_for_vehicle(conn, mye_id, category_slug) finally: conn.close() return jsonify({'data': data, 'mode': 'local'}) # OEM mode — degrada con elegancia si el esquema TecDoc no está. blocked = _oem_blocked() if blocked: return blocked if not category_id: return jsonify({'error': 'category_id required for oem mode'}), 400 session = Session() try: rows = session.execute(text(""" SELECT pg.id_part_group, COALESCE(pg.name_es, pg.name_part_group) AS name, COUNT(*) AS part_count 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 WHERE vp.model_year_engine_id = :mye_id AND pg.category_id = :category_id GROUP BY pg.id_part_group, name ORDER BY name """), {'mye_id': mye_id, 'category_id': category_id}).mappings().all() return jsonify([{'id_part_group': r['id_part_group'], 'name': translate_category(r['name']), 'part_count': r['part_count']} for r in rows]) except Exception as e: try: session.rollback() except Exception: pass return jsonify({'data': [], 'oem_disabled': True, 'message': 'Sub-grupos OEM no disponibles; usa el modo Local.', 'error': str(e)}), 200 finally: session.close() @app.route('/api/public/contact-config') def api_public_contact_config(): """Public contact config for the catalog WhatsApp quote button.""" wa = (os.environ.get('PUBLIC_WA_NUMBER') or '').replace(' ', '').replace('+', '') return jsonify({ 'enabled': bool(wa), 'wa_number': wa, }) @app.route('/api/catalog/part-types') def api_catalog_part_types(): """Distinct part types within a vehicle+group (3rd subcategory level). OEM mode: distinct name_part values within a TecDoc part_group_id. Local mode: Nexpart Part Types within a Nexpart group + subgroup. """ from services.translations import translate_part_name 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') subgroup_slug = request.args.get('subgroup_slug') mode = _catalog_mode(request.args.get('mode')) if not mye_id: return jsonify({'error': 'mye_id required'}), 400 if mode == 'local': if not group_slug or not subgroup_slug: return jsonify({'error': 'group_slug and subgroup_slug required for local mode'}), 400 from services.catalog_service import get_nexpart_part_types_for_vehicle conn = _nexpart_master_conn() try: data = get_nexpart_part_types_for_vehicle(conn, mye_id, group_slug, subgroup_slug) finally: conn.close() return jsonify({'data': data, 'mode': 'local'}) # OEM mode — degrada con elegancia si el esquema TecDoc no está. blocked = _oem_blocked() if blocked: return blocked if not group_id: return jsonify({'error': 'group_id required for oem mode'}), 400 session = Session() try: rows = session.execute(text(""" SELECT p.name_part AS slug, COALESCE(p.name_es, p.name_part) AS display_name, COUNT(*) AS variants, (ARRAY_AGG(p.image_url) FILTER (WHERE p.image_url IS NOT NULL))[1] AS sample_image FROM vehicle_parts vp JOIN parts p ON p.id_part = vp.part_id WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id GROUP BY p.name_part, COALESCE(p.name_es, p.name_part) ORDER BY variants DESC, display_name ASC """), {'mye_id': mye_id, 'group_id': group_id}).mappings().all() return jsonify({'data': [{ 'slug': r['slug'], 'name': translate_part_name(r['display_name']), 'variant_count': r['variants'], 'sample_image': r['sample_image'], } for r in rows]}) except Exception as e: try: session.rollback() except Exception: pass return jsonify({'data': [], 'oem_disabled': True, 'message': 'Tipos de parte OEM no disponibles; usa el modo Local.', 'error': str(e)}), 200 finally: session.close() @app.route('/api/catalog/shop-supplies/groups') def api_shop_supplies_groups(): """Public Shop Supplies tab: vehicle-independent groups.""" from services.catalog_service import get_shop_supplies_groups return jsonify({'data': get_shop_supplies_groups()}) @app.route('/api/catalog/shop-supplies/subgroups') def api_shop_supplies_subgroups(): group_slug = request.args.get('group_slug') if not group_slug: return jsonify({'error': 'group_slug required'}), 400 from services.catalog_service import get_shop_supplies_subgroups conn = _nexpart_master_conn() try: return jsonify({'data': get_shop_supplies_subgroups(conn, group_slug)}) finally: conn.close() @app.route('/api/catalog/shop-supplies/part-types') def api_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 from services.catalog_service import get_shop_supplies_part_types conn = _nexpart_master_conn() try: return jsonify({'data': get_shop_supplies_part_types(conn, group_slug, subgroup_slug)}) finally: conn.close() @app.route('/api/catalog/shop-supplies/parts') def api_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 = max(1, request.args.get('page', 1, type=int)) per_page = min(request.args.get('per_page', 30, type=int), 100) 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 from services.catalog_service import get_shop_supplies_parts conn = _nexpart_master_conn() try: result = get_shop_supplies_parts( conn, group_slug, subgroup_slug, part_type_slug, tenant_conn=None, branch_id=None, page=page, per_page=per_page, ) return jsonify(result) finally: conn.close() @app.route('/api/catalog/parts') def api_catalog_parts(): from services.catalog_modes import ( LOCAL_PRIORITY_MANUFACTURERS_TIER1, LOCAL_PRIORITY_MANUFACTURERS_TIER2, ) 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') # Nexpart navigation slugs (Local mode, chosen via new Nexpart hierarchy) nexpart_group = request.args.get('nexpart_group') nexpart_subgroup = request.args.get('nexpart_subgroup') nexpart_part_type = request.args.get('nexpart_part_type') if not mye_id: return jsonify({'error': 'mye_id required'}), 400 page = max(1, request.args.get('page', 1, type=int)) per_page = min(request.args.get('per_page', 30, type=int), 100) offset = (page - 1) * per_page mode = _catalog_mode(request.args.get('mode')) # ─── Nexpart-nav dispatch (delegates to POS service layer) ─── # Public catalog has no tenant context — pass tenant_conn=None which # the service gracefully handles (no local stock / price enrichment). if mode == 'local' and nexpart_group and nexpart_subgroup and nexpart_part_type: from services.catalog_service import get_parts_for_nexpart_triple conn = _nexpart_master_conn() try: result = get_parts_for_nexpart_triple( conn, mye_id, nexpart_group, nexpart_subgroup, nexpart_part_type, tenant_conn=None, branch_id=None, page=page, per_page=per_page, ) return jsonify(result) finally: conn.close() # Below here: legacy group_id based flow (OEM or legacy Local) if not group_id: return jsonify({'error': 'group_id required'}), 400 session = Session() try: # Optional 3rd-level Part Type filter (applies to both OEM and Local modes) pt_clause = " AND p.name_part = :part_type" if part_type else "" if mode == 'local': # Aftermarket-oriented listing, prioritized + stock-aware. count_params = {'mye_id': mye_id, 'group_id': group_id} if part_type: count_params['part_type'] = part_type total = session.execute(text(""" SELECT COUNT(*) FROM vehicle_parts vp JOIN parts p ON p.id_part = vp.part_id JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id""" + pt_clause), count_params).scalar() fetch_params = { 'mye_id': mye_id, 'group_id': group_id, 'tier1': list(LOCAL_PRIORITY_MANUFACTURERS_TIER1), 'tier2': list(LOCAL_PRIORITY_MANUFACTURERS_TIER2), 'limit': per_page, 'offset': offset, } if part_type: fetch_params['part_type'] = part_type rows = session.execute(text(""" WITH aftermarket_for_vehicle AS ( SELECT DISTINCT ap.id_aftermarket_parts, ap.oem_part_id, ap.part_number, COALESCE(ap.name_es, ap.name_aftermarket_parts) AS am_name, m.name_manufacture, p.oem_part_number, COALESCE(p.name_es, p.name_part) AS oem_name, COALESCE(p.description_es, p.description) AS oem_desc, p.image_url AS oem_image FROM vehicle_parts vp JOIN parts p ON p.id_part = vp.part_id JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id""" + pt_clause + """ ), stock_per_oem AS ( SELECT part_id, COUNT(*) AS bodega_count, MIN(price) AS min_price, SUM(stock_quantity) AS total_stock FROM warehouse_inventory WHERE stock_quantity > 0 GROUP BY part_id ) SELECT afv.id_aftermarket_parts, afv.oem_part_id, afv.part_number, afv.am_name, afv.name_manufacture, afv.oem_part_number, afv.oem_name, afv.oem_desc, afv.oem_image, COALESCE(s.bodega_count, 0) AS bodega_count, s.min_price AS warehouse_price, COALESCE(s.total_stock, 0) AS warehouse_stock, CASE WHEN UPPER(afv.name_manufacture) = ANY(:tier1) THEN 1 WHEN UPPER(afv.name_manufacture) = ANY(:tier2) THEN 2 ELSE 3 END AS tier FROM aftermarket_for_vehicle afv LEFT JOIN stock_per_oem s ON s.part_id = afv.oem_part_id ORDER BY tier ASC, (COALESCE(s.bodega_count, 0) > 0) DESC, afv.name_manufacture ASC, afv.am_name ASC LIMIT :limit OFFSET :offset """), fetch_params).mappings().all() items = [{ 'id_part': r['oem_part_id'], 'id_aftermarket': r['id_aftermarket_parts'], 'oem_part_number': r['oem_part_number'], 'part_number': r['part_number'], 'name': translate_part_name(r['am_name'] or r['oem_name']), 'description': r['oem_desc'], 'image_url': r['oem_image'], 'manufacturer': r['name_manufacture'], 'priority_tier': r['tier'], 'bodega_count': r['bodega_count'], 'warehouse_stock': r['warehouse_stock'], 'warehouse_price': float(r['warehouse_price']) if r['warehouse_price'] is not None else None, 'in_stock_network': r['bodega_count'] > 0, } for r in rows] total_pages = max(1, (total + per_page - 1) // per_page) return jsonify({'data': items, 'mode': 'local', 'pagination': { 'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages }}) # OEM mode (original behavior) blocked = _oem_blocked() if blocked: return blocked oem_count_params = {'mye_id': mye_id, 'group_id': group_id} oem_fetch_params = {'mye_id': mye_id, 'group_id': group_id, 'limit': per_page, 'offset': offset} if part_type: oem_count_params['part_type'] = part_type oem_fetch_params['part_type'] = part_type total = session.execute(text(""" SELECT COUNT(*) FROM vehicle_parts vp JOIN parts p ON p.id_part = vp.part_id WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id""" + pt_clause), oem_count_params).scalar() rows = session.execute(text(""" SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es, p.description, p.description_es, p.image_url FROM vehicle_parts vp JOIN parts p ON p.id_part = vp.part_id WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id""" + pt_clause + """ ORDER BY p.name_part LIMIT :limit OFFSET :offset """), oem_fetch_params).mappings().all() items = [{ 'id_part': r['id_part'], 'oem_part_number': r['oem_part_number'], 'name': translate_part_name(r['name_es'] or r['name_part']), 'description': r['description_es'] or r['description'], 'image_url': r['image_url'], } for r in rows] total_pages = max(1, (total + per_page - 1) // per_page) return jsonify({'data': items, 'mode': 'oem', 'pagination': { 'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages }}) except Exception as e: try: session.rollback() except Exception: pass # Modo OEM/Local legacy sobre TecDoc retirado: degradar sin 500. return jsonify({'data': [], 'mode': 'local', 'oem_disabled': True, 'message': 'Listado no disponible (esquema TecDoc retirado). ' 'Prueba la navegación Local por categorías.', 'pagination': {'page': page, 'per_page': per_page, 'total': 0, 'total_pages': 1}, 'error': str(e)}), 200 finally: session.close() @app.route('/api/catalog/part/') def api_catalog_part_detail(part_id): # Catálogo OEM retirado/deshabilitado → detalle desde el catálogo vivo. if not (CATALOG_OEM_ENABLED and _tecdoc_available()): return _catalog_part_detail_live(part_id) session = Session() try: row = session.execute(text(""" SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es, p.description, p.description_es, p.image_url, COALESCE(pg.name_es, pg.name_part_group) AS group_name, COALESCE(pc.name_es, pc.name_part_category) AS category_name FROM parts p LEFT JOIN part_groups pg ON pg.id_part_group = p.group_id LEFT JOIN part_categories pc ON pc.id_part_category = pg.category_id WHERE p.id_part = :part_id """), {'part_id': part_id}).mappings().first() if not row: return jsonify({'error': 'Part not found'}), 404 part = { 'id_part': row['id_part'], 'oem_part_number': row['oem_part_number'], 'name': translate_part_name(row['name_es'] or row['name_part']), 'description': row['description_es'] or row['description'], 'image_url': row['image_url'], 'group_name': translate_category(row['group_name']) if row['group_name'] else row['group_name'], 'category_name': translate_category(row['category_name']) if row['category_name'] else row['category_name'], } # Cross-references xrefs = session.execute(text(""" SELECT pcr.cross_reference_number, pcr.source_ref FROM part_cross_references pcr WHERE pcr.part_id = :pid LIMIT 50 """), {'pid': part_id}).mappings().all() # Aftermarket alternatives afters = session.execute(text(""" SELECT ap.part_number, m.name_manufacture, COALESCE(ap.name_es, ap.name_aftermarket_parts) AS name FROM aftermarket_parts ap JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id WHERE ap.oem_part_id = :pid LIMIT 50 """), {'pid': part_id}).mappings().all() alternatives = [] for x in xrefs: alternatives.append({ 'part_number': x['cross_reference_number'], 'manufacturer': x['source_ref'] or 'OEM Cross-Ref', 'name': None, 'type': 'cross_reference', }) for a in afters: alternatives.append({ 'part_number': a['part_number'], 'manufacturer': a['name_manufacture'], 'name': a['name'], 'type': 'aftermarket', }) # Bodegas bodegas_rows = session.execute(text(""" SELECT u.business_name, wi.stock_quantity, wi.warehouse_location FROM warehouse_inventory wi JOIN users u ON u.id_user = wi.user_id WHERE wi.part_id = :pid AND wi.stock_quantity > 0 ORDER BY wi.stock_quantity DESC LIMIT 20 """), {'pid': part_id}).mappings().all() bodegas = [{'business_name': b['business_name'], 'stock': b['stock_quantity'], 'location': b['warehouse_location']} for b in bodegas_rows] return jsonify({'part': part, 'alternatives': alternatives, 'bodegas': bodegas}) except Exception as e: try: session.rollback() except Exception: pass if _is_retired_schema_error(e): # Esquema TecDoc retirado a medio camino → catálogo vivo. return _catalog_part_detail_live(part_id) return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/catalog/search') def api_catalog_search(): """Búsqueda del catálogo público. OEM (TecDoc) ya no existe: se busca en el catálogo vivo (supplier_catalog) vía Meilisearch, con fallback SQL. Nunca 500. """ q = request.args.get('q', '').strip() if not q or len(q) < 2: return jsonify([]) limit = min(request.args.get('limit', 50, type=int), 100) return jsonify(_search_catalog_live(q, limit=limit)) # ============================================================================ # Core API Endpoints # ============================================================================ @app.route('/api/catalog/stats') def api_catalog_stats(): """Public stats endpoint consumed by the landing page hero cards. Returns live counts from the master catalog so the landing never shows stale numbers. Counts are fast (pg_class reltuples for the big tables) and cached in-process for 10 minutes. """ # In-process cache — counts barely change and are called on every # landing pageview; no reason to hit the DB every time. import time as _t now = _t.time() cache = getattr(api_catalog_stats, '_cache', None) if cache and cache[0] > now: return jsonify(cache[1]) session = Session() try: # Use reltuples for large tables (parts, aftermarket_parts, # part_cross_references) — exact COUNT(*) on 1M+ row tables is slow # and the landing doesn't need exact accuracy, just "big numbers". # Tablas vivas: supplier_catalog + compat + interchange sustituyen # a las antiguas parts/aftermarket_parts/part_cross_references. row = session.execute(text(""" SELECT (SELECT COUNT(*) FROM brands) AS brands, (SELECT COUNT(*) FROM models) AS models, (SELECT COUNT(*) FROM model_year_engine) AS vehicles, (SELECT COUNT(*) FROM supplier_catalog) AS parts, (SELECT COUNT(*) FROM supplier_catalog_compat) AS aftermarket_parts, (SELECT COUNT(*) FROM supplier_catalog_interchange) AS cross_references, (SELECT COUNT(*) FROM supplier_catalog WHERE supplier_name IS NOT NULL AND supplier_name <> '') AS manufacturers """)).mappings().first() data = { 'brands': row['brands'] or 0, 'models': row['models'] or 0, 'vehicles': row['vehicles'] or 0, 'parts': row['parts'] or 0, 'aftermarket_parts': row['aftermarket_parts'] or 0, 'cross_references': row['cross_references'] or 0, 'manufacturers': row['manufacturers'] or 0, } api_catalog_stats._cache = (now + 600, data) return jsonify(data) except Exception: return jsonify({ 'brands': 0, 'models': 0, 'vehicles': 0, 'parts': 0, 'aftermarket_parts': 0, 'cross_references': 0, 'manufacturers': 0, }) finally: session.close() @app.route('/api/brands') def api_brands(): detailed = request.args.get('detailed', 'false').lower() == 'true' region = request.args.get('region') region_mask = int(region) if region else None return jsonify(get_all_brands(detailed=detailed, region_mask=region_mask)) @app.route('/api/years') def api_years(): brand = request.args.get('brand') model = request.args.get('model') session = Session() try: q = """SELECT DISTINCT y.year_car AS year FROM years y JOIN model_year_engine mye ON y.id_year = mye.year_id JOIN models m ON mye.model_id = m.id_model JOIN brands b ON m.brand_id = b.id_brand WHERE 1=1""" params = {} if brand: q += " AND b.name_brand ILIKE :brand" params['brand'] = brand if model: q += " AND m.name_model ILIKE :model" params['model'] = model q += " ORDER BY y.year_car DESC" rows = session.execute(text(q), params).mappings().all() return jsonify([r['year'] for r in rows]) finally: session.close() @app.route('/api/engines') def api_engines(): brand = request.args.get('brand') model = request.args.get('model') year = request.args.get('year') session = Session() try: q = """SELECT DISTINCT e.name_engine AS name FROM engines e JOIN model_year_engine mye ON e.id_engine = mye.engine_id JOIN models m ON mye.model_id = m.id_model JOIN brands b ON m.brand_id = b.id_brand JOIN years y ON mye.year_id = y.id_year WHERE 1=1""" params = {} if brand: q += " AND b.name_brand ILIKE :brand" params['brand'] = brand if model: q += " AND m.name_model ILIKE :model" params['model'] = model if year: q += " AND y.year_car = :year" params['year'] = int(year) q += " ORDER BY e.name_engine" rows = session.execute(text(q), params).mappings().all() return jsonify([r['name'] for r in rows]) finally: session.close() @app.route('/api/models') def api_models(): brand = request.args.get('brand') detailed = request.args.get('detailed', 'false').lower() == 'true' if detailed and brand: session = Session() try: sql = text(""" SELECT m.name_model AS name, MIN(y.year_car) AS year_min, MAX(y.year_car) AS year_max, COUNT(DISTINCT y.year_car) AS year_count, COUNT(DISTINCT mye.id_mye) AS vehicle_count, COUNT(DISTINCT e.name_engine) AS engine_count FROM models m JOIN brands b ON m.brand_id = b.id_brand JOIN model_year_engine mye ON mye.model_id = m.id_model JOIN years y ON mye.year_id = y.id_year JOIN engines e ON mye.engine_id = e.id_engine WHERE b.name_brand ILIKE :brand GROUP BY m.name_model ORDER BY m.name_model LIMIT 1000 """) rows = session.execute(sql, {'brand': brand}).mappings().all() return jsonify([{'name': r['name'], 'year_min': r['year_min'], 'year_max': r['year_max'], 'year_count': r['year_count'], 'vehicle_count': r['vehicle_count'], 'engine_count': r['engine_count']} for r in rows]) finally: session.close() return jsonify(get_models_by_brand(brand)) @app.route('/api/vehicles') def api_vehicles(): brand = request.args.get('brand') model = request.args.get('model') year = request.args.get('year') eng = request.args.get('engine') page = request.args.get('page', 1, type=int) per_page = request.args.get('per_page', 50, type=int) return jsonify(search_vehicles(brand, model, year, eng, page=page, per_page=per_page)) @app.route('/api/model-year-engine') def api_model_year_engine(): try: brand = request.args.get('brand') model = request.args.get('model') year = request.args.get('year', type=int) with_parts = request.args.get('with_parts', 'false').lower() == 'true' page = request.args.get('page', 1, type=int) per_page = min(request.args.get('per_page', 50, type=int), 100) offset = (page - 1) * per_page session = Session() try: base_from = """ FROM model_year_engine mye JOIN models m ON mye.model_id = m.id_model JOIN brands b ON m.brand_id = b.id_brand JOIN years y ON mye.year_id = y.id_year JOIN engines e ON mye.engine_id = e.id_engine LEFT JOIN drivetrain dt ON mye.id_drivetrain = dt.id_drivetrain LEFT JOIN transmission tr ON mye.id_transmission = tr.id_transmission """ if with_parts: base_from += " JOIN (SELECT DISTINCT model_year_engine_id FROM vehicle_parts) AS has_parts ON mye.id_mye = has_parts.model_year_engine_id" where = " WHERE 1=1" params = {} if brand: where += " AND b.name_brand ILIKE :brand" params['brand'] = brand if model: where += " AND m.name_model ILIKE :model" params['model'] = model if year: where += " AND y.year_car = :year" params['year'] = year total_count = session.execute(text("SELECT COUNT(*) AS total " + base_from + where), params).mappings().first()['total'] data_params = dict(params) data_params['limit'] = per_page data_params['offset'] = offset query = text(""" SELECT mye.id_mye AS id, b.name_brand AS brand, m.name_model AS model, y.year_car AS year, e.name_engine AS engine, mye.trim_level, dt.name_drivetrain AS drivetrain, tr.name_transmission AS transmission """ + base_from + where + " ORDER BY b.name_brand, m.name_model, y.year_car, e.name_engine LIMIT :limit OFFSET :offset") rows = session.execute(query, data_params).mappings().all() records = [{'id': r['id'], 'brand': r['brand'], 'model': r['model'], 'year': r['year'], 'engine': r['engine'], 'trim_level': r['trim_level'], 'drivetrain': r['drivetrain'], 'transmission': r['transmission']} for r in rows] total_pages = (total_count + per_page - 1) // per_page return jsonify({'data': records, 'pagination': {'page': page, 'per_page': per_page, 'total': total_count, 'total_pages': total_pages}}) finally: session.close() except Exception as e: return jsonify({'error': str(e)}), 500 # ============================================================================ # Parts Catalog Endpoints # ============================================================================ @app.route('/api/categories') def api_categories(): """Categorías del catálogo. El árbol TecDoc (part_categories) ya no existe: se degrada a los 14 grupos Nexpart del catálogo vivo.""" session = Session() try: rows = session.execute(text(""" SELECT id_part_category AS id, name_part_category AS name, name_es, slug, icon_name, display_order, parent_id FROM part_categories ORDER BY display_order, name_part_category LIMIT 50 """)).mappings().all() categories_dict = {} root_categories = [] for r in rows: cat = {'id': r['id'], 'name': r['name'], 'name_es': r['name_es'], 'slug': r['slug'], 'icon_name': r['icon_name'], 'display_order': r['display_order'], 'children': []} categories_dict[r['id']] = cat if r['parent_id'] is None: root_categories.append(cat) for r in rows: if r['parent_id'] is not None and r['parent_id'] in categories_dict: categories_dict[r['parent_id']]['children'].append(categories_dict[r['id']]) return jsonify(root_categories) except Exception as e: try: session.rollback() except Exception: pass if _is_retired_schema_error(e): return jsonify(_live_categories()) return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/categories//groups') def api_category_groups(category_id): session = Session() try: rows = session.execute(text(""" SELECT id_part_group AS id, name_part_group AS name, name_es, slug, display_order FROM part_groups WHERE category_id = :cid ORDER BY display_order, name_part_group LIMIT 200 """), {'cid': category_id}).mappings().all() return jsonify([{'id': r['id'], 'name': r['name'], 'name_es': r['name_es'], 'slug': r['slug'], 'display_order': r['display_order']} for r in rows]) except Exception as e: try: session.rollback() except Exception: pass if _is_retired_schema_error(e): return jsonify([]) return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/parts') def api_parts(): try: group_id = request.args.get('group_id', type=int) category_id = request.args.get('category_id', type=int) search = request.args.get('search') page = request.args.get('page', 1, type=int) per_page = min(request.args.get('per_page', 50, type=int), 100) offset = (page - 1) * per_page session = Session() try: where = " WHERE 1=1" params = {} if group_id: where += " AND p.group_id = :group_id" params['group_id'] = group_id if category_id: where += " AND pg.category_id = :category_id" params['category_id'] = category_id if search: where += " AND (p.name_part ILIKE :search OR p.name_es ILIKE :search OR p.oem_part_number ILIKE :search)" params['search'] = '%' + search + '%' base = """ FROM parts p JOIN part_groups pg ON p.group_id = pg.id_part_group JOIN part_categories pc ON pg.category_id = pc.id_part_category """ total_count = session.execute(text("SELECT COUNT(*) AS total " + base + where), params).mappings().first()['total'] data_params = dict(params) data_params['limit'] = per_page data_params['offset'] = offset rows = session.execute(text(""" SELECT p.id_part AS id, p.oem_part_number, p.name_part AS name, p.name_es, p.group_id, pg.name_part_group AS group_name, pc.name_part_category AS category_name, p.image_url """ + base + where + " ORDER BY p.name_part LIMIT :limit OFFSET :offset"), data_params).mappings().all() parts = [{'id': r['id'], 'oem_part_number': r['oem_part_number'], 'name': r['name'], 'name_es': r['name_es'], 'group_id': r['group_id'], 'group_name': r['group_name'], 'category_name': r['category_name'], 'image_url': r['image_url']} for r in rows] total_pages = (total_count + per_page - 1) // per_page return jsonify({'data': parts, 'pagination': {'page': page, 'per_page': per_page, 'total': total_count, 'total_pages': total_pages}}) finally: session.close() except Exception as e: if _is_retired_schema_error(e): return jsonify({'data': [], 'pagination': {'page': page, 'per_page': per_page, 'total': 0, 'total_pages': 1}, 'oem_disabled': True, 'message': 'Listado OEM no disponible (esquema TecDoc retirado).'}), 200 return jsonify({'error': str(e)}), 500 @app.route('/api/parts/') def api_part_detail(part_id): session = Session() try: row = session.execute(text(""" SELECT p.id_part AS id, p.oem_part_number, p.name_part AS name, p.name_es, p.description, p.description_es, p.group_id, p.image_url, pg.name_part_group AS group_name, pg.name_es AS group_name_es, pc.id_part_category AS category_id, pc.name_part_category AS category_name, pc.name_es AS category_name_es FROM parts p JOIN part_groups pg ON p.group_id = pg.id_part_group JOIN part_categories pc ON pg.category_id = pc.id_part_category WHERE p.id_part = :pid """), {'pid': part_id}).mappings().first() if row is None: return jsonify({'error': 'Part not found'}), 404 return jsonify({'id': row['id'], 'oem_part_number': row['oem_part_number'], 'name': row['name'], 'name_es': row['name_es'], 'description': row['description'], 'description_es': row['description_es'], 'group_id': row['group_id'], 'group_name': row['group_name'], 'image_url': row['image_url'], 'group_name_es': row['group_name_es'], 'category_id': row['category_id'], 'category_name': row['category_name'], 'category_name_es': row['category_name_es']}) except Exception as e: if _is_retired_schema_error(e): return jsonify({'error': 'Part not found', 'message': 'El detalle OEM no está disponible (esquema TecDoc retirado).', 'oem_disabled': True}), 404 return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/vehicles//categories') def api_vehicle_categories(mye_id): session = Session() try: rows = session.execute(text(""" SELECT DISTINCT pc.id_part_category AS id, pc.name_part_category AS name, pc.name_es, pc.slug, pc.icon_name, pc.display_order FROM part_categories pc JOIN part_groups pg ON pg.category_id = pc.id_part_category JOIN parts p ON p.group_id = pg.id_part_group JOIN vehicle_parts vp ON vp.part_id = p.id_part WHERE vp.model_year_engine_id = :mye_id ORDER BY pc.display_order, pc.name_part_category LIMIT 50 """), {'mye_id': mye_id}).mappings().all() return jsonify([{'id': r['id'], 'name': r['name'], 'name_es': r['name_es'], 'slug': r['slug'], 'icon_name': r['icon_name'], 'display_order': r['display_order']} for r in rows]) except Exception as e: if _is_retired_schema_error(e): return jsonify([]) return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/vehicles//groups') def api_vehicle_groups(mye_id): session = Session() try: category_id = request.args.get('category_id', type=int) q = """ SELECT DISTINCT pg.id_part_group AS id, pg.name_part_group AS name, pg.name_es, pg.slug, pg.display_order, COUNT(DISTINCT p.id_part) AS parts_count FROM part_groups pg JOIN parts p ON p.group_id = pg.id_part_group JOIN vehicle_parts vp ON vp.part_id = p.id_part WHERE vp.model_year_engine_id = :mye_id """ params = {'mye_id': mye_id} if category_id: q += " AND pg.category_id = :cid" params['cid'] = category_id q += " GROUP BY pg.id_part_group, pg.name_part_group, pg.name_es, pg.slug, pg.display_order ORDER BY pg.display_order, pg.name_part_group LIMIT 200" rows = session.execute(text(q), params).mappings().all() return jsonify([{'id': r['id'], 'name': r['name'], 'name_es': r['name_es'], 'slug': r['slug'], 'display_order': r['display_order'], 'parts_count': r['parts_count']} for r in rows]) except Exception as e: if _is_retired_schema_error(e): return jsonify([]) return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/vehicles//parts') def api_vehicle_parts(mye_id): try: category_id = request.args.get('category_id', type=int) group_id = request.args.get('group_id', type=int) page = request.args.get('page', 1, type=int) per_page = min(request.args.get('per_page', 50, type=int), 100) offset = (page - 1) * per_page session = Session() try: base = """ FROM vehicle_parts vp JOIN parts p ON vp.part_id = p.id_part JOIN part_groups pg ON p.group_id = pg.id_part_group JOIN part_categories pc ON pg.category_id = pc.id_part_category LEFT JOIN position_part pp ON vp.id_position_part = pp.id_position_part WHERE vp.model_year_engine_id = :mye_id """ params = {'mye_id': mye_id} if category_id: base += " AND pc.id_part_category = :cid" params['cid'] = category_id if group_id: base += " AND pg.id_part_group = :gid" params['gid'] = group_id total_count = session.execute(text("SELECT COUNT(*) AS total " + base), params).mappings().first()['total'] data_params = dict(params) data_params['limit'] = per_page data_params['offset'] = offset rows = session.execute(text(""" SELECT p.id_part AS id, p.oem_part_number, p.name_part AS name, p.name_es, vp.quantity_required, pp.name_position_part AS position, pc.name_part_category AS category_name, pg.name_part_group AS group_name, p.image_url """ + base + " ORDER BY pc.display_order, pg.display_order, p.name_part LIMIT :limit OFFSET :offset"), data_params).mappings().all() parts = [{'id': r['id'], 'oem_part_number': r['oem_part_number'], 'name': r['name'], 'name_es': r['name_es'], 'quantity_required': r['quantity_required'], 'position': r['position'], 'category_name': r['category_name'], 'group_name': r['group_name'], 'image_url': r['image_url']} for r in rows] total_pages = (total_count + per_page - 1) // per_page return jsonify({'data': parts, 'pagination': {'page': page, 'per_page': per_page, 'total': total_count, 'total_pages': total_pages}}) finally: session.close() except Exception as e: if _is_retired_schema_error(e): return jsonify({'data': [], 'oem_disabled': True, 'pagination': {'page': page, 'per_page': per_page, 'total': 0, 'total_pages': 1}, 'message': 'Listado OEM no disponible (esquema TecDoc retirado).'}), 200 return jsonify({'error': str(e)}), 500 # ============================================================================ # Cross-References and Aftermarket Endpoints # ============================================================================ @app.route('/api/manufacturers') def api_manufacturers(): session = Session() try: manufacturer_type = request.args.get('type') quality_tier = request.args.get('quality_tier') q = """ SELECT mfr.id_manufacture AS id, mfr.name_manufacture AS name, mt.name_type_manu AS type, qt.name_quality AS quality_tier, co.name_country AS country, mfr.logo_url, mfr.website FROM manufacturers mfr LEFT JOIN manufacture_type mt ON mfr.id_type_manu = mt.id_type_manu LEFT JOIN quality_tier qt ON mfr.id_quality_tier = qt.id_quality_tier LEFT JOIN countries co ON mfr.id_country = co.id_country WHERE 1=1 """ params = {} if manufacturer_type: q += " AND mt.name_type_manu ILIKE :type" params['type'] = manufacturer_type if quality_tier: q += " AND qt.name_quality ILIKE :qt" params['qt'] = quality_tier q += " ORDER BY mfr.name_manufacture LIMIT 200" rows = session.execute(text(q), params).mappings().all() return jsonify([{'id': r['id'], 'name': r['name'], 'type': r['type'], 'quality_tier': r['quality_tier'], 'country': r['country'], 'logo_url': r['logo_url'], 'website': r['website']} for r in rows]) except Exception as e: if _is_retired_schema_error(e): return jsonify(_live_manufacturers()) return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/parts//alternatives') def api_part_alternatives(part_id): # Alternativas reconstruidas sobre el catálogo vivo (supplier_catalog + # supplier_catalog_interchange + similitud semántica). Las tablas TecDoc # (aftermarket_parts/parts) están retiradas. try: from tenant_db import get_master_conn from services import catalog_service mconn = get_master_conn() try: results = catalog_service.get_alternatives(mconn, catalog_id=part_id, limit=50) finally: mconn.close() except Exception: return jsonify([]) return jsonify([{ 'id': r.get('catalog_id') or r.get('id'), 'part_number': r.get('part_number'), 'name': r.get('name'), 'name_es': r.get('name'), 'manufacturer_name': r.get('supplier_name'), 'manufacturer_id': None, 'quality_tier': r.get('type'), 'price_usd': None, 'warranty_months': None, 'in_stock': r.get('local_stock'), 'similarity': r.get('similarity'), } for r in results]) @app.route('/api/parts//cross-references') def api_part_cross_references(part_id): session = Session() try: rows = session.execute(text(""" SELECT pcr.id_part_cross_ref AS id, pcr.cross_reference_number, rt.name_ref_type AS reference_type, pcr.source_ref AS source, pcr.notes FROM part_cross_references pcr LEFT JOIN reference_type rt ON pcr.id_ref_type = rt.id_ref_type WHERE pcr.part_id = :pid ORDER BY rt.name_ref_type, pcr.cross_reference_number LIMIT 100 """), {'pid': part_id}).mappings().all() return jsonify([{'id': r['id'], 'cross_reference_number': r['cross_reference_number'], 'reference_type': r['reference_type'], 'source': r['source'], 'notes': r['notes']} for r in rows]) except Exception as e: if _is_retired_schema_error(e): return jsonify([]) return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/search/part-number/') def api_search_part_number(part_number): session = Session() try: results = [] st = '%' + part_number + '%' for row in session.execute(text("SELECT id_part AS id, oem_part_number, name_part AS name, name_es FROM parts WHERE oem_part_number ILIKE :s"), {'s': st}).mappings().all(): results.append({'id': row['id'], 'oem_part_number': row['oem_part_number'], 'name': row['name'], 'name_es': row['name_es'], 'match_type': 'oem', 'matched_number': row['oem_part_number']}) for row in session.execute(text("SELECT p.id_part AS id, p.oem_part_number, p.name_part AS name, p.name_es, ap.part_number FROM aftermarket_parts ap JOIN parts p ON ap.oem_part_id = p.id_part WHERE ap.part_number ILIKE :s"), {'s': st}).mappings().all(): results.append({'id': row['id'], 'oem_part_number': row['oem_part_number'], 'name': row['name'], 'name_es': row['name_es'], 'match_type': 'aftermarket', 'matched_number': row['part_number']}) for row in session.execute(text("SELECT p.id_part AS id, p.oem_part_number, p.name_part AS name, p.name_es, pcr.cross_reference_number FROM part_cross_references pcr JOIN parts p ON pcr.part_id = p.id_part WHERE pcr.cross_reference_number ILIKE :s"), {'s': st}).mappings().all(): results.append({'id': row['id'], 'oem_part_number': row['oem_part_number'], 'name': row['name'], 'name_es': row['name_es'], 'match_type': 'cross_reference', 'matched_number': row['cross_reference_number']}) return jsonify(results) except Exception as e: if _is_retired_schema_error(e): # Catálogo vivo: se busca el número de parte entre los SKUs. return jsonify([{ 'id': p['id_part'], 'oem_part_number': p['oem_part_number'], 'name': p['name'], 'name_es': p['name'], 'match_type': 'supplier_catalog', 'matched_number': p['oem_part_number'], } for p in _search_catalog_live(part_number, limit=50)]) return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/aftermarket') def api_aftermarket_parts(): session = Session() try: manufacturer_id = request.args.get('manufacturer_id', type=int) quality_tier = request.args.get('quality_tier') search = request.args.get('search') page = request.args.get('page', 1, type=int) per_page = min(request.args.get('per_page', 50, type=int), 100) offset = (page - 1) * per_page where = " WHERE 1=1" params = {} if manufacturer_id: where += " AND ap.manufacturer_id = :mid" params['mid'] = manufacturer_id if quality_tier: where += " AND qt.name_quality ILIKE :qt" params['qt'] = quality_tier if search: where += " AND (ap.name_aftermarket_parts ILIKE :s OR ap.part_number ILIKE :s OR p.oem_part_number ILIKE :s)" params['s'] = '%' + search + '%' base = """ FROM aftermarket_parts ap JOIN parts p ON ap.oem_part_id = p.id_part JOIN manufacturers m ON ap.manufacturer_id = m.id_manufacture LEFT JOIN quality_tier qt ON ap.id_quality_tier = qt.id_quality_tier """ total_count = session.execute(text("SELECT COUNT(*) AS total " + base + where), params).mappings().first()['total'] data_params = dict(params) data_params['limit'] = per_page data_params['offset'] = offset rows = session.execute(text(""" SELECT ap.id_aftermarket_parts AS id, ap.part_number, ap.name_aftermarket_parts AS name, p.oem_part_number, m.name_manufacture AS manufacturer_name, qt.name_quality AS quality_tier, ap.price_usd """ + base + where + " ORDER BY ap.name_aftermarket_parts LIMIT :limit OFFSET :offset"), data_params).mappings().all() parts = [{'id': r['id'], 'part_number': r['part_number'], 'name': r['name'], 'oem_part_number': r['oem_part_number'], 'manufacturer_name': r['manufacturer_name'], 'quality_tier': r['quality_tier'], 'price_usd': r['price_usd']} for r in rows] total_pages = (total_count + per_page - 1) // per_page return jsonify({'data': parts, 'pagination': {'page': page, 'per_page': per_page, 'total': total_count, 'total_pages': total_pages}}) except Exception as e: if _is_retired_schema_error(e): return _live_aftermarket(page, per_page, offset) return jsonify({'error': str(e)}), 500 finally: session.close() # ============================================================================ # Diagram Endpoints # ============================================================================ @app.route('/api/diagrams') def api_diagrams(): session = Session() try: group_id = request.args.get('group_id', type=int) q = """ SELECT d.id_diagram AS id, d.name_diagram AS name, d.name_es, d.group_id, pg.name_part_group AS group_name, d.thumbnail_path, d.display_order FROM diagrams d JOIN part_groups pg ON d.group_id = pg.id_part_group WHERE 1=1 """ params = {} if group_id: q += " AND d.group_id = :gid" params['gid'] = group_id q += " ORDER BY d.display_order, d.name_diagram LIMIT 200" rows = session.execute(text(q), params).mappings().all() return jsonify([{'id': r['id'], 'name': r['name'], 'name_es': r['name_es'], 'group_id': r['group_id'], 'group_name': r['group_name'], 'thumbnail_path': r['thumbnail_path'], 'display_order': r['display_order']} for r in rows]) except Exception as e: if _is_retired_schema_error(e): return jsonify([]) return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/diagrams/') def api_diagram_detail(diagram_id): session = Session() try: row = session.execute(text(""" SELECT d.id_diagram AS id, d.name_diagram AS name, d.name_es, d.group_id, pg.name_part_group AS group_name, d.image_path FROM diagrams d JOIN part_groups pg ON d.group_id = pg.id_part_group WHERE d.id_diagram = :did """), {'did': diagram_id}).mappings().first() if row is None: return jsonify({'error': 'Diagram not found'}), 404 image_path = row['image_path'] or '' image_url = '/' + image_path if image_path and not image_path.startswith('/') else image_path diagram = {'id': row['id'], 'name': row['name'], 'name_es': row['name_es'], 'group_id': row['group_id'], 'group_name': row['group_name'], 'image_path': image_path, 'image_url': image_url, 'svg_content': None, 'width': None, 'height': None, 'hotspots': []} hotspot_rows = session.execute(text(""" SELECT h.id_dgr_hotspot AS id, h.part_id, h.callout_number, sh.name_shape AS shape, h.coords, p.name_part AS part_name, p.oem_part_number AS part_number FROM diagram_hotspots h LEFT JOIN parts p ON h.part_id = p.id_part LEFT JOIN shapes sh ON h.id_shape = sh.id_shape WHERE h.diagram_id = :did ORDER BY h.callout_number """), {'did': diagram_id}).mappings().all() for hr in hotspot_rows: diagram['hotspots'].append({'id': hr['id'], 'part_id': hr['part_id'], 'callout_number': hr['callout_number'], 'label': None, 'shape': hr['shape'], 'coords': hr['coords'], 'color': None, 'part_name': hr['part_name'], 'part_number': hr['part_number']}) return jsonify(diagram) except Exception as e: return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/diagrams//hotspots') def api_diagram_hotspots(diagram_id): session = Session() try: rows = session.execute(text(""" SELECT h.id_dgr_hotspot AS id, h.part_id, h.callout_number, sh.name_shape AS shape, h.coords, p.name_part AS part_name, p.oem_part_number AS part_number FROM diagram_hotspots h LEFT JOIN parts p ON h.part_id = p.id_part LEFT JOIN shapes sh ON h.id_shape = sh.id_shape WHERE h.diagram_id = :did ORDER BY h.callout_number LIMIT 500 """), {'did': diagram_id}).mappings().all() return jsonify([{'id': r['id'], 'part_id': r['part_id'], 'callout_number': r['callout_number'], 'label': None, 'shape': r['shape'], 'coords': r['coords'], 'color': None, 'part_name': r['part_name'], 'part_number': r['part_number']} for r in rows]) except Exception as e: return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/groups//diagrams') def api_group_diagrams(group_id): session = Session() try: rows = session.execute(text(""" SELECT id_diagram AS id, name_diagram AS name, name_es, thumbnail_path, display_order FROM diagrams WHERE group_id = :gid ORDER BY display_order, name_diagram LIMIT 100 """), {'gid': group_id}).mappings().all() return jsonify([{'id': r['id'], 'name': r['name'], 'name_es': r['name_es'], 'thumbnail_path': r['thumbnail_path'], 'display_order': r['display_order']} for r in rows]) except Exception as e: return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/vehicles//diagrams') def api_vehicle_diagrams(mye_id): session = Session() try: rows = session.execute(text(""" SELECT DISTINCT d.id_diagram AS id, d.name_diagram AS name, d.name_es, d.group_id, d.image_path, pg.name_part_group AS group_name, pc.id_part_category AS category_id, pc.name_part_category AS category_name, d.thumbnail_path, vd.notes FROM vehicle_diagrams vd JOIN diagrams d ON vd.diagram_id = d.id_diagram JOIN part_groups pg ON d.group_id = pg.id_part_group JOIN part_categories pc ON pg.category_id = pc.id_part_category WHERE vd.model_year_engine_id = :mye_id ORDER BY pc.display_order, pg.display_order, d.display_order LIMIT 200 """), {'mye_id': mye_id}).mappings().all() diagrams = [] for r in rows: ip = r['image_path'] or '' iu = '/' + ip if ip and not ip.startswith('/') else ip diagrams.append({'id': r['id'], 'name': r['name'], 'name_es': r['name_es'], 'group_id': r['group_id'], 'group_name': r['group_name'], 'category_id': r['category_id'], 'category_name': r['category_name'], 'image_url': iu, 'thumbnail_path': r['thumbnail_path'], 'notes': r['notes']}) return jsonify(diagrams) except Exception as e: return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/diagrams//parts') def api_diagram_parts(diagram_id): session = Session() try: mye_id = request.args.get('mye_id', type=int) q = """ SELECT DISTINCT p.id_part AS id, p.oem_part_number, p.name_part AS name, p.name_es, p.description, pg.id_part_group AS group_id, pg.name_part_group AS group_name, pg.name_es AS group_name_es FROM vehicle_diagrams vd JOIN vehicle_parts vp ON vp.model_year_engine_id = vd.model_year_engine_id JOIN parts p ON vp.part_id = p.id_part JOIN part_groups pg ON p.group_id = pg.id_part_group JOIN part_categories pc ON pg.category_id = pc.id_part_category WHERE vd.diagram_id = :did AND pc.id_part_category IN (10, 11) """ params = {'did': diagram_id} if mye_id: q += " AND vd.model_year_engine_id = :mye_id" params['mye_id'] = mye_id q += " ORDER BY pg.name_part_group, p.oem_part_number LIMIT 200" rows = session.execute(text(q), params).mappings().all() # Batch cross-refs (N+1 fix) xrefs_map = {} if rows: part_ids = list(set(r['id'] for r in rows)) in_params = {} in_pl = [] for i, pid in enumerate(part_ids): in_params[f'pid_{i}'] = pid in_pl.append(f':pid_{i}') xref_rows = session.execute(text(f""" SELECT part_id, cross_reference_number, source_ref AS source FROM part_cross_references WHERE part_id IN ({', '.join(in_pl)}) """), in_params).mappings().all() for xr in xref_rows: xrefs_map.setdefault(xr['part_id'], []).append({'number': xr['cross_reference_number'], 'source': xr['source']}) return jsonify([{'id': r['id'], 'part_number': r['oem_part_number'], 'name': r['name'], 'name_es': r['name_es'], 'description': r['description'], 'group_name': r['group_name'], 'group_name_es': r['group_name_es'], 'cross_references': xrefs_map.get(r['id'], [])} for r in rows]) except Exception as e: if _is_retired_schema_error(e): return jsonify([]) return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/diagrams/search') def api_diagrams_search(): session = Session() try: q = request.args.get('q', '').strip() brand = request.args.get('brand', '').strip() model = request.args.get('model', '').strip() if q: rows = session.execute(text(""" SELECT DISTINCT d.id_diagram AS id, d.name_diagram AS name, d.name_es, d.image_path, d.source_diagram AS source FROM diagrams d WHERE d.name_diagram ILIKE :q OR d.name_es ILIKE :q ORDER BY d.name_diagram LIMIT 50 """), {'q': '%' + q + '%'}).mappings().all() elif brand or model: sql = """ SELECT DISTINCT d.id_diagram AS id, d.name_diagram AS name, d.name_es, d.image_path, d.source_diagram AS source FROM diagrams d JOIN vehicle_diagrams vd ON vd.diagram_id = d.id_diagram JOIN model_year_engine mye ON vd.model_year_engine_id = mye.id_mye JOIN models m ON mye.model_id = m.id_model JOIN brands b ON m.brand_id = b.id_brand WHERE 1=1 """ params = {} if brand: sql += " AND b.name_brand ILIKE :brand" params['brand'] = brand if model: sql += " AND m.name_model ILIKE :model" params['model'] = model sql += " ORDER BY d.name_diagram LIMIT 50" rows = session.execute(text(sql), params).mappings().all() else: rows = session.execute(text(""" SELECT d.id_diagram AS id, d.name_diagram AS name, d.name_es, d.image_path, d.source_diagram AS source FROM diagrams d WHERE d.source_diagram = 'MOOG Catalog' ORDER BY d.name_diagram LIMIT 50 """)).mappings().all() return jsonify([{'id': r['id'], 'name': r['name'], 'name_es': r['name_es'], 'image_path': r['image_path'], 'source': r['source']} for r in rows]) except Exception as e: if _is_retired_schema_error(e): return jsonify([]) return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/hotspots/') def api_hotspot_detail(hotspot_id): session = Session() try: row = session.execute(text(""" SELECT h.id_dgr_hotspot AS id, h.diagram_id, h.part_id, h.callout_number, sh.name_shape AS shape, h.coords FROM diagram_hotspots h LEFT JOIN shapes sh ON h.id_shape = sh.id_shape WHERE h.id_dgr_hotspot = :hid """), {'hid': hotspot_id}).mappings().first() if row is None: return jsonify({'error': 'Hotspot not found'}), 404 hotspot = {'id': row['id'], 'diagram_id': row['diagram_id'], 'part_id': row['part_id'], 'callout_number': row['callout_number'], 'label': None, 'shape': row['shape'], 'coords': row['coords'], 'color': None, 'part': None} if row['part_id']: pr = session.execute(text(""" SELECT p.id_part AS id, p.oem_part_number, p.name_part AS name, p.name_es, pg.name_part_group AS group_name, pc.name_part_category AS category_name FROM parts p JOIN part_groups pg ON p.group_id = pg.id_part_group JOIN part_categories pc ON pg.category_id = pc.id_part_category WHERE p.id_part = :pid """), {'pid': row['part_id']}).mappings().first() if pr: hotspot['part'] = {'id': pr['id'], 'oem_part_number': pr['oem_part_number'], 'name': pr['name'], 'name_es': pr['name_es'], 'group_name': pr['group_name'], 'category_name': pr['category_name']} return jsonify(hotspot) except Exception as e: return jsonify({'error': str(e)}), 500 finally: session.close() # ============================================================================ # Search and VIN Endpoints # ============================================================================ def validate_vin(vin): if not vin or len(vin) != 17: return False return bool(re.compile(r'^[A-HJ-NPR-Z0-9]{17}$', re.IGNORECASE).match(vin)) def find_vehicle_in_terms(session, terms): if len(terms) < 2: return None, terms year_terms = [] other_terms = [] for t in terms: if t.isdigit() and 1980 <= int(t) <= 2030: year_terms.append(t) else: other_terms.append(t) if not other_terms: return None, terms best_match = None used_terms = [] for num_terms in range(min(3, len(other_terms)), 0, -1): if best_match: break for i in range(len(other_terms) - num_terms + 1): test_terms = other_terms[i:i + num_terms] if year_terms: test_terms = test_terms + year_terms[:1] where_clauses = [] params = {} for idx, t in enumerate(test_terms): tp = f"%{t}%" where_clauses.append(f"(b.name_brand ILIKE :t{idx}_b OR m.name_model ILIKE :t{idx}_m OR CAST(y.year_car AS TEXT) ILIKE :t{idx}_y)") params[f't{idx}_b'] = tp params[f't{idx}_m'] = tp params[f't{idx}_y'] = tp where_sql = " AND ".join(where_clauses) row = session.execute(text(f""" SELECT mye.id_mye AS id, b.name_brand AS brand, m.name_model AS model, y.year_car AS year, e.name_engine AS engine FROM model_year_engine mye JOIN models m ON mye.model_id = m.id_model JOIN brands b ON m.brand_id = b.id_brand JOIN years y ON mye.year_id = y.id_year JOIN engines e ON mye.engine_id = e.id_engine WHERE {where_sql} ORDER BY y.year_car DESC LIMIT 1 """), params).mappings().first() if row: best_match = {'id': row['id'], 'brand': row['brand'], 'model': row['model'], 'year': row['year'], 'engine': row['engine']} used_terms = test_terms break if best_match: remaining = [] used_lower = [t.lower() for t in used_terms] for t in terms: if t.lower() not in used_lower: remaining.append(t) else: used_lower.remove(t.lower()) return best_match, remaining return None, terms @app.route('/api/search') def api_search(): session = Session() try: q = request.args.get('q', '').strip() search_type = request.args.get('type', 'all') category_id = request.args.get('category_id', type=int) limit = request.args.get('limit', 50, type=int) offset = request.args.get('offset', 0, type=int) if not q: return jsonify({'error': 'Search query is required'}), 400 results = {'parts': [], 'vehicles': [], 'vehicle_parts': [], 'matched_vehicle': None, 'total_count': 0} terms = q.split() # Combined vehicle + part search (requiere el esquema TecDoc retirado; # se omite limpiamente cuando no está disponible) if len(terms) >= 2 and search_type == 'all' and _tecdoc_available(): matched_vehicle, remaining_terms = find_vehicle_in_terms(session, terms) if matched_vehicle and remaining_terms: results['matched_vehicle'] = matched_vehicle mv = matched_vehicle vp_where_clauses = [] vp_params = {'mv_brand': mv['brand'], 'mv_model': mv['model'], 'mv_year': mv['year'], 'limit': limit} for i, t in enumerate(remaining_terms): tp = f"%{t}%" vp_where_clauses.append(f"(p.name_part ILIKE :vp{i}_n OR p.name_es ILIKE :vp{i}_ne OR p.oem_part_number ILIKE :vp{i}_o OR pg.name_part_group ILIKE :vp{i}_g)") vp_params[f'vp{i}_n'] = tp vp_params[f'vp{i}_ne'] = tp vp_params[f'vp{i}_o'] = tp vp_params[f'vp{i}_g'] = tp vp_where_sql = " AND ".join(vp_where_clauses) rows = session.execute(text(f""" SELECT p.id_part AS id, p.oem_part_number, p.name_part AS name, p.name_es, pg.name_part_group AS group_name, pg.id_part_group AS group_id, pc.name_part_category AS category_name, pc.id_part_category AS category_id, vp.quantity_required, pp.name_position_part AS position, p.image_url FROM vehicle_parts vp JOIN parts p ON vp.part_id = p.id_part JOIN part_groups pg ON p.group_id = pg.id_part_group JOIN part_categories pc ON pg.category_id = pc.id_part_category LEFT JOIN position_part pp ON vp.id_position_part = pp.id_position_part WHERE vp.model_year_engine_id IN ( SELECT mye.id_mye FROM model_year_engine mye JOIN models m ON mye.model_id = m.id_model JOIN brands b ON m.brand_id = b.id_brand JOIN years y ON mye.year_id = y.id_year WHERE b.name_brand ILIKE :mv_brand AND m.name_model ILIKE :mv_model AND y.year_car = :mv_year ) AND ({vp_where_sql}) ORDER BY p.name_part LIMIT :limit """), vp_params).mappings().all() for r in rows: results['vehicle_parts'].append({'id': r['id'], 'oem_part_number': r['oem_part_number'], 'name': r['name'], 'name_es': r['name_es'], 'image_url': r.get('image_url'), 'group_name': r['group_name'], 'group_id': r['group_id'], 'category_name': r['category_name'], 'category_id': r['category_id'], 'quantity': r['quantity_required'], 'position': r['position'], 'match_type': 'vehicle_part'}) if results['vehicle_parts']: results['total_count'] = len(results['vehicle_parts']) return jsonify(results) # Partes — catálogo vivo (Meilisearch + fallback SQL). # `category_id` ya no aplica: los ids TecDoc no existen. if search_type in ('parts', 'all') and terms and not results['vehicle_parts']: for p in _search_catalog_live(q, limit=limit, offset=offset): results['parts'].append({ 'id': p['id_part'], 'oem_part_number': p['oem_part_number'], 'name': p['name'], 'name_es': p['name'], 'image_url': p['image_url'], 'group_name': p['group_name'], 'category_name': p['category_name'], 'manufacturer': p['manufacturer'], 'match_type': 'supplier_catalog', }) # Search vehicles if search_type in ('vehicles', 'all') and terms: v_params = {'v_limit': limit, 'v_offset': offset} v_clauses = [] for i, t in enumerate(terms): tp = f"%{t}%" v_clauses.append(f"(b.name_brand ILIKE :v{i}_b OR m.name_model ILIKE :v{i}_m OR CAST(y.year_car AS TEXT) ILIKE :v{i}_y OR e.name_engine ILIKE :v{i}_e)") v_params[f'v{i}_b'] = tp v_params[f'v{i}_m'] = tp v_params[f'v{i}_y'] = tp v_params[f'v{i}_e'] = tp v_rows = session.execute(text(f""" SELECT mye.id_mye AS id, b.name_brand AS brand, m.name_model AS model, y.year_car AS year, e.name_engine AS engine FROM model_year_engine mye JOIN models m ON mye.model_id = m.id_model JOIN brands b ON m.brand_id = b.id_brand JOIN years y ON mye.year_id = y.id_year JOIN engines e ON mye.engine_id = e.id_engine WHERE {' AND '.join(v_clauses)} ORDER BY y.year_car DESC, b.name_brand, m.name_model LIMIT :v_limit OFFSET :v_offset """), v_params).mappings().all() for r in v_rows: results['vehicles'].append({'id': r['id'], 'brand': r['brand'], 'model': r['model'], 'year': r['year'], 'engine': r['engine']}) results['total_count'] = len(results['parts']) + len(results['vehicles']) return jsonify(results) except Exception as e: try: session.rollback() except Exception: pass # Nunca 500 en la superficie pública: se degrada con mensaje claro. return jsonify({'parts': [], 'vehicles': [], 'vehicle_parts': [], 'matched_vehicle': None, 'total_count': 0, 'error': str(e)}), 200 finally: session.close() @app.route('/api/search/parts') def api_search_parts(): """Búsqueda paginada de partes en el catálogo vivo (Meilisearch + SQL).""" try: q = request.args.get('q', '').strip() page = max(1, request.args.get('page', 1, type=int)) per_page = min(request.args.get('per_page', 50, type=int), 100) offset = (page - 1) * per_page if not q: return jsonify({'error': 'Search query is required'}), 400 parts = _search_catalog_live(q, limit=per_page, offset=offset) total = len(parts) + offset total_pages = max(1, (total + per_page - 1) // per_page) return jsonify({'data': parts, 'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}}) except Exception as e: # Nunca 500 en la superficie pública. return jsonify({'data': [], 'error': str(e), 'pagination': {'page': 1, 'per_page': 50, 'total': 0, 'total_pages': 1}}), 200 @app.route('/api/vin/decode/') def api_vin_decode(vin): session = Session() try: vin = vin.upper().strip() if not validate_vin(vin): return jsonify({'error': 'Invalid VIN format. VIN must be 17 alphanumeric characters (no I, O, Q).'}), 400 cached_row = session.execute(text(""" SELECT vin, make, model, year, engine_info, body_class, drive_type, model_year_engine_id, created_at, expires_at FROM vin_cache WHERE vin = :vin AND expires_at > NOW() """), {'vin': vin}).mappings().first() if cached_row: engine_info_data = {} if cached_row['engine_info']: try: engine_info_data = json_module.loads(cached_row['engine_info']) except Exception: engine_info_data = {'raw': cached_row['engine_info']} cached_data = {'vin': cached_row['vin'], 'make': cached_row['make'], 'model': cached_row['model'], 'year': cached_row['year'], 'engine_info': engine_info_data, 'body_class': cached_row['body_class'], 'drive_type': cached_row['drive_type'], 'matched_vehicle': None, 'cached': True} if cached_row['model_year_engine_id']: mye_row = session.execute(text(""" SELECT mye.id_mye AS id, b.name_brand AS brand, m.name_model AS model, y.year_car AS year, e.name_engine AS engine FROM model_year_engine mye JOIN models m ON mye.model_id = m.id_model JOIN brands b ON m.brand_id = b.id_brand JOIN years y ON mye.year_id = y.id_year JOIN engines e ON mye.engine_id = e.id_engine WHERE mye.id_mye = :mye_id """), {'mye_id': cached_row['model_year_engine_id']}).mappings().first() if mye_row: cached_data['matched_vehicle'] = {'mye_id': mye_row['id'], 'brand': mye_row['brand'], 'model': mye_row['model'], 'year': mye_row['year'], 'engine': mye_row['engine']} return jsonify(cached_data) # Call NHTSA API nhtsa_url = f'https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVin/{vin}?format=json' try: req = urllib.request.Request(nhtsa_url, headers={'User-Agent': 'NexusAutoparts/2.0'}) with urllib.request.urlopen(req, timeout=10) as response: nhtsa_data = json_module.loads(response.read().decode('utf-8')) except urllib.error.URLError as e: return jsonify({'error': f'Failed to connect to NHTSA API: {str(e)}'}), 503 except urllib.error.HTTPError as e: return jsonify({'error': f'NHTSA API error: {e.code}'}), 502 except Exception as e: return jsonify({'error': f'Error calling NHTSA API: {str(e)}'}), 500 nhtsa_results = {item['Variable']: item['Value'] for item in nhtsa_data.get('Results', [])} make = nhtsa_results.get('Make', '') model = nhtsa_results.get('Model', '') year_str = nhtsa_results.get('ModelYear', '') year = int(year_str) if year_str and year_str.isdigit() else None engine_config = nhtsa_results.get('EngineConfiguration', '') cylinders_str = nhtsa_results.get('EngineCylinders', '') cylinders = int(cylinders_str) if cylinders_str and cylinders_str.isdigit() else None displacement_str = nhtsa_results.get('DisplacementL', '') displacement_l = float(displacement_str) if displacement_str else None fuel_type = nhtsa_results.get('FuelTypePrimary', '') body_class = nhtsa_results.get('BodyClass', '') drive_type = nhtsa_results.get('DriveType', '') matched_mye_id = None matched_vehicle = None if make and model and year: mye_row = session.execute(text(""" SELECT mye.id_mye AS id, b.name_brand AS brand, m.name_model AS model, y.year_car AS year, e.name_engine AS engine FROM model_year_engine mye JOIN models m ON mye.model_id = m.id_model JOIN brands b ON m.brand_id = b.id_brand JOIN years y ON mye.year_id = y.id_year JOIN engines e ON mye.engine_id = e.id_engine WHERE b.name_brand ILIKE :make AND m.name_model ILIKE :model AND y.year_car = :year LIMIT 1 """), {'make': make, 'model': model, 'year': year}).mappings().first() if mye_row: matched_mye_id = mye_row['id'] matched_vehicle = {'mye_id': mye_row['id'], 'brand': mye_row['brand'], 'model': mye_row['model'], 'year': mye_row['year'], 'engine': mye_row['engine']} expires_at = datetime.now() + timedelta(days=30) engine_info = json_module.dumps({'configuration': engine_config, 'cylinders': cylinders, 'displacement_l': displacement_l, 'fuel_type': fuel_type}) session.execute(text(""" INSERT INTO vin_cache (vin, decoded_data, make, model, year, engine_info, body_class, drive_type, model_year_engine_id, expires_at) VALUES (:vin, CAST(:decoded_data AS jsonb), :make, :model, :year, :engine_info, :body_class, :drive_type, :mye_id, :expires_at) ON CONFLICT (vin) DO UPDATE SET decoded_data = EXCLUDED.decoded_data, make = EXCLUDED.make, model = EXCLUDED.model, year = EXCLUDED.year, engine_info = EXCLUDED.engine_info, body_class = EXCLUDED.body_class, drive_type = EXCLUDED.drive_type, model_year_engine_id = EXCLUDED.model_year_engine_id, expires_at = EXCLUDED.expires_at """), {'vin': vin, 'decoded_data': json_module.dumps(nhtsa_results), 'make': make, 'model': model, 'year': year, 'engine_info': engine_info, 'body_class': body_class, 'drive_type': drive_type, 'mye_id': matched_mye_id, 'expires_at': expires_at}) session.commit() return jsonify({'vin': vin, 'make': make, 'model': model, 'year': year, 'engine_info': {'configuration': engine_config, 'cylinders': cylinders, 'displacement_l': displacement_l, 'fuel_type': fuel_type}, 'body_class': body_class, 'drive_type': drive_type, 'matched_vehicle': matched_vehicle, 'cached': False}) except Exception as e: return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/vin//parts') def api_vin_parts(vin): session = Session() try: vin = vin.upper().strip() if not validate_vin(vin): return jsonify({'error': 'Invalid VIN format. VIN must be 17 alphanumeric characters (no I, O, Q).'}), 400 category_id = request.args.get('category_id', type=int) cached_row = session.execute(text("SELECT vin, make, model, year, model_year_engine_id FROM vin_cache WHERE vin = :vin"), {'vin': vin}).mappings().first() if not cached_row: return jsonify({'error': 'VIN not found in cache. Please decode the VIN first using /api/vin/decode/'}), 404 mye_id = cached_row['model_year_engine_id'] vehicle_info = {'vin': cached_row['vin'], 'make': cached_row['make'], 'model': cached_row['model'], 'year': cached_row['year'], 'mye_id': mye_id} if not mye_id: return jsonify({'vin': vin, 'vehicle_info': vehicle_info, 'categories': [], 'message': 'No matching vehicle configuration found in database. Use /api/vin//match to manually link.'}) params = {'mye_id': mye_id} cat_filter = "" if category_id: cat_filter = " AND pc.id_part_category = :cid" params['cid'] = category_id rows = session.execute(text(f""" SELECT pc.id_part_category AS category_id, pc.name_part_category AS category_name, pc.name_es AS category_name_es, p.id_part AS part_id, p.oem_part_number, p.name_part AS part_name, p.name_es AS part_name_es, pg.name_part_group AS group_name, vp.quantity_required, pp.name_position_part AS position FROM vehicle_parts vp JOIN parts p ON vp.part_id = p.id_part JOIN part_groups pg ON p.group_id = pg.id_part_group JOIN part_categories pc ON pg.category_id = pc.id_part_category LEFT JOIN position_part pp ON vp.id_position_part = pp.id_position_part WHERE vp.model_year_engine_id = :mye_id {cat_filter} ORDER BY pc.display_order, pg.display_order, p.name_part """), params).mappings().all() categories_dict = {} for r in rows: cid = r['category_id'] if cid not in categories_dict: categories_dict[cid] = {'id': cid, 'name': r['category_name'], 'name_es': r['category_name_es'], 'parts': []} categories_dict[cid]['parts'].append({'id': r['part_id'], 'oem_part_number': r['oem_part_number'], 'name': r['part_name'], 'name_es': r['part_name_es'], 'group_name': r['group_name'], 'quantity_required': r['quantity_required'], 'position': r['position']}) return jsonify({'vin': vin, 'vehicle_info': vehicle_info, 'categories': list(categories_dict.values())}) except Exception as e: return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/vin//match') def api_vin_match(vin): session = Session() try: vin = vin.upper().strip() if not validate_vin(vin): return jsonify({'error': 'Invalid VIN format. VIN must be 17 alphanumeric characters (no I, O, Q).'}), 400 mye_id = request.args.get('mye_id', type=int) if not mye_id: return jsonify({'error': 'mye_id parameter is required'}), 400 mye_row = session.execute(text("SELECT id_mye FROM model_year_engine WHERE id_mye = :mid"), {'mid': mye_id}).mappings().first() if not mye_row: return jsonify({'error': f'model_year_engine_id {mye_id} not found'}), 404 vin_row = session.execute(text("SELECT vin FROM vin_cache WHERE vin = :vin"), {'vin': vin}).mappings().first() if vin_row: session.execute(text("UPDATE vin_cache SET model_year_engine_id = :mid WHERE vin = :vin"), {'mid': mye_id, 'vin': vin}) else: expires_at = datetime.now() + timedelta(days=30) session.execute(text(""" INSERT INTO vin_cache (vin, decoded_data, model_year_engine_id, created_at, expires_at) VALUES (:vin, '{}'::jsonb, :mid, NOW(), :expires_at) """), {'vin': vin, 'mid': mye_id, 'expires_at': expires_at}) session.commit() return jsonify({'success': True, 'vin': vin, 'mye_id': mye_id}) except Exception as e: return jsonify({'error': str(e)}), 500 finally: session.close() # ============================================================================ # Admin Endpoints # ============================================================================ @app.route('/api/admin/stats') def api_admin_stats(): session = Session() try: stats = {} for table, key in [('part_categories', 'categories'), ('part_groups', 'groups'), ('manufacturers', 'manufacturers'), ('parts', 'parts'), ('aftermarket_parts', 'aftermarket'), ('vehicle_parts', 'fitment')]: # Tablas TecDoc retiradas (jun 2026): degradar a 0 en vez de 500. exists = session.execute( text("SELECT to_regclass(:t)"), {'t': 'public.' + table} ).scalar() if not exists: stats[key] = 0 continue est = session.execute(text( "SELECT reltuples::bigint FROM pg_class WHERE relname = :t" ), {'t': table}).scalar() if est and est > 0: stats[key] = est else: stats[key] = session.execute(text(f"SELECT COUNT(*) FROM {table}")).scalar() return jsonify(stats) except Exception as e: return jsonify({'error': str(e)}), 500 finally: session.close() # ---- Categories CRUD ---- @app.route('/api/admin/categories', methods=['POST']) def api_admin_create_category(): session = Session() try: data = request.get_json() result = session.execute(text(""" INSERT INTO part_categories (name_part_category, name_es, slug, icon_name, display_order, parent_id) VALUES (:name, :name_es, :slug, :icon_name, :display_order, :parent_id) RETURNING id_part_category """), {'name': data['name'], 'name_es': data.get('name_es'), 'slug': data.get('slug') or data['name'].lower().replace(' ', '-'), 'icon_name': data.get('icon_name'), 'display_order': data.get('display_order', 0), 'parent_id': data.get('parent_id')}) new_id = result.scalar() session.commit() return jsonify({'id': new_id, 'message': 'Category created'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/admin/categories/', methods=['PUT']) def api_admin_update_category(category_id): session = Session() try: data = request.get_json() session.execute(text(""" UPDATE part_categories SET name_part_category = :name, name_es = :name_es, slug = :slug, icon_name = :icon_name, display_order = :display_order WHERE id_part_category = :id """), {'name': data['name'], 'name_es': data.get('name_es'), 'slug': data.get('slug'), 'icon_name': data.get('icon_name'), 'display_order': data.get('display_order', 0), 'id': category_id}) session.commit() return jsonify({'message': 'Category updated'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/admin/categories/', methods=['DELETE']) def api_admin_delete_category(category_id): session = Session() try: session.execute(text("DELETE FROM part_categories WHERE id_part_category = :id"), {'id': category_id}) session.commit() return jsonify({'message': 'Category deleted'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() # ---- Groups CRUD ---- @app.route('/api/admin/groups') def api_admin_list_groups(): session = Session() try: category_id = request.args.get('category_id', type=int) q = """ SELECT pg.id_part_group AS id, pg.name_part_group AS name, pg.name_es, pg.category_id, pg.display_order, pg.slug, pc.name_part_category AS category_name FROM part_groups pg LEFT JOIN part_categories pc ON pg.category_id = pc.id_part_category WHERE 1=1 """ params = {} if category_id: q += " AND pg.category_id = :cid" params['cid'] = category_id q += " ORDER BY pg.display_order, pg.name_part_group" rows = session.execute(text(q), params).mappings().all() return jsonify([{'id': r['id'], 'name': r['name'], 'name_es': r['name_es'], 'category_id': r['category_id'], 'category_name': r['category_name'], 'display_order': r['display_order'], 'slug': r['slug']} for r in rows]) except Exception as e: return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/admin/groups', methods=['POST']) def api_admin_create_group(): session = Session() try: data = request.get_json() result = session.execute(text(""" INSERT INTO part_groups (category_id, name_part_group, name_es, slug, display_order) VALUES (:category_id, :name, :name_es, :slug, :display_order) RETURNING id_part_group """), {'category_id': data['category_id'], 'name': data['name'], 'name_es': data.get('name_es'), 'slug': data.get('slug') or data['name'].lower().replace(' ', '-'), 'display_order': data.get('display_order', 0)}) new_id = result.scalar() session.commit() return jsonify({'id': new_id, 'message': 'Group created'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/admin/groups/', methods=['PUT']) def api_admin_update_group(group_id): session = Session() try: data = request.get_json() session.execute(text(""" UPDATE part_groups SET category_id = :category_id, name_part_group = :name, name_es = :name_es, display_order = :display_order WHERE id_part_group = :id """), {'category_id': data['category_id'], 'name': data['name'], 'name_es': data.get('name_es'), 'display_order': data.get('display_order', 0), 'id': group_id}) session.commit() return jsonify({'message': 'Group updated'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/admin/groups/', methods=['DELETE']) def api_admin_delete_group(group_id): session = Session() try: session.execute(text("DELETE FROM part_groups WHERE id_part_group = :id"), {'id': group_id}) session.commit() return jsonify({'message': 'Group deleted'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() # ---- Parts CRUD ---- @app.route('/api/admin/parts', methods=['POST']) def api_admin_create_part(): session = Session() try: data = request.get_json() result = session.execute(text(""" INSERT INTO parts (oem_part_number, name_part, name_es, group_id, description, description_es, weight_kg, id_material) VALUES (:oem, :name, :name_es, :group_id, :desc, :desc_es, :weight, (SELECT id_material FROM materials WHERE name_material = :material)) RETURNING id_part """), {'oem': data['oem_part_number'], 'name': data['name'], 'name_es': data.get('name_es'), 'group_id': data['group_id'], 'desc': data.get('description'), 'desc_es': data.get('description_es'), 'weight': data.get('weight_kg'), 'material': data.get('material')}) new_id = result.scalar() session.commit() return jsonify({'id': new_id, 'message': 'Part created'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/admin/parts/', methods=['PUT']) def api_admin_update_part(part_id): session = Session() try: data = request.get_json() session.execute(text(""" UPDATE parts SET oem_part_number = :oem, name_part = :name, name_es = :name_es, group_id = :group_id, description = :desc, description_es = :desc_es, weight_kg = :weight, id_material = (SELECT id_material FROM materials WHERE name_material = :material) WHERE id_part = :id """), {'oem': data['oem_part_number'], 'name': data['name'], 'name_es': data.get('name_es'), 'group_id': data['group_id'], 'desc': data.get('description'), 'desc_es': data.get('description_es'), 'weight': data.get('weight_kg'), 'material': data.get('material'), 'id': part_id}) session.commit() return jsonify({'message': 'Part updated'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/admin/parts/', methods=['DELETE']) def api_admin_delete_part(part_id): session = Session() try: session.execute(text("DELETE FROM parts WHERE id_part = :id"), {'id': part_id}) session.commit() return jsonify({'message': 'Part deleted'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() # ---- Manufacturers CRUD ---- @app.route('/api/admin/manufacturers', methods=['POST']) def api_admin_create_manufacturer(): session = Session() try: data = request.get_json() result = session.execute(text(""" INSERT INTO manufacturers (name_manufacture, id_type_manu, id_quality_tier, id_country, website) VALUES (:name, (SELECT id_type_manu FROM manufacture_type WHERE name_type_manu = :type), (SELECT id_quality_tier FROM quality_tier WHERE name_quality = :qt), (SELECT id_country FROM countries WHERE name_country = :country), :website) RETURNING id_manufacture """), {'name': data['name'], 'type': data.get('type', 'aftermarket'), 'qt': data.get('quality_tier', 'standard'), 'country': data.get('country'), 'website': data.get('website')}) new_id = result.scalar() session.commit() return jsonify({'id': new_id, 'message': 'Manufacturer created'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/admin/manufacturers/', methods=['PUT']) def api_admin_update_manufacturer(manufacturer_id): session = Session() try: data = request.get_json() session.execute(text(""" UPDATE manufacturers SET name_manufacture = :name, id_type_manu = (SELECT id_type_manu FROM manufacture_type WHERE name_type_manu = :type), id_quality_tier = (SELECT id_quality_tier FROM quality_tier WHERE name_quality = :qt), id_country = (SELECT id_country FROM countries WHERE name_country = :country), website = :website WHERE id_manufacture = :id """), {'name': data['name'], 'type': data.get('type'), 'qt': data.get('quality_tier'), 'country': data.get('country'), 'website': data.get('website'), 'id': manufacturer_id}) session.commit() return jsonify({'message': 'Manufacturer updated'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/admin/manufacturers/', methods=['DELETE']) def api_admin_delete_manufacturer(manufacturer_id): session = Session() try: session.execute(text("DELETE FROM manufacturers WHERE id_manufacture = :id"), {'id': manufacturer_id}) session.commit() return jsonify({'message': 'Manufacturer deleted'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() # ---- Aftermarket Parts CRUD ---- @app.route('/api/admin/aftermarket', methods=['POST']) def api_admin_create_aftermarket(): session = Session() try: data = request.get_json() result = session.execute(text(""" INSERT INTO aftermarket_parts (oem_part_id, manufacturer_id, part_number, name_aftermarket_parts, name_es, id_quality_tier, price_usd, warranty_months) VALUES (:oem_part_id, :manufacturer_id, :part_number, :name, :name_es, (SELECT id_quality_tier FROM quality_tier WHERE name_quality = :qt), :price_usd, :warranty_months) RETURNING id_aftermarket_parts """), {'oem_part_id': data['oem_part_id'], 'manufacturer_id': data['manufacturer_id'], 'part_number': data['part_number'], 'name': data.get('name'), 'name_es': data.get('name_es'), 'qt': data.get('quality_tier', 'standard'), 'price_usd': data.get('price_usd'), 'warranty_months': data.get('warranty_months')}) new_id = result.scalar() session.commit() return jsonify({'id': new_id, 'message': 'Aftermarket part created'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/admin/aftermarket/', methods=['PUT']) def api_admin_update_aftermarket(aftermarket_id): session = Session() try: data = request.get_json() session.execute(text(""" UPDATE aftermarket_parts SET oem_part_id = :oem_part_id, manufacturer_id = :manufacturer_id, part_number = :part_number, name_aftermarket_parts = :name, name_es = :name_es, id_quality_tier = (SELECT id_quality_tier FROM quality_tier WHERE name_quality = :qt), price_usd = :price_usd, warranty_months = :warranty_months WHERE id_aftermarket_parts = :id """), {'oem_part_id': data['oem_part_id'], 'manufacturer_id': data['manufacturer_id'], 'part_number': data['part_number'], 'name': data.get('name'), 'name_es': data.get('name_es'), 'qt': data.get('quality_tier'), 'price_usd': data.get('price_usd'), 'warranty_months': data.get('warranty_months'), 'id': aftermarket_id}) session.commit() return jsonify({'message': 'Aftermarket part updated'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/admin/aftermarket/', methods=['DELETE']) def api_admin_delete_aftermarket(aftermarket_id): session = Session() try: session.execute(text("DELETE FROM aftermarket_parts WHERE id_aftermarket_parts = :id"), {'id': aftermarket_id}) session.commit() return jsonify({'message': 'Aftermarket part deleted'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() # ---- Cross-References CRUD ---- @app.route('/api/admin/crossref') def api_admin_list_crossref(): session = Session() try: page = request.args.get('page', 1, type=int) per_page = min(request.args.get('per_page', 50, type=int), 100) offset = (page - 1) * per_page total_count = session.execute(text("SELECT COUNT(*) FROM part_cross_references")).scalar() rows = session.execute(text(""" SELECT pcr.id_part_cross_ref AS id, pcr.part_id, pcr.cross_reference_number, rt.name_ref_type AS reference_type, pcr.source_ref AS source, pcr.notes, p.oem_part_number, p.name_part AS part_name FROM part_cross_references pcr JOIN parts p ON pcr.part_id = p.id_part LEFT JOIN reference_type rt ON pcr.id_ref_type = rt.id_ref_type ORDER BY pcr.id_part_cross_ref DESC LIMIT :limit OFFSET :offset """), {'limit': per_page, 'offset': offset}).mappings().all() refs = [{'id': r['id'], 'part_id': r['part_id'], 'cross_reference_number': r['cross_reference_number'], 'reference_type': r['reference_type'], 'source': r['source'], 'notes': r['notes'], 'oem_part_number': r['oem_part_number'], 'part_name': r['part_name']} for r in rows] total_pages = (total_count + per_page - 1) // per_page return jsonify({'data': refs, 'pagination': {'page': page, 'per_page': per_page, 'total': total_count, 'total_pages': total_pages}}) except Exception as e: return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/admin/crossref', methods=['POST']) def api_admin_create_crossref(): session = Session() try: data = request.get_json() result = session.execute(text(""" INSERT INTO part_cross_references (part_id, cross_reference_number, id_ref_type, source_ref, notes) VALUES (:part_id, :cross_ref, (SELECT id_ref_type FROM reference_type WHERE name_ref_type = :ref_type), :source, :notes) RETURNING id_part_cross_ref """), {'part_id': data['part_id'], 'cross_ref': data['cross_reference_number'], 'ref_type': data['reference_type'], 'source': data.get('source'), 'notes': data.get('notes')}) new_id = result.scalar() session.commit() return jsonify({'id': new_id, 'message': 'Cross-reference created'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/admin/crossref/', methods=['PUT']) def api_admin_update_crossref(crossref_id): session = Session() try: data = request.get_json() session.execute(text(""" UPDATE part_cross_references SET part_id = :part_id, cross_reference_number = :cross_ref, id_ref_type = (SELECT id_ref_type FROM reference_type WHERE name_ref_type = :ref_type), source_ref = :source, notes = :notes WHERE id_part_cross_ref = :id """), {'part_id': data['part_id'], 'cross_ref': data['cross_reference_number'], 'ref_type': data['reference_type'], 'source': data.get('source'), 'notes': data.get('notes'), 'id': crossref_id}) session.commit() return jsonify({'message': 'Cross-reference updated'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/admin/crossref/', methods=['DELETE']) def api_admin_delete_crossref(crossref_id): session = Session() try: session.execute(text("DELETE FROM part_cross_references WHERE id_part_cross_ref = :id"), {'id': crossref_id}) session.commit() return jsonify({'message': 'Cross-reference deleted'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() # ---- Fitment CRUD ---- @app.route('/api/admin/fitment') def api_admin_list_fitment(): session = Session() try: page = request.args.get('page', 1, type=int) per_page = min(request.args.get('per_page', 50, type=int), 500) offset = (page - 1) * per_page brand = request.args.get('brand') model = request.args.get('model') mye_id = request.args.get('mye_id', type=int) where = " WHERE 1=1" params = {'limit': per_page, 'offset': offset} if mye_id: where += " AND vp.model_year_engine_id = :mye_id" params['mye_id'] = mye_id if brand: where += " AND b.name_brand ILIKE :brand" params['brand'] = brand if model: where += " AND m.name_model ILIKE :model" params['model'] = model base = """ FROM vehicle_parts vp JOIN model_year_engine mye ON vp.model_year_engine_id = mye.id_mye JOIN models m ON mye.model_id = m.id_model JOIN brands b ON m.brand_id = b.id_brand """ total_count = session.execute(text("SELECT COUNT(*) " + base + where), params).scalar() rows = session.execute(text(""" SELECT vp.id_vehicle_part AS id, vp.model_year_engine_id, vp.part_id, vp.quantity_required, pp.name_position_part AS position, vp.fitment_notes, b.name_brand AS brand, m.name_model AS model, y.year_car AS year, e.name_engine AS engine, p.oem_part_number, p.name_part AS part_name FROM vehicle_parts vp JOIN model_year_engine mye ON vp.model_year_engine_id = mye.id_mye JOIN models m ON mye.model_id = m.id_model JOIN brands b ON m.brand_id = b.id_brand JOIN years y ON mye.year_id = y.id_year JOIN engines e ON mye.engine_id = e.id_engine JOIN parts p ON vp.part_id = p.id_part LEFT JOIN position_part pp ON vp.id_position_part = pp.id_position_part """ + where + " ORDER BY vp.id_vehicle_part DESC LIMIT :limit OFFSET :offset"), params).mappings().all() fitments = [dict(r) for r in rows] total_pages = (total_count + per_page - 1) // per_page return jsonify({'data': fitments, 'pagination': {'page': page, 'per_page': per_page, 'total': total_count, 'total_pages': total_pages}}) except Exception as e: return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/admin/fitment', methods=['POST']) def api_admin_create_fitment(): session = Session() try: data = request.get_json() result = session.execute(text(""" INSERT INTO vehicle_parts (model_year_engine_id, part_id, quantity_required, id_position_part, fitment_notes) VALUES (:mye_id, :part_id, :qty, (SELECT id_position_part FROM position_part WHERE name_position_part = :position), :notes) RETURNING id_vehicle_part """), {'mye_id': data['model_year_engine_id'], 'part_id': data['part_id'], 'qty': data.get('quantity_required', 1), 'position': data.get('position'), 'notes': data.get('fitment_notes')}) new_id = result.scalar() session.commit() return jsonify({'id': new_id, 'message': 'Fitment created'}) except IntegrityError: session.rollback() return jsonify({'error': 'Este fitment ya existe'}), 400 except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/admin/fitment/', methods=['DELETE']) def api_admin_delete_fitment(fitment_id): session = Session() try: session.execute(text("DELETE FROM vehicle_parts WHERE id_vehicle_part = :id"), {'id': fitment_id}) session.commit() return jsonify({'message': 'Fitment deleted'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() # ============================================================================ # CSV Import # ============================================================================ @app.route('/api/admin/import/', methods=['POST']) def api_admin_import_csv(import_type): session = Session() try: data = request.get_json() records = data.get('records', []) if not records: return jsonify({'error': 'No records to import'}), 400 imported = 0 errors = [] for i, rec in enumerate(records): try: if import_type == 'categories': session.execute(text("INSERT INTO part_categories (name_part_category, name_es, slug, icon_name, display_order) VALUES (:name, :name_es, :slug, :icon_name, :do)"), {'name': rec['name'], 'name_es': rec.get('name_es'), 'slug': rec.get('slug') or rec['name'].lower().replace(' ', '-'), 'icon_name': rec.get('icon_name'), 'do': rec.get('display_order', 0)}) elif import_type == 'groups': session.execute(text("INSERT INTO part_groups (category_id, name_part_group, name_es, display_order) VALUES (:cid, :name, :name_es, :do)"), {'cid': rec['category_id'], 'name': rec['name'], 'name_es': rec.get('name_es'), 'do': rec.get('display_order', 0)}) elif import_type == 'parts': session.execute(text("""INSERT INTO parts (oem_part_number, name_part, name_es, group_id, description, description_es, weight_kg, id_material) VALUES (:oem, :name, :name_es, :gid, :desc, :desc_es, :weight, (SELECT id_material FROM materials WHERE name_material = :material))"""), {'oem': rec['oem_part_number'], 'name': rec['name'], 'name_es': rec.get('name_es'), 'gid': rec['group_id'], 'desc': rec.get('description'), 'desc_es': rec.get('description_es'), 'weight': rec.get('weight_kg'), 'material': rec.get('material')}) elif import_type == 'manufacturers': session.execute(text("""INSERT INTO manufacturers (name_manufacture, id_type_manu, id_quality_tier, id_country, website) VALUES (:name, (SELECT id_type_manu FROM manufacture_type WHERE name_type_manu = :type), (SELECT id_quality_tier FROM quality_tier WHERE name_quality = :qt), (SELECT id_country FROM countries WHERE name_country = :country), :website)"""), {'name': rec['name'], 'type': rec.get('type', 'aftermarket'), 'qt': rec.get('quality_tier', 'standard'), 'country': rec.get('country'), 'website': rec.get('website')}) elif import_type == 'aftermarket': session.execute(text("""INSERT INTO aftermarket_parts (oem_part_id, manufacturer_id, part_number, name_aftermarket_parts, name_es, id_quality_tier, price_usd, warranty_months) VALUES (:oem_part_id, :mid, :pn, :name, :name_es, (SELECT id_quality_tier FROM quality_tier WHERE name_quality = :qt), :price, :warranty)"""), {'oem_part_id': rec['oem_part_id'], 'mid': rec['manufacturer_id'], 'pn': rec['part_number'], 'name': rec.get('name'), 'name_es': rec.get('name_es'), 'qt': rec.get('quality_tier', 'standard'), 'price': rec.get('price_usd'), 'warranty': rec.get('warranty_months')}) elif import_type == 'crossref': session.execute(text("""INSERT INTO part_cross_references (part_id, cross_reference_number, id_ref_type, source_ref, notes) VALUES (:pid, :cross_ref, (SELECT id_ref_type FROM reference_type WHERE name_ref_type = :ref_type), :source, :notes)"""), {'pid': rec['part_id'], 'cross_ref': rec['cross_reference_number'], 'ref_type': rec.get('reference_type'), 'source': rec.get('source'), 'notes': rec.get('notes')}) elif import_type == 'fitment': session.execute(text("""INSERT INTO vehicle_parts (model_year_engine_id, part_id, quantity_required, id_position_part, fitment_notes) VALUES (:mye_id, :pid, :qty, (SELECT id_position_part FROM position_part WHERE name_position_part = :position), :notes) ON CONFLICT (model_year_engine_id, part_id, id_position_part) DO NOTHING"""), {'mye_id': rec['model_year_engine_id'], 'pid': rec['part_id'], 'qty': rec.get('quantity_required', 1), 'position': rec.get('position'), 'notes': rec.get('fitment_notes')}) else: return jsonify({'error': f'Unknown import type: {import_type}'}), 400 imported += 1 except Exception as e: errors.append(f"Row {i + 1}: {str(e)}") session.commit() result = {'imported': imported} if errors: result['errors'] = errors[:10] return jsonify(result) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() # ============================================================================ # CSV Export # ============================================================================ @app.route('/api/admin/export/') def api_admin_export_csv(export_type): session = Session() try: page = request.args.get('page', 1, type=int) per_page = min(request.args.get('per_page', 1000, type=int), 10000) offset = (page - 1) * per_page export_queries = { 'categories': ("SELECT id_part_category AS id, name_part_category AS name, name_es, slug, icon_name, display_order FROM part_categories ORDER BY display_order, name_part_category", "part_categories"), 'groups': ("SELECT id_part_group AS id, category_id, name_part_group AS name, name_es, display_order FROM part_groups ORDER BY category_id, display_order, name_part_group", "part_groups"), 'parts': ("SELECT p.id_part AS id, p.oem_part_number, p.name_part AS name, p.name_es, p.group_id, p.description, p.description_es, p.weight_kg, mat.name_material AS material FROM parts p LEFT JOIN materials mat ON p.id_material = mat.id_material ORDER BY p.id_part", "parts"), 'manufacturers': ("SELECT mfr.id_manufacture AS id, mfr.name_manufacture AS name, mt.name_type_manu AS type, qt.name_quality AS quality_tier, co.name_country AS country, mfr.website FROM manufacturers mfr LEFT JOIN manufacture_type mt ON mfr.id_type_manu = mt.id_type_manu LEFT JOIN quality_tier qt ON mfr.id_quality_tier = qt.id_quality_tier LEFT JOIN countries co ON mfr.id_country = co.id_country ORDER BY mfr.name_manufacture", "manufacturers"), 'aftermarket': ("SELECT ap.id_aftermarket_parts AS id, ap.oem_part_id, ap.manufacturer_id, ap.part_number, ap.name_aftermarket_parts AS name, ap.name_es, qt.name_quality AS quality_tier, ap.price_usd, ap.warranty_months FROM aftermarket_parts ap LEFT JOIN quality_tier qt ON ap.id_quality_tier = qt.id_quality_tier ORDER BY ap.id_aftermarket_parts", "aftermarket_parts"), 'crossref': ("SELECT pcr.id_part_cross_ref AS id, pcr.part_id, pcr.cross_reference_number, rt.name_ref_type AS reference_type, pcr.source_ref AS source, pcr.notes FROM part_cross_references pcr LEFT JOIN reference_type rt ON pcr.id_ref_type = rt.id_ref_type ORDER BY pcr.id_part_cross_ref", "part_cross_references"), 'fitment': ("SELECT vp.id_vehicle_part AS id, vp.model_year_engine_id, vp.part_id, vp.quantity_required, pp.name_position_part AS position, vp.fitment_notes FROM vehicle_parts vp LEFT JOIN position_part pp ON vp.id_position_part = pp.id_position_part ORDER BY vp.id_vehicle_part", "vehicle_parts"), } if export_type not in export_queries: return jsonify({'error': f'Unknown export type: {export_type}'}), 400 base_query, table_name = export_queries[export_type] total_count = session.execute(text(f"SELECT COUNT(*) FROM {table_name}")).scalar() rows = session.execute(text(base_query + " LIMIT :limit OFFSET :offset"), {'limit': per_page, 'offset': offset}).mappings().all() data_list = [dict(r) for r in rows] total_pages = (total_count + per_page - 1) // per_page return jsonify({'data': data_list, 'pagination': {'page': page, 'per_page': per_page, 'total': total_count, 'total_pages': total_pages}}) except Exception as e: return jsonify({'error': str(e)}), 500 finally: session.close() # ============================================================================ # Image Upload # ============================================================================ @app.route('/api/admin/upload-image', methods=['POST']) def api_admin_upload_image(): try: data = request.get_json() image_data = data.get('image') if not image_data: return jsonify({'error': 'No image data provided'}), 400 if ',' in image_data: header, encoded = image_data.split(',', 1) ext = 'png' if 'jpeg' in header or 'jpg' in header: ext = 'jpg' elif 'gif' in header: ext = 'gif' elif 'webp' in header: ext = 'webp' else: encoded = image_data ext = 'png' image_bytes = base64.b64decode(encoded) filename = f"{uuid.uuid4().hex}.{ext}" filepath = os.path.join('static', 'parts_images', filename) os.makedirs(os.path.join('.', 'static', 'parts_images'), exist_ok=True) with open(filepath, 'wb') as f: f.write(image_bytes) return jsonify({'url': f"/static/parts_images/{filename}", 'filename': filename}) except Exception as e: return jsonify({'error': str(e)}), 500 # ============================================================================ # Diagrams by Category # ============================================================================ @app.route('/api/vehicles//diagrams/by-category') def api_vehicle_diagrams_by_category(mye_id): session = Session() try: category_id = request.args.get('category_id', type=int) params = {'mye_id': mye_id} cat_filter = "" if category_id: cat_filter = " AND pc.id_part_category = :cid" params['cid'] = category_id rows = session.execute(text(f""" SELECT DISTINCT d.id_diagram AS id, d.name_diagram AS name, d.name_es, d.group_id, d.image_path, d.thumbnail_path, pg.name_part_group AS group_name, pg.name_es AS group_name_es, pc.id_part_category AS category_id, pc.name_part_category AS category_name, pc.name_es AS category_name_es, vd.notes FROM vehicle_diagrams vd JOIN diagrams d ON vd.diagram_id = d.id_diagram JOIN part_groups pg ON d.group_id = pg.id_part_group JOIN part_categories pc ON pg.category_id = pc.id_part_category WHERE vd.model_year_engine_id = :mye_id {cat_filter} ORDER BY pc.display_order, pg.display_order, d.display_order, d.name_diagram """), params).mappings().all() categories = {} for r in rows: cid = r['category_id'] if cid not in categories: categories[cid] = {'category_id': cid, 'category_name': r['category_name'], 'category_name_es': r['category_name_es'], 'diagrams': []} ip = r['image_path'] or '' iu = '/' + ip if ip and not ip.startswith('/') else ip categories[cid]['diagrams'].append({'id': r['id'], 'name': r['name'], 'name_es': r['name_es'], 'group_id': r['group_id'], 'group_name': r['group_name'], 'group_name_es': r['group_name_es'], 'image_url': iu, 'thumbnail_path': r['thumbnail_path'], 'notes': r['notes']}) return jsonify(list(categories.values())) except Exception as e: return jsonify({'error': str(e)}), 500 finally: session.close() # ============================================================================ # Hotspot CRUD # ============================================================================ @app.route('/api/admin/hotspots', methods=['POST']) def api_admin_create_hotspot(): session = Session() try: data = request.get_json() if not data: return jsonify({'error': 'No data provided'}), 400 diagram_id = data.get('diagram_id') coords = data.get('coords', '') if not diagram_id or not coords: return jsonify({'error': 'diagram_id and coords are required'}), 400 result = session.execute(text(""" INSERT INTO diagram_hotspots (diagram_id, part_id, callout_number, id_shape, coords) VALUES (:did, :pid, :callout, (SELECT id_shape FROM shapes WHERE name_shape = :shape), :coords) RETURNING id_dgr_hotspot """), {'did': diagram_id, 'pid': data.get('part_id'), 'callout': data.get('callout_number'), 'shape': data.get('shape', 'circle'), 'coords': coords}) hotspot_id = result.scalar() session.commit() return jsonify({'id': hotspot_id, 'message': 'Hotspot created'}), 201 except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/admin/hotspots/', methods=['PUT']) def api_admin_update_hotspot(hotspot_id): session = Session() try: data = request.get_json() if not data: return jsonify({'error': 'No data provided'}), 400 exists = session.execute(text("SELECT id_dgr_hotspot FROM diagram_hotspots WHERE id_dgr_hotspot = :id"), {'id': hotspot_id}).mappings().first() if not exists: return jsonify({'error': 'Hotspot not found'}), 404 fields = [] params = {'id': hotspot_id} if 'part_id' in data: fields.append("part_id = :part_id") params['part_id'] = data['part_id'] if 'callout_number' in data: fields.append("callout_number = :callout_number") params['callout_number'] = data['callout_number'] if 'shape' in data: fields.append("id_shape = (SELECT id_shape FROM shapes WHERE name_shape = :shape)") params['shape'] = data['shape'] if 'coords' in data: fields.append("coords = :coords") params['coords'] = data['coords'] if not fields: return jsonify({'error': 'No fields to update'}), 400 session.execute(text(f"UPDATE diagram_hotspots SET {', '.join(fields)} WHERE id_dgr_hotspot = :id"), params) session.commit() return jsonify({'message': 'Hotspot updated'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/admin/hotspots/', methods=['DELETE']) def api_admin_delete_hotspot(hotspot_id): session = Session() try: exists = session.execute(text("SELECT id_dgr_hotspot FROM diagram_hotspots WHERE id_dgr_hotspot = :id"), {'id': hotspot_id}).mappings().first() if not exists: return jsonify({'error': 'Hotspot not found'}), 404 session.execute(text("DELETE FROM diagram_hotspots WHERE id_dgr_hotspot = :id"), {'id': hotspot_id}) session.commit() return jsonify({'message': 'Hotspot deleted'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() # ============================================================================ # Captura (Data Entry) Endpoints # ============================================================================ # Captura page routes removed — APIs below kept for compatibility @app.route('/api/captura/vehicles/pending') def api_captura_vehicles_pending(): session = Session() try: brand = request.args.get('brand', '') model = request.args.get('model', '') page = int(request.args.get('page', 1)) per_page = min(int(request.args.get('per_page', 50)), 100) offset = (page - 1) * per_page filters = ["mye.captura_status = 'pending'"] params = {'limit': per_page, 'offset': offset} if brand: filters.append("b.name_brand = :brand") params['brand'] = brand if model: filters.append("m.name_model ILIKE :model") params['model'] = f'%{model}%' where = ' AND '.join(filters) total = session.execute(text(f""" SELECT COUNT(*) FROM model_year_engine mye JOIN models m ON mye.model_id = m.id_model JOIN brands b ON m.brand_id = b.id_brand WHERE {where} """), params).scalar() rows = session.execute(text(f""" SELECT mye.id_mye, b.name_brand AS brand, m.name_model AS model, y.year_car AS year, e.name_engine AS engine, mye.trim_level, mye.captura_status FROM model_year_engine mye JOIN models m ON mye.model_id = m.id_model JOIN brands b ON m.brand_id = b.id_brand JOIN years y ON mye.year_id = y.id_year JOIN engines e ON mye.engine_id = e.id_engine WHERE {where} ORDER BY b.name_brand, m.name_model, y.year_car, e.name_engine LIMIT :limit OFFSET :offset """), params).mappings().all() return jsonify({'data': [dict(r) for r in rows], 'pagination': { 'page': page, 'per_page': per_page, 'total': total, 'total_pages': (total + per_page - 1) // per_page }}) finally: session.close() @app.route('/api/captura/vehicles/in-progress') def api_captura_vehicles_in_progress(): session = Session() try: page = int(request.args.get('page', 1)) per_page = min(int(request.args.get('per_page', 50)), 100) offset = (page - 1) * per_page total = session.execute(text( "SELECT COUNT(*) FROM model_year_engine WHERE captura_status = 'in_progress'" )).scalar() rows = session.execute(text(""" SELECT mye.id_mye, b.name_brand AS brand, m.name_model AS model, y.year_car AS year, e.name_engine AS engine, mye.trim_level, mye.captura_status, COUNT(vp.id_vehicle_part) AS parts_count FROM model_year_engine mye JOIN models m ON mye.model_id = m.id_model JOIN brands b ON m.brand_id = b.id_brand JOIN years y ON mye.year_id = y.id_year JOIN engines e ON mye.engine_id = e.id_engine LEFT JOIN vehicle_parts vp ON vp.model_year_engine_id = mye.id_mye WHERE mye.captura_status = 'in_progress' GROUP BY mye.id_mye, b.name_brand, m.name_model, y.year_car, e.name_engine, mye.trim_level, mye.captura_status ORDER BY b.name_brand, m.name_model, y.year_car LIMIT :limit OFFSET :offset """), {'limit': per_page, 'offset': offset}).mappings().all() return jsonify({'data': [dict(r) for r in rows], 'pagination': { 'page': page, 'per_page': per_page, 'total': total, 'total_pages': (total + per_page - 1) // per_page }}) finally: session.close() @app.route('/api/captura/vehicles//status', methods=['PUT']) def api_captura_vehicle_status(mye_id): session = Session() try: data = request.get_json() status = data.get('status') if status not in ('pending', 'in_progress', 'completed'): return jsonify({'error': 'Invalid status'}), 400 session.execute(text( "UPDATE model_year_engine SET captura_status = :status WHERE id_mye = :id" ), {'status': status, 'id': mye_id}) session.commit() return jsonify({'message': f'Status updated to {status}'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/captura/vehicles//parts') def api_captura_vehicle_parts(mye_id): session = Session() try: vehicle = session.execute(text(""" SELECT mye.id_mye, b.name_brand AS brand, m.name_model AS model, y.year_car AS year, e.name_engine AS engine, mye.trim_level FROM model_year_engine mye JOIN models m ON mye.model_id = m.id_model JOIN brands b ON m.brand_id = b.id_brand JOIN years y ON mye.year_id = y.id_year JOIN engines e ON mye.engine_id = e.id_engine WHERE mye.id_mye = :id """), {'id': mye_id}).mappings().first() if not vehicle: return jsonify({'error': 'Vehicle not found'}), 404 groups = session.execute(text(""" SELECT pc.id_part_category, pc.name_part_category AS category, pg.id_part_group, pg.name_part_group AS group_name, pc.display_order AS cat_order, pg.display_order AS grp_order FROM part_categories pc JOIN part_groups pg ON pg.category_id = pc.id_part_category ORDER BY pc.display_order, pg.display_order """)).mappings().all() existing = session.execute(text(""" SELECT vp.id_vehicle_part, vp.part_id, p.oem_part_number, p.name_part, p.name_es, p.group_id, vp.quantity_required, pp.name_position_part AS position FROM vehicle_parts vp JOIN parts p ON vp.part_id = p.id_part LEFT JOIN position_part pp ON vp.id_position_part = pp.id_position_part WHERE vp.model_year_engine_id = :id ORDER BY p.group_id, p.oem_part_number """), {'id': mye_id}).mappings().all() return jsonify({ 'vehicle': dict(vehicle), 'groups': [dict(g) for g in groups], 'parts': [dict(e) for e in existing] }) finally: session.close() @app.route('/api/captura/parts/without-aftermarket') def api_captura_parts_without_aftermarket(): session = Session() try: search = request.args.get('search', '') group_id = request.args.get('group_id', '') page = int(request.args.get('page', 1)) per_page = min(int(request.args.get('per_page', 50)), 100) offset = (page - 1) * per_page filters = ["NOT EXISTS (SELECT 1 FROM aftermarket_parts ap WHERE ap.oem_part_id = p.id_part)"] params = {'limit': per_page, 'offset': offset} if search: filters.append("(p.oem_part_number ILIKE :search OR p.name_part ILIKE :search)") params['search'] = f'%{search}%' if group_id: filters.append("p.group_id = :group_id") params['group_id'] = int(group_id) where = ' AND '.join(filters) total = session.execute(text(f"SELECT COUNT(*) FROM parts p WHERE {where}"), params).scalar() rows = session.execute(text(f""" SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es, pg.name_part_group AS group_name, pc.name_part_category AS category FROM parts p JOIN part_groups pg ON p.group_id = pg.id_part_group JOIN part_categories pc ON pg.category_id = pc.id_part_category WHERE {where} ORDER BY pc.display_order, pg.display_order, p.oem_part_number LIMIT :limit OFFSET :offset """), params).mappings().all() return jsonify({'data': [dict(r) for r in rows], 'pagination': { 'page': page, 'per_page': per_page, 'total': total, 'total_pages': (total + per_page - 1) // per_page }}) finally: session.close() @app.route('/api/captura/parts/without-image') def api_captura_parts_without_image(): session = Session() try: search = request.args.get('search', '') page = int(request.args.get('page', 1)) per_page = min(int(request.args.get('per_page', 50)), 100) offset = (page - 1) * per_page filters = ["(p.image_url IS NULL OR p.image_url = '')"] params = {'limit': per_page, 'offset': offset} if search: filters.append("(p.oem_part_number ILIKE :search OR p.name_part ILIKE :search)") params['search'] = f'%{search}%' where = ' AND '.join(filters) total = session.execute(text(f"SELECT COUNT(*) FROM parts p WHERE {where}"), params).scalar() rows = session.execute(text(f""" SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es, pg.name_part_group AS group_name, pc.name_part_category AS category FROM parts p JOIN part_groups pg ON p.group_id = pg.id_part_group JOIN part_categories pc ON pg.category_id = pc.id_part_category WHERE {where} ORDER BY p.oem_part_number LIMIT :limit OFFSET :offset """), params).mappings().all() return jsonify({'data': [dict(r) for r in rows], 'pagination': { 'page': page, 'per_page': per_page, 'total': total, 'total_pages': (total + per_page - 1) // per_page }}) finally: session.close() @app.route('/api/captura/parts//image', methods=['POST']) def api_captura_upload_part_image(part_id): session = Session() try: if 'image' not in request.files: return jsonify({'error': 'No image file provided'}), 400 file = request.files['image'] if not file.filename: return jsonify({'error': 'No file selected'}), 400 allowed = {'jpg', 'jpeg', 'png', 'webp'} ext = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else '' if ext not in allowed: return jsonify({'error': f'Tipo no permitido. Usar: {", ".join(allowed)}'}), 400 file.seek(0, 2) size = file.tell() file.seek(0) if size > 2 * 1024 * 1024: return jsonify({'error': 'Archivo muy grande (max 2MB)'}), 400 part = session.execute(text( "SELECT oem_part_number FROM parts WHERE id_part = :id" ), {'id': part_id}).mappings().first() if not part: return jsonify({'error': 'Part not found'}), 404 safe_oem = re.sub(r'[^a-zA-Z0-9_-]', '_', part['oem_part_number']) filename = f"{safe_oem}.{ext}" filepath = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static', 'parts', filename) file.save(filepath) image_url = f"/static/parts/{filename}" session.execute(text( "UPDATE parts SET image_url = :url WHERE id_part = :id" ), {'url': image_url, 'id': part_id}) session.commit() return jsonify({'message': 'Image uploaded', 'image_url': image_url}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/captura/parts/check-oem') def api_captura_check_oem(): session = Session() try: oem = request.args.get('oem', '') if not oem: return jsonify({'exists': False}) row = session.execute(text( "SELECT id_part, oem_part_number, name_part, name_es, group_id FROM parts WHERE oem_part_number = :oem" ), {'oem': oem}).mappings().first() if row: return jsonify({'exists': True, 'part': dict(row)}) return jsonify({'exists': False}) finally: session.close() @app.route('/api/captura/manufacturers') def api_captura_manufacturers(): session = Session() try: rows = session.execute(text(""" SELECT m.id_manufacture AS id, m.name_manufacture AS name, qt.name_quality AS quality FROM manufacturers m LEFT JOIN quality_tier qt ON m.id_quality_tier = qt.id_quality_tier ORDER BY m.name_manufacture """)).mappings().all() return jsonify([dict(r) for r in rows]) finally: session.close() @app.route('/api/captura/parts//aftermarket') def api_captura_part_aftermarket(part_id): session = Session() try: rows = session.execute(text(""" SELECT ap.id_aftermarket_parts AS id, ap.part_number, ap.name_aftermarket_parts AS name, m.name_manufacture AS manufacturer, qt.name_quality AS quality, ap.price_usd, ap.warranty_months FROM aftermarket_parts ap JOIN manufacturers m ON ap.manufacturer_id = m.id_manufacture LEFT JOIN quality_tier qt ON ap.id_quality_tier = qt.id_quality_tier WHERE ap.oem_part_id = :id ORDER BY m.name_manufacture """), {'id': part_id}).mappings().all() return jsonify([dict(r) for r in rows]) finally: session.close() # ============================================================================ # POS (Point of Sale) Endpoints # ============================================================================ # POS/cuentas page routes removed — served by POS app on its own port # ---- Customers ---- @app.route('/api/pos/customers') def api_pos_customers(): session = Session() try: search = request.args.get('search', '') page = int(request.args.get('page', 1)) per_page = min(int(request.args.get('per_page', 50)), 100) offset = (page - 1) * per_page filters = ["active = TRUE"] params = {'limit': per_page, 'offset': offset} if search: filters.append("(name ILIKE :search OR rfc ILIKE :search OR business_name ILIKE :search)") params['search'] = f'%{search}%' where = ' AND '.join(filters) total = session.execute(text(f"SELECT COUNT(*) FROM customers WHERE {where}"), params).scalar() rows = session.execute(text(f""" SELECT id_customer, name, rfc, business_name, phone, balance, credit_limit, payment_terms FROM customers WHERE {where} ORDER BY name LIMIT :limit OFFSET :offset """), params).mappings().all() return jsonify({'data': [dict(r) for r in rows], 'pagination': { 'page': page, 'per_page': per_page, 'total': total, 'total_pages': (total + per_page - 1) // per_page }}) finally: session.close() @app.route('/api/pos/customers/') def api_pos_customer_detail(customer_id): session = Session() try: row = session.execute(text( "SELECT * FROM customers WHERE id_customer = :id" ), {'id': customer_id}).mappings().first() if not row: return jsonify({'error': 'Cliente no encontrado'}), 404 return jsonify(dict(row)) finally: session.close() @app.route('/api/pos/customers', methods=['POST']) def api_pos_create_customer(): session = Session() try: data = request.get_json() result = session.execute(text(""" INSERT INTO customers (name, rfc, business_name, email, phone, address, credit_limit, payment_terms) VALUES (:name, :rfc, :business_name, :email, :phone, :address, :credit_limit, :payment_terms) RETURNING id_customer """), { 'name': data['name'], 'rfc': data.get('rfc'), 'business_name': data.get('business_name'), 'email': data.get('email'), 'phone': data.get('phone'), 'address': data.get('address'), 'credit_limit': data.get('credit_limit', 0), 'payment_terms': data.get('payment_terms', 30) }) new_id = result.scalar() session.commit() return jsonify({'id': new_id, 'message': 'Cliente creado'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/pos/customers/', methods=['PUT']) def api_pos_update_customer(customer_id): session = Session() try: data = request.get_json() session.execute(text(""" UPDATE customers SET name = :name, rfc = :rfc, business_name = :business_name, email = :email, phone = :phone, address = :address, credit_limit = :credit_limit, payment_terms = :payment_terms WHERE id_customer = :id """), { 'name': data['name'], 'rfc': data.get('rfc'), 'business_name': data.get('business_name'), 'email': data.get('email'), 'phone': data.get('phone'), 'address': data.get('address'), 'credit_limit': data.get('credit_limit', 0), 'payment_terms': data.get('payment_terms', 30), 'id': customer_id }) session.commit() return jsonify({'message': 'Cliente actualizado'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() # ---- Invoices ---- @app.route('/api/pos/invoices') def api_pos_invoices(): session = Session() try: customer_id = request.args.get('customer_id', '') status = request.args.get('status', '') page = int(request.args.get('page', 1)) per_page = min(int(request.args.get('per_page', 50)), 100) offset = (page - 1) * per_page filters = ["1=1"] params = {'limit': per_page, 'offset': offset} if customer_id: filters.append("i.customer_id = :customer_id") params['customer_id'] = int(customer_id) if status: filters.append("i.status = :status") params['status'] = status where = ' AND '.join(filters) total = session.execute(text(f""" SELECT COUNT(*) FROM invoices i WHERE {where} """), params).scalar() rows = session.execute(text(f""" SELECT i.id_invoice, i.folio, i.date_issued, i.subtotal, i.tax_amount, i.total, i.amount_paid, i.status, c.name AS customer_name, c.rfc FROM invoices i JOIN customers c ON i.customer_id = c.id_customer WHERE {where} ORDER BY i.date_issued DESC LIMIT :limit OFFSET :offset """), params).mappings().all() return jsonify({'data': [dict(r) for r in rows], 'pagination': { 'page': page, 'per_page': per_page, 'total': total, 'total_pages': (total + per_page - 1) // per_page }}) finally: session.close() @app.route('/api/pos/invoices/') def api_pos_invoice_detail(invoice_id): session = Session() try: inv = session.execute(text(""" SELECT i.*, c.name AS customer_name, c.rfc, c.business_name, c.address FROM invoices i JOIN customers c ON i.customer_id = c.id_customer WHERE i.id_invoice = :id """), {'id': invoice_id}).mappings().first() if not inv: return jsonify({'error': 'Factura no encontrada'}), 404 items = session.execute(text(""" SELECT ii.*, p.oem_part_number, ap.part_number AS aftermarket_number FROM invoice_items ii LEFT JOIN parts p ON ii.part_id = p.id_part LEFT JOIN aftermarket_parts ap ON ii.aftermarket_id = ap.id_aftermarket_parts WHERE ii.invoice_id = :id ORDER BY ii.id_invoice_item """), {'id': invoice_id}).mappings().all() return jsonify({'invoice': dict(inv), 'items': [dict(it) for it in items]}) finally: session.close() @app.route('/api/pos/invoices', methods=['POST']) def api_pos_create_invoice(): session = Session() try: data = request.get_json() customer_id = data['customer_id'] items = data['items'] tax_rate = data.get('tax_rate', 0.16) notes = data.get('notes', '') if not items: return jsonify({'error': 'La factura debe tener al menos una linea'}), 400 folio_num = session.execute(text("SELECT nextval('invoice_folio_seq')")).scalar() folio = f"NX-{folio_num:06d}" subtotal = sum(it['quantity'] * it['unit_price'] for it in items) tax_amount = round(subtotal * tax_rate, 2) total = round(subtotal + tax_amount, 2) result = session.execute(text(""" INSERT INTO invoices (customer_id, folio, subtotal, tax_rate, tax_amount, total, notes) VALUES (:customer_id, :folio, :subtotal, :tax_rate, :tax_amount, :total, :notes) RETURNING id_invoice """), { 'customer_id': customer_id, 'folio': folio, 'subtotal': subtotal, 'tax_rate': tax_rate, 'tax_amount': tax_amount, 'total': total, 'notes': notes }) invoice_id = result.scalar() for it in items: line_total = it['quantity'] * it['unit_price'] session.execute(text(""" INSERT INTO invoice_items (invoice_id, part_id, aftermarket_id, description, quantity, unit_cost, margin_pct, unit_price, line_total) VALUES (:inv_id, :part_id, :af_id, :desc, :qty, :cost, :margin, :price, :total) """), { 'inv_id': invoice_id, 'part_id': it.get('part_id'), 'af_id': it.get('aftermarket_id'), 'desc': it['description'], 'qty': it['quantity'], 'cost': it.get('unit_cost', 0), 'margin': it.get('margin_pct', 30), 'price': it['unit_price'], 'total': line_total }) session.execute(text( "UPDATE customers SET balance = balance + :total WHERE id_customer = :id" ), {'total': total, 'id': customer_id}) session.commit() return jsonify({'id': invoice_id, 'folio': folio, 'total': total, 'message': 'Factura creada'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/pos/invoices//cancel', methods=['PUT']) def api_pos_cancel_invoice(invoice_id): session = Session() try: inv = session.execute(text( "SELECT total, customer_id, status FROM invoices WHERE id_invoice = :id" ), {'id': invoice_id}).mappings().first() if not inv: return jsonify({'error': 'Factura no encontrada'}), 404 if inv['status'] == 'cancelled': return jsonify({'error': 'La factura ya esta cancelada'}), 400 session.execute(text( "UPDATE invoices SET status = 'cancelled' WHERE id_invoice = :id" ), {'id': invoice_id}) session.execute(text( "UPDATE customers SET balance = balance - :total WHERE id_customer = :cid" ), {'total': inv['total'], 'cid': inv['customer_id']}) session.commit() return jsonify({'message': 'Factura cancelada'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() # ---- Payments ---- @app.route('/api/pos/payments', methods=['POST']) def api_pos_create_payment(): session = Session() try: data = request.get_json() customer_id = data['customer_id'] amount = float(data['amount']) payment_method = data.get('payment_method', 'efectivo') reference = data.get('reference') invoice_id = data.get('invoice_id') notes = data.get('notes') if amount <= 0: return jsonify({'error': 'El monto debe ser mayor a 0'}), 400 result = session.execute(text(""" INSERT INTO payments (customer_id, invoice_id, amount, payment_method, reference, notes) VALUES (:cid, :inv_id, :amount, :method, :ref, :notes) RETURNING id_payment """), { 'cid': customer_id, 'inv_id': invoice_id, 'amount': amount, 'method': payment_method, 'ref': reference, 'notes': notes }) payment_id = result.scalar() session.execute(text( "UPDATE customers SET balance = balance - :amount WHERE id_customer = :id" ), {'amount': amount, 'id': customer_id}) if invoice_id: session.execute(text( "UPDATE invoices SET amount_paid = amount_paid + :amount WHERE id_invoice = :id" ), {'amount': amount, 'id': invoice_id}) session.execute(text(""" UPDATE invoices SET status = CASE WHEN amount_paid >= total THEN 'paid' WHEN amount_paid > 0 THEN 'partial' ELSE 'pending' END WHERE id_invoice = :id """), {'id': invoice_id}) session.commit() return jsonify({'id': payment_id, 'message': 'Pago registrado'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/pos/customers//statement') def api_pos_customer_statement(customer_id): session = Session() try: customer = session.execute(text( "SELECT * FROM customers WHERE id_customer = :id" ), {'id': customer_id}).mappings().first() if not customer: return jsonify({'error': 'Cliente no encontrado'}), 404 invoices = session.execute(text(""" SELECT id_invoice, folio, date_issued, total, amount_paid, status FROM invoices WHERE customer_id = :id AND status != 'cancelled' ORDER BY date_issued DESC LIMIT 100 """), {'id': customer_id}).mappings().all() payments = session.execute(text(""" SELECT p.id_payment, p.amount, p.payment_method, p.reference, p.date_payment, p.notes, i.folio AS invoice_folio FROM payments p LEFT JOIN invoices i ON p.invoice_id = i.id_invoice WHERE p.customer_id = :id ORDER BY p.date_payment DESC LIMIT 100 """), {'id': customer_id}).mappings().all() return jsonify({ 'customer': dict(customer), 'invoices': [dict(i) for i in invoices], 'payments': [dict(p) for p in payments] }) finally: session.close() @app.route('/api/pos/search-parts') def api_pos_search_parts(): """Búsqueda de partes para la UI del POS (catálogo vivo, nunca 500).""" q = request.args.get('q', '') if len(q) < 2: return jsonify([]) return jsonify([{ 'id_part': p['id_part'], 'oem_part_number': p['oem_part_number'], 'name_part': p['name'], 'name_es': p['name'], 'cost_usd': None, 'group_name': p['category_name'], 'part_type': 'supplier_catalog', } for p in _search_catalog_live(q, limit=40)]) # ============================================================================ # Store Dashboard Endpoints # ============================================================================ # Old page routes removed (demo, bodega, login, tienda) # APIs below are kept for backward compatibility @app.route('/pitch') def pitch_deck(): return send_from_directory(os.path.join(_base, '..', 'pitch'), 'deck.html') @app.route('/api/tienda/stats') def api_tienda_stats(): session = Session() try: today = "date_issued::date = CURRENT_DATE" month = "date_issued >= date_trunc('month', CURRENT_DATE)" sales_today = session.execute(text(f""" SELECT COALESCE(SUM(total), 0), COUNT(*) FROM invoices WHERE {today} AND status != 'cancelled' """)).fetchone() sales_month = session.execute(text(f""" SELECT COALESCE(SUM(total), 0), COUNT(*) FROM invoices WHERE {month} AND status != 'cancelled' """)).fetchone() payments_today = session.execute(text(f""" SELECT COALESCE(SUM(amount), 0), COUNT(*) FROM payments WHERE date_payment::date = CURRENT_DATE """)).fetchone() pending_balance = session.execute(text( "SELECT COALESCE(SUM(balance), 0) FROM customers WHERE active = TRUE AND balance > 0" )).scalar() pending_invoices = session.execute(text( "SELECT COUNT(*) FROM invoices WHERE status IN ('pending', 'partial')" )).scalar() total_customers = session.execute(text( "SELECT COUNT(*) FROM customers WHERE active = TRUE" )).scalar() total_parts = session.execute(text("SELECT COUNT(*) FROM supplier_catalog")).scalar() total_aftermarket = session.execute(text("SELECT COUNT(*) FROM supplier_catalog_compat")).scalar() recent_invoices = session.execute(text(""" SELECT i.folio, i.total, i.status, i.date_issued, c.name AS customer_name FROM invoices i JOIN customers c ON i.customer_id = c.id_customer ORDER BY i.date_issued DESC LIMIT 8 """)).mappings().all() top_debtors = session.execute(text(""" SELECT id_customer, name, balance, credit_limit FROM customers WHERE active = TRUE AND balance > 0 ORDER BY balance DESC LIMIT 6 """)).mappings().all() return jsonify({ 'sales_today': {'total': float(sales_today[0]), 'count': int(sales_today[1])}, 'sales_month': {'total': float(sales_month[0]), 'count': int(sales_month[1])}, 'payments_today': {'total': float(payments_today[0]), 'count': int(payments_today[1])}, 'pending_balance': float(pending_balance), 'pending_invoices': pending_invoices, 'total_customers': total_customers, 'total_parts': total_parts, 'total_aftermarket': total_aftermarket, 'recent_invoices': [dict(r) for r in recent_invoices], 'top_debtors': [dict(r) for r in top_debtors] }) finally: session.close() # ============================================================================ # Auth Endpoints # ============================================================================ @app.route('/api/auth/register', methods=['POST']) def auth_register(): """Register a new user (TALLER or BODEGA). Account starts inactive.""" data = request.get_json() if not data: return jsonify({'error': 'Invalid JSON'}), 400 required = ['name', 'email', 'password', 'role', 'business_name'] for field in required: if not data.get(field): return jsonify({'error': f'Missing required field: {field}'}), 400 role = data['role'].upper() if role not in ('TALLER', 'BODEGA'): return jsonify({'error': 'Role must be TALLER or BODEGA'}), 400 role_map = {'TALLER': 3, 'BODEGA': 4} id_rol = role_map[role] hashed = hash_password(data['password']) session = Session() try: session.execute(text( """INSERT INTO users (name_user, email, pass, id_rol, business_name, phone, address, is_active, created_at) VALUES (:name, :email, :pass, :id_rol, :biz, :phone, :addr, false, NOW())""" ), { 'name': data['name'], 'email': data['email'], 'pass': hashed, 'id_rol': id_rol, 'biz': data['business_name'], 'phone': data.get('phone', ''), 'addr': data.get('address', '') }) session.commit() return jsonify({'message': 'Registration successful. Account pending activation.'}), 201 except IntegrityError: session.rollback() return jsonify({'error': 'Email already registered'}), 409 finally: session.close() @app.route('/api/auth/login', methods=['POST']) def auth_login(): """Authenticate user and return access + refresh tokens.""" data = request.get_json() if not data or not data.get('email') or not data.get('password'): return jsonify({'error': 'Email and password are required'}), 400 session = Session() try: row = session.execute(text( """SELECT u.id_user, u.name_user, u.email, u.pass, u.is_active, u.business_name, r.name_rol FROM users u JOIN roles r ON r.id_rol = u.id_rol WHERE u.email = :email""" ), {'email': data['email']}).mappings().first() if not row: return jsonify({'error': 'Invalid email or password'}), 401 if not check_password(data['password'], row['pass']): return jsonify({'error': 'Invalid email or password'}), 401 if not row['is_active']: return jsonify({'error': 'Account is not active. Contact an administrator.'}), 403 # Update last_login session.execute(text( "UPDATE users SET last_login = NOW() WHERE id_user = :uid" ), {'uid': row['id_user']}) session.commit() access_token = create_access_token(row['id_user'], row['name_rol'], row['business_name']) refresh_token = create_refresh_token(row['id_user']) return jsonify({ 'access_token': access_token, 'refresh_token': refresh_token, 'user': { 'id': row['id_user'], 'name': row['name_user'], 'role': row['name_rol'], 'business_name': row['business_name'] } }) finally: session.close() @app.route('/api/auth/refresh', methods=['POST']) def auth_refresh(): """Exchange a valid refresh token for a new access token.""" data = request.get_json() if not data or not data.get('refresh_token'): return jsonify({'error': 'refresh_token is required'}), 400 session = Session() try: row = session.execute(text( """SELECT s.user_id, s.expires_at, u.business_name, r.name_rol FROM sessions s JOIN users u ON u.id_user = s.user_id JOIN roles r ON r.id_rol = u.id_rol WHERE s.refresh_token = :token""" ), {'token': data['refresh_token']}).mappings().first() if not row: return jsonify({'error': 'Invalid refresh token'}), 401 if row['expires_at'] < datetime.utcnow(): # Clean up expired token session.execute(text( "DELETE FROM sessions WHERE refresh_token = :token" ), {'token': data['refresh_token']}) session.commit() return jsonify({'error': 'Refresh token expired'}), 401 access_token = create_access_token(row['user_id'], row['name_rol'], row['business_name']) return jsonify({'access_token': access_token}) finally: session.close() @app.route('/api/auth/me', methods=['GET']) @require_auth() def auth_me(): """Return the current authenticated user's info from the JWT payload.""" return jsonify(g.user) # ============================================================================ # Task 5: Admin User Management # ============================================================================ import csv import io import math @app.route('/api/admin/users', methods=['GET']) @require_auth('ADMIN', 'OWNER') def admin_list_users(): """Return list of all users with role info.""" session = Session() try: rows = session.execute(text( """SELECT u.id_user, u.name_user, u.email, u.business_name, u.phone, u.is_active, u.created_at, u.last_login, r.name_rol FROM users u JOIN roles r ON r.id_rol = u.id_rol ORDER BY u.created_at DESC""" )).mappings().all() users = [] for r in rows: users.append({ 'id': r['id_user'], 'name': r['name_user'], 'email': r['email'], 'business_name': r['business_name'], 'phone': r['phone'], 'is_active': r['is_active'], 'created_at': r['created_at'].isoformat() if r['created_at'] else None, 'last_login': r['last_login'].isoformat() if r['last_login'] else None, 'role': r['name_rol'] }) return jsonify(users) finally: session.close() @app.route('/api/admin/users//activate', methods=['PUT']) @require_auth('ADMIN', 'OWNER') def admin_activate_user(user_id): """Activate or deactivate a user.""" data = request.get_json() if data is None or 'is_active' not in data: return jsonify({'error': 'is_active field is required'}), 400 session = Session() try: result = session.execute(text( "UPDATE users SET is_active = :active WHERE id_user = :uid" ), {'active': bool(data['is_active']), 'uid': user_id}) session.commit() if result.rowcount == 0: return jsonify({'error': 'User not found'}), 404 return jsonify({'message': 'User updated', 'is_active': bool(data['is_active'])}) finally: session.close() # ============================================================================ # Task 6: Inventory Endpoints (BODEGA) # ============================================================================ @app.route('/api/inventory/mapping', methods=['GET']) @require_auth('BODEGA', 'ADMIN') def inventory_get_mapping(): """Return column mapping for current user.""" session = Session() try: row = session.execute(text( "SELECT mapping FROM inventory_column_mappings WHERE user_id = :uid" ), {'uid': g.user['user_id']}).mappings().first() return jsonify({'mapping': row['mapping'] if row else {}}) finally: session.close() @app.route('/api/inventory/mapping', methods=['PUT']) @require_auth('BODEGA', 'ADMIN') def inventory_put_mapping(): """Upsert column mapping for current user.""" data = request.get_json() if not data or 'mapping' not in data: return jsonify({'error': 'mapping is required'}), 400 mapping = data['mapping'] required_keys = ['part_number', 'price', 'stock'] missing = [k for k in required_keys if k not in mapping or not mapping[k]] if missing: return jsonify({'error': f'Missing required mapping keys: {", ".join(missing)}'}), 400 session = Session() try: session.execute(text( """INSERT INTO inventory_column_mappings (user_id, mapping) VALUES (:uid, :mapping) ON CONFLICT (user_id) DO UPDATE SET mapping = :mapping""" ), {'uid': g.user['user_id'], 'mapping': json_module.dumps(mapping)}) session.commit() return jsonify({'message': 'Mapping saved', 'mapping': mapping}) finally: session.close() @app.route('/api/inventory/upload', methods=['POST']) @require_auth('BODEGA', 'ADMIN') def inventory_upload(): """Upload inventory file (CSV or Excel), apply column mapping, upsert into warehouse_inventory.""" if 'file' not in request.files: return jsonify({'error': 'No file provided'}), 400 file = request.files['file'] if not file.filename: return jsonify({'error': 'Empty filename'}), 400 session = Session() try: # 1. Get mapping row = session.execute(text( "SELECT mapping FROM inventory_column_mappings WHERE user_id = :uid" ), {'uid': g.user['user_id']}).mappings().first() if not row or not row['mapping']: return jsonify({'error': 'No column mapping configured. Set mapping first.'}), 400 mapping = row['mapping'] if isinstance(row['mapping'], dict) else json_module.loads(row['mapping']) # 2. Create upload record upload = session.execute(text( """INSERT INTO inventory_uploads (user_id, filename, status) VALUES (:uid, :fname, 'processing') RETURNING id_upload""" ), {'uid': g.user['user_id'], 'fname': file.filename}).mappings().first() session.commit() upload_id = upload['id_upload'] # 3. Parse file filename_lower = file.filename.lower() rows_data = [] if filename_lower.endswith(('.xlsx', '.xls')): import openpyxl wb = openpyxl.load_workbook(io.BytesIO(file.read()), read_only=True, data_only=True) ws = wb.active headers = None for row_cells in ws.iter_rows(values_only=True): if headers is None: headers = [str(c).strip() if c else '' for c in row_cells] continue row_dict = {} for i, val in enumerate(row_cells): if i < len(headers): row_dict[headers[i]] = val rows_data.append(row_dict) wb.close() else: # CSV content = file.read().decode('utf-8-sig', errors='replace') reader = csv.DictReader(io.StringIO(content)) for row_dict in reader: rows_data.append(row_dict) # 4. Process rows imported = 0 errors = 0 error_samples = [] def clean_price(val): if val is None: return None s = str(val).replace('$', '').replace(',', '').strip() try: return float(s) except (ValueError, TypeError): return None def clean_stock(val): if val is None: return 0 s = str(val).replace(',', '').strip() try: return int(float(s)) except (ValueError, TypeError): return 0 for i, row_dict in enumerate(rows_data): try: part_number_col = mapping.get('part_number', '') price_col = mapping.get('price', '') stock_col = mapping.get('stock', '') location_col = mapping.get('location', '') part_number = str(row_dict.get(part_number_col, '')).strip() if not part_number: errors += 1 if len(error_samples) < 10: error_samples.append({'row': i + 2, 'error': 'Empty part number'}) continue price = clean_price(row_dict.get(price_col)) stock = clean_stock(row_dict.get(stock_col)) location = str(row_dict.get(location_col, 'Principal')).strip() if location_col else 'Principal' if not location: location = 'Principal' # Find part by OEM part number part_row = session.execute(text( "SELECT id_part FROM parts WHERE oem_part_number = :pn LIMIT 1" ), {'pn': part_number}).mappings().first() # Also try aftermarket_parts.part_number if OEM not found if not part_row: am_row = session.execute(text( "SELECT oem_part_id FROM aftermarket_parts WHERE part_number = :pn LIMIT 1" ), {'pn': part_number}).mappings().first() if am_row: part_row = {'id_part': am_row['oem_part_id']} if not part_row: errors += 1 if len(error_samples) < 10: error_samples.append({'row': i + 2, 'error': f'Part not found: {part_number}'}) continue # UPSERT into warehouse_inventory session.execute(text( """INSERT INTO warehouse_inventory (user_id, part_id, price, stock_quantity, warehouse_location, updated_at) VALUES (:uid, :pid, :price, :stock, :loc, NOW()) ON CONFLICT (user_id, part_id, warehouse_location) DO UPDATE SET price = :price, stock_quantity = :stock, updated_at = NOW()""" ), { 'uid': g.user['user_id'], 'pid': part_row['id_part'], 'price': price, 'stock': stock, 'loc': location }) imported += 1 except Exception as e: errors += 1 if len(error_samples) < 10: error_samples.append({'row': i + 2, 'error': str(e)}) # 5. Update upload record session.execute(text( """UPDATE inventory_uploads SET status = 'completed', rows_total = :total, rows_imported = :imported, rows_errors = :errors, error_log = :elog, completed_at = NOW() WHERE id_upload = :uid""" ), { 'total': len(rows_data), 'imported': imported, 'errors': errors, 'elog': json_module.dumps(error_samples) if error_samples else None, 'uid': upload_id }) session.commit() return jsonify({ 'message': 'Upload processed', 'upload_id': upload_id, 'imported': imported, 'errors': errors, 'error_samples': error_samples }) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/inventory/uploads', methods=['GET']) @require_auth('BODEGA', 'ADMIN') def inventory_list_uploads(): """Return last 50 uploads for current user.""" session = Session() try: rows = session.execute(text( """SELECT id_upload, filename, status, rows_total, rows_imported, rows_errors, created_at, completed_at FROM inventory_uploads WHERE user_id = :uid ORDER BY created_at DESC LIMIT 50""" ), {'uid': g.user['user_id']}).mappings().all() uploads = [] for r in rows: uploads.append({ 'id': r['id_upload'], 'filename': r['filename'], 'status': r['status'], 'rows_total': r['rows_total'], 'rows_imported': r['rows_imported'], 'rows_errors': r['rows_errors'], 'created_at': r['created_at'].isoformat() if r['created_at'] else None, 'completed_at': r['completed_at'].isoformat() if r['completed_at'] else None }) return jsonify(uploads) finally: session.close() @app.route('/api/inventory/items', methods=['GET']) @require_auth('BODEGA', 'ADMIN') def inventory_list_items(): """Paginated list of warehouse_inventory for current user.""" page = max(1, request.args.get('page', 1, type=int)) per_page = min(200, max(1, request.args.get('per_page', 50, type=int))) q = request.args.get('q', '').strip() session = Session() try: params = {'uid': g.user['user_id'], 'offset': (page - 1) * per_page, 'limit': per_page} where_clause = "WHERE wi.user_id = :uid" if q: where_clause += " AND (p.oem_part_number ILIKE :q OR p.name_part ILIKE :q)" params['q'] = f'%{q}%' count_row = session.execute(text( f"""SELECT COUNT(*) AS cnt FROM warehouse_inventory wi JOIN parts p ON p.id_part = wi.part_id {where_clause}""" ), params).mappings().first() total = count_row['cnt'] rows = session.execute(text( f"""SELECT wi.id_inventory, wi.part_id, p.oem_part_number, p.name_part, wi.price, wi.stock_quantity, wi.warehouse_location, wi.updated_at FROM warehouse_inventory wi JOIN parts p ON p.id_part = wi.part_id {where_clause} ORDER BY wi.updated_at DESC LIMIT :limit OFFSET :offset""" ), params).mappings().all() data = [] for r in rows: data.append({ 'id': r['id_inventory'], 'part_id': r['part_id'], 'oem_part_number': r['oem_part_number'], 'name': r['name_part'], 'price': float(r['price']) if r['price'] else None, 'stock': r['stock_quantity'], 'location': r['warehouse_location'], 'updated_at': r['updated_at'].isoformat() if r['updated_at'] else None }) total_pages = math.ceil(total / per_page) if total > 0 else 1 return jsonify({ 'data': data, 'pagination': { 'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages } }) finally: session.close() @app.route('/api/inventory/items', methods=['DELETE']) @require_auth('BODEGA', 'ADMIN') def inventory_delete_all(): """Delete all warehouse_inventory for current user.""" session = Session() try: result = session.execute(text( "DELETE FROM warehouse_inventory WHERE user_id = :uid" ), {'uid': g.user['user_id']}) session.commit() return jsonify({'message': 'Inventory cleared', 'deleted': result.rowcount}) finally: session.close() # ============================================================================ # Task 7: Part Availability & Aftermarket # ============================================================================ @app.route('/api/parts//availability', methods=['GET']) @require_auth('TALLER', 'ADMIN', 'OWNER') def part_availability(part_id): """Return all bodegas that have this part in stock.""" session = Session() try: rows = session.execute(text( """SELECT u.business_name, wi.price, wi.stock_quantity, wi.warehouse_location, wi.updated_at FROM warehouse_inventory wi JOIN users u ON u.id_user = wi.user_id WHERE wi.part_id = :pid AND wi.stock_quantity > 0 AND u.is_active = true ORDER BY wi.price ASC""" ), {'pid': part_id}).mappings().all() data = [] for r in rows: data.append({ 'bodega': r['business_name'], 'price': float(r['price']) if r['price'] else None, 'stock': r['stock_quantity'], 'location': r['warehouse_location'], 'updated_at': r['updated_at'].isoformat() if r['updated_at'] else None }) return jsonify(data) finally: session.close() @app.route('/api/parts//aftermarket', methods=['GET']) def part_aftermarket(part_id): """Return aftermarket alternatives and cross-references for a part (public).""" # Alternativas + cross-refs sobre el catálogo vivo (tablas TecDoc retiradas). try: from tenant_db import get_master_conn from services import catalog_service mconn = get_master_conn() try: results = catalog_service.get_alternatives(mconn, catalog_id=part_id, limit=50) finally: mconn.close() except Exception: results = [] alternatives = [] cross_refs = [] for r in results: alternatives.append({ 'id': r.get('catalog_id') or r.get('id'), 'part_number': r.get('part_number'), 'name': r.get('name'), 'manufacturer': r.get('supplier_name'), 'quality_tier': r.get('type'), 'price': None, 'similarity': r.get('similarity'), 'source': r.get('type') or 'catalog', }) if r.get('part_number'): cross_refs.append({ 'cross_reference_number': r.get('part_number'), 'source': r.get('type'), 'notes': r.get('name'), }) return jsonify({'data': alternatives, 'cross_references': cross_refs}) # ============================================================================ # Tenant Module Config Endpoints # ============================================================================ MODULE_CONFIG_KEYS = [ 'whatsapp_enabled', 'marketplace_enabled', 'meli_enabled', 'yonke_enabled', ] @app.route('/api/admin/tenants') @require_auth('ADMIN') def api_admin_tenants(): session = Session() try: rows = session.execute(text( "SELECT id, name, db_name, is_active, is_seller FROM tenants WHERE is_active = true ORDER BY id" )).mappings().all() return jsonify({'tenants': [dict(r) for r in rows]}) except Exception as e: return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/admin/tenants//modules') @require_auth('ADMIN') def api_admin_tenant_modules(tenant_id): try: conn = get_tenant_conn(tenant_id) cur = conn.cursor() result = {} for key in MODULE_CONFIG_KEYS: mod_key = 'module_' + key.replace('_enabled', '') cur.execute( "SELECT key, value FROM tenant_config WHERE key IN (%s, %s)", (key, mod_key), ) vals = {r[0]: r[1] for r in cur.fetchall()} v = vals.get(key) if v is None: v = vals.get(mod_key) result[key] = (v or '').lower() == 'true' if v is not None else False cur.close() conn.close() return jsonify(result) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/admin/tenants//modules', methods=['PUT']) @require_auth('ADMIN') def api_admin_tenant_modules_update(tenant_id): data = request.get_json() or {} if not data: return jsonify({'error': 'No data provided'}), 400 try: conn = get_tenant_conn(tenant_id) cur = conn.cursor() for key, value in data.items(): if key not in MODULE_CONFIG_KEYS: continue val = 'true' if value else 'false' # Doble escritura 2026-10-11: 'X_enabled' (dashboard) + 'module_X' (POS). mod_key = 'module_' + key.replace('_enabled', '') for k in (key, mod_key): cur.execute( """ INSERT INTO tenant_config (key, value, updated_at) VALUES (%s, %s, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() """, (k, val), ) conn.commit() cur.close() conn.close() return jsonify({'ok': True}) except Exception as e: return jsonify({'error': str(e)}), 500 # ============================================================================ # Static files from dashboard root (CSS/JS/HTML) # ============================================================================ # ═══════════════════════════════════════════════════════════════════════════ # SEO: páginas de parte server-side, sitemap y robots # ═══════════════════════════════════════════════════════════════════════════ from flask import render_template_string import re as _re_seo import time as _time_seo _PART_PAGE_TEMPLATE = """ {{ part.name | replace("_", " ") }} {{ part.sku }} — Refacción y disponibilidad | Nexus Autoparts
NEXUS AUTOPARTS

{{ part.name | replace("_", " ") }}

OEM / SKU: {{ part.sku }}
{% if part.category %}

Categoría: {{ part.category }}

{% endif %} {% if part.description %}

{{ part.description }}

{% endif %} {% if part.image_url %}{{ part.name }}{% endif %}

Disponibilidad en la red ({{ avail_count }} bodegas)

{% if avail_count %} {% for b in bodegas %}{% endfor %}
BodegaStockUbicación
{{ b.name }}{{ b.stock }}{{ b.location or '—' }}
{% else %}

Consultar disponibilidad con tu refaccionaria Nexus más cercana.

{% endif %}

← Volver al catálogo

""" _seo_cache = {'parts': None, 'ts': 0} def _norm_sku_seo(s): return _re_seo.sub(r'[\s\-\./_]', '', str(s or '')).upper() def _seo_availability(sku): """Availability across active tenant DBs for a normalized SKU (best effort).""" results = [] # Nota: el acceso a otras BD de tenant se hace con psycopg2 (no con # SQLAlchemy): abrir una Session por tenant y no cerrarla agotaba el pool. n = _norm_sku_seo(sku) try: import psycopg2 as _pg conn = _pg.connect(dbname='nexus_autoparts') cur = conn.cursor() cur.execute("SELECT db_name FROM tenants WHERE is_active = true") dbs = [r[0] for r in cur.fetchall()] conn.close() for db in dbs[:10]: c2 = None try: c2 = _pg.connect(dbname=db) cu2 = c2.cursor() cu2.execute(""" SELECT 'Principal' || '', COALESCE(SUM(stock), 0) FROM inventory_stock WHERE inventory_id IN ( SELECT id FROM inventory WHERE is_active AND ( REPLACE(REPLACE(REPLACE(REPLACE(UPPER(part_number),' ',''),'-',''),'.',''),'/','') = %s)) """, (n,)) row = cu2.fetchone() cu2.close() if row and int(row[1] or 0) > 0: results.append({'name': db.replace('tenant_', '').replace('_', ' ').title(), 'stock': int(row[1]), 'location': ''}) except Exception: continue finally: if c2 is not None: try: c2.close() except Exception: pass except Exception: pass return results @app.route('/parte/') def seo_part_page(catalog_id): """Server-rendered product page for SEO (schema.org Product + WhatsApp CTA).""" session = Session() try: row = session.execute(text(""" SELECT id, sku, name, category, description, image_url, supplier_name FROM supplier_catalog WHERE id = :cid AND is_active """), {'cid': catalog_id}).mappings().first() finally: session.close() if not row: return abort(404) part = { 'id': row['id'], 'sku': row['sku'], 'name': row['name'], 'category': row['category'], 'description': row['description'], 'image_url': row['image_url'], 'supplier_name': row['supplier_name'], } bodegas = _seo_availability(row['sku']) wa = (os.environ.get('PUBLIC_WA_NUMBER') or '').replace(' ', '').replace('+', '') wa_text = '' if wa: msg = f"Hola, me interesa: {part['name']} (SKU {part['sku']})" wa_text = msg.replace(' ', '%20').replace('\n', '%0A') html = render_template_string( _PART_PAGE_TEMPLATE, part=part, bodegas=bodegas, avail_count=len(bodegas), availability='https://schema.org/InStock' if bodegas else 'https://schema.org/PreOrder', wa_number=wa, wa_text=wa_text) resp = Flask.response_class(html, mimetype='text/html') resp.headers['Cache-Control'] = 'public, max-age=300' return resp @app.route('/robots.txt') def seo_robots(): from flask import Response body = "User-agent: *\nAllow: /\nSitemap: https://nexusautoparts.com.mx/sitemap.xml\n" return Response(body, mimetype='text/plain') @app.route('/sitemap.xml') def seo_sitemap(): """Sitemap con catálogo OEM + vitrinas de usados (cacheado 1h, máx 10k URLs).""" now = _time_seo.time() if _seo_cache['parts'] is None or now - _seo_cache['ts'] > 3600: urls = [ {'loc': PUBLIC_BASE_URL + '/', 'lastmod': None}, {'loc': PUBLIC_BASE_URL + '/catalog', 'lastmod': None}, {'loc': PUBLIC_BASE_URL + '/usados', 'lastmod': None}, ] try: session = Session() rows = session.execute(text( "SELECT id FROM supplier_catalog WHERE is_active ORDER BY id LIMIT 10000" )).fetchall() session.close() urls += [{'loc': f"{PUBLIC_BASE_URL}/parte/{r[0]}", 'lastmod': None} for r in rows] except Exception: pass with contextlib.suppress(Exception): urls += _used_sitemap_urls() _seo_cache['parts'] = urls _seo_cache['ts'] = now body = ['', ''] for u in _seo_cache['parts']: loc = u['loc'] if isinstance(u, dict) else u lastmod = u.get('lastmod') if isinstance(u, dict) else None if lastmod: body.append(f"{loc}{lastmod}") else: body.append(f"{loc}") body.append('') from flask import Response return Response('\n'.join(body), mimetype='application/xml') # ═══════════════════════════════════════════════════════════════════════════ # Vitrina pública de partes usadas (Yonke) # GET /api/public/used-parts — JSON multi-tenant (subdominio o ?tenant=) # GET /usado/ — vitrina server-rendered + filtros # GET /usado// — detalle con schema.org Product # GET /usado//marca/ — piezas por vehículo donador # GET /usados — directorio de tiendas con usados # Degradación elegante: tenant inexistente, esquema ausente o fallo de BD # responden 404/lista vacía — nunca un 500. # ═══════════════════════════════════════════════════════════════════════════ PUBLIC_BASE_URL = os.environ.get('PUBLIC_BASE_URL', 'https://nexusautoparts.com.mx').rstrip('/') _RESERVED_SUBDOMAINS = {'www', 'api', 'admin', 'mail', 'staging', 'dev', 'nexus', 'pos', 'app', 'dashboard'} _USED_GRADES = ('A', 'B', 'C', 'D') _used_dir_cache = {'data': None, 'ts': 0} _public_tenant_cache = {} def _used_close(conn): """Cierra una conexión de tenant sin propagar errores (degradación).""" if conn is not None: with contextlib.suppress(Exception): conn.close() def _used_donor_label(donor): label = ' '.join(x for x in (donor.get('make'), donor.get('model')) if x) if donor.get('year'): label = f"{label} {donor['year']}".strip() return label def _used_item(row): """Mapea una fila de la consulta de usados a un dict listo para plantilla.""" price = row[13] if (row[13] is not None and float(row[13]) > 0) else row[14] price = float(price or 0) stock = int(row[16] or 0) donor = {'make': row[8] or '', 'model': row[9] or '', 'year': row[10], 'mileage_km': row[11], 'vin': row[12] or ''} condition = row[5] or 'usado' return { 'id': row[0], 'part_number': row[1] or '', 'name': row[2] or '', 'description': row[3] or '', 'brand': row[4] or '', 'condition': condition, 'condition_label': 'Reconstruido' if condition == 'reconstruido' else 'Usado', 'condition_fem': 'Reconstruida' if condition == 'reconstruido' else 'Usada', 'grade': (row[6] or '').strip().upper(), 'is_unique_piece': bool(row[7]), 'donor': donor, 'donor_label': _used_donor_label(donor), 'price': price, 'price_label': f"${price:,.0f} MXN" if price > 0 else 'Precio a consultar', 'stock': stock, 'in_stock': stock > 0, 'stock_label': f"{stock} disponible{'s' if stock != 1 else ''}", 'image_url': (row[17] or row[15] or '').strip(), } _USED_SELECT = """ SELECT i.id, i.part_number, i.name, i.description, i.brand, i.condition, i.condition_grade, i.is_unique_piece, i.donor_make, i.donor_model, i.donor_year, i.donor_mileage_km, i.donor_vin, i.price_1, i.retail_price, i.image_url, CASE WHEN s.inventory_id IS NULL AND i.is_unique_piece THEN 1 ELSE COALESCE(s.stock, 0) END AS stock, img.url AS image_primary FROM inventory i LEFT JOIN ( SELECT inventory_id, SUM(stock) AS stock FROM inventory_stock GROUP BY inventory_id ) s ON s.inventory_id = i.id LEFT JOIN LATERAL ( SELECT url FROM inventory_images WHERE inventory_id = i.id ORDER BY is_primary DESC, sort_order ASC, id ASC LIMIT 1 ) img ON TRUE """ _USED_WHERE = "WHERE i.is_active = true AND i.condition IN ('usado', 'reconstruido')" def _used_filter_clause(q, make, model, year, grade): where, params = [], [] if q: like = f"%{q}%" where.append("(i.name ILIKE %s OR i.part_number ILIKE %s OR i.description ILIKE %s)") params += [like, like, like] if make: where.append("i.donor_make ILIKE %s") params.append(make) if model: where.append("i.donor_model ILIKE %s") params.append(model) if year: where.append("i.donor_year = %s") params.append(year) if grade: where.append("i.condition_grade = %s") params.append(grade) return (" AND " + " AND ".join(where)) if where else "", params def _used_parts_query(tid, q='', make='', model='', year='', grade='', page=1, per_page=24): """(items, total) de piezas usadas del tenant. ([], 0) ante cualquier fallo.""" try: page = max(1, int(page or 1)) except (ValueError, TypeError): page = 1 try: per_page = max(1, min(48, int(per_page or 24))) except (ValueError, TypeError): per_page = 24 offset = (page - 1) * per_page clause, params = _used_filter_clause(q, make, model, year, grade) conn = None try: conn = get_tenant_conn(tid) cur = conn.cursor() cur.execute(f"SELECT count(*) FROM inventory i {_USED_WHERE}{clause}", params) total = int((cur.fetchone() or [0])[0] or 0) cur.execute( _USED_SELECT + _USED_WHERE + clause + """ ORDER BY COALESCE(i.condition_grade, 'Z') ASC, i.id DESC LIMIT %s OFFSET %s """, params + [per_page, offset]) items = [_used_item(r) for r in cur.fetchall()] cur.close() return items, total except Exception: return [], 0 finally: _used_close(conn) def _used_part(tid, part_id): """Una pieza usada por id, o None si no existe / falla la BD.""" conn = None try: conn = get_tenant_conn(tid) cur = conn.cursor() cur.execute(_USED_SELECT + _USED_WHERE + " AND i.id = %s", (part_id,)) row = cur.fetchone() cur.close() return _used_item(row) if row else None except Exception: return None finally: _used_close(conn) def _used_facets(tid, make=''): """Makes / models / years / grades con conteo para los filtros de la vitrina.""" facets = {'makes': [], 'models': [], 'years': [], 'grades': []} conn = None try: conn = get_tenant_conn(tid) cur = conn.cursor() cur.execute(f"""SELECT i.donor_make, count(*) FROM inventory i {_USED_WHERE} AND i.donor_make IS NOT NULL AND i.donor_make <> '' GROUP BY i.donor_make ORDER BY count(*) DESC, i.donor_make LIMIT 40""") facets['makes'] = [{'value': r[0], 'count': int(r[1])} for r in cur.fetchall()] model_where = f"{_USED_WHERE} AND i.donor_model IS NOT NULL AND i.donor_model <> ''" mparams = [] if make: model_where += " AND i.donor_make ILIKE %s" mparams.append(make) cur.execute(f"""SELECT i.donor_model, count(*) FROM inventory i {model_where} GROUP BY i.donor_model ORDER BY count(*) DESC, i.donor_model LIMIT 40""", mparams) facets['models'] = [{'value': r[0], 'count': int(r[1])} for r in cur.fetchall()] cur.execute(f"""SELECT i.donor_year, count(*) FROM inventory i {_USED_WHERE} AND i.donor_year IS NOT NULL GROUP BY i.donor_year ORDER BY i.donor_year DESC LIMIT 40""") facets['years'] = [{'value': r[0], 'count': int(r[1])} for r in cur.fetchall()] cur.execute(f"""SELECT i.condition_grade, count(*) FROM inventory i {_USED_WHERE} AND i.condition_grade IS NOT NULL GROUP BY i.condition_grade ORDER BY i.condition_grade""") facets['grades'] = [{'value': r[0], 'count': int(r[1])} for r in cur.fetchall()] cur.close() except Exception: pass finally: _used_close(conn) return facets def _public_tenant(raw=None): """Resuelve un tenant público por id/subdominio o por el host actual.""" if raw is None: raw = request.args.get('tenant', '') raw = (raw or '').strip() tid = int(raw) if raw.isdigit() else None sub = '' if raw.isdigit() else raw.lower() if not raw: sub = (request.headers.get('X-Tenant-Subdomain') or '').strip().lower() if sub in _RESERVED_SUBDOMAINS: sub = '' if not sub: host = (request.host or '').split(':')[0].lower() parts = host.split('.') if len(parts) >= 3 or (len(parts) == 2 and parts[1] == 'localhost'): sub = parts[0] if sub in _RESERVED_SUBDOMAINS: sub = '' if tid is None and not sub: return None cache_key = f"id:{tid}" if tid is not None else f"sub:{sub}" cached = _public_tenant_cache.get(cache_key) if cached and cached[1] > _time_seo.time(): return cached[0] try: from tenant_db import get_master_conn conn = get_master_conn() cur = conn.cursor() if tid is not None: cur.execute( "SELECT id, name, subdomain, db_name FROM tenants WHERE id = %s AND is_active = true", (tid,)) else: cur.execute( "SELECT id, name, subdomain, db_name FROM tenants WHERE LOWER(subdomain) = %s AND is_active = true", (sub,)) row = cur.fetchone() cur.close() conn.close() except Exception: return None if not row: return None tenant = {'id': row[0], 'name': row[1], 'subdomain': (row[2] or str(row[0])).strip(), 'db_name': row[3]} _public_tenant_cache[cache_key] = (tenant, _time_seo.time() + 300) return tenant def _tenant_used_summary(tenant): """Resumen de usados de un tenant para el directorio y el sitemap.""" conn = None try: conn = get_tenant_conn(tenant['id']) cur = conn.cursor() cur.execute(f"SELECT count(*) FROM inventory i {_USED_WHERE}") count = int((cur.fetchone() or [0])[0] or 0) if not count: cur.close() return None cur.execute(f"SELECT i.id, i.created_at FROM inventory i {_USED_WHERE} ORDER BY i.id LIMIT 2000") rows = cur.fetchall() cur.execute(f"""SELECT i.donor_make, count(*) FROM inventory i {_USED_WHERE} AND i.donor_make IS NOT NULL AND i.donor_make <> '' GROUP BY i.donor_make ORDER BY count(*) DESC LIMIT 200""") makes = [{'make': r[0], 'count': int(r[1])} for r in cur.fetchall()] cur.close() lastmod = None items = [] for r in rows: d = r[1].strftime('%Y-%m-%d') if r[1] is not None else None if d and (lastmod is None or d > lastmod): lastmod = d items.append({'id': r[0], 'lastmod': d}) return {'count': count, 'makes': makes, 'lastmod': lastmod, 'items': items} except Exception: return None finally: _used_close(conn) def _active_tenants_with_used(): """Tenants activos con al menos una pieza usada (cache 10 min).""" now = _time_seo.time() if _used_dir_cache['data'] is not None and now - _used_dir_cache['ts'] < 600: return _used_dir_cache['data'] result = [] try: from tenant_db import get_master_conn conn = get_master_conn() cur = conn.cursor() cur.execute("SELECT id, name, subdomain FROM tenants WHERE is_active = true ORDER BY id") tenants = cur.fetchall() cur.close() conn.close() except Exception: tenants = [] for tid, name, subdomain in tenants: t = {'id': tid, 'name': name, 'subdomain': (subdomain or str(tid)).strip()} summary = _tenant_used_summary(t) if not summary: continue t.update(summary) result.append(t) _used_dir_cache['data'] = result _used_dir_cache['ts'] = now return result def _used_sitemap_urls(): """URLs de vitrinas, páginas de marca y detalles usados, con lastmod.""" from urllib.parse import quote as _q urls = [] detail_budget = 10000 for t in _active_tenants_with_used(): base = f"{PUBLIC_BASE_URL}/usado/{t['subdomain']}" urls.append({'loc': base, 'lastmod': t.get('lastmod')}) for mk in t.get('makes', []): urls.append({'loc': f"{base}/marca/{_q(str(mk['make']), safe='')}", 'lastmod': t.get('lastmod')}) for it in t.get('items', []): if detail_budget <= 0: break urls.append({'loc': f"{base}/{it['id']}", 'lastmod': it.get('lastmod')}) detail_budget -= 1 return urls def _json_ld(data): return json_module.dumps(data, ensure_ascii=False).replace('<', '\\u003c') def _used_qs(q, make, model, year, grade, page=None): from urllib.parse import urlencode params = {} if q: params['q'] = q if make: params['make'] = make if model: params['model'] = model if year: params['year'] = year if grade: params['grade'] = grade if page and page > 1: params['page'] = page return ('?' + urlencode(params)) if params else '' _USED_CSS = """ :root{--ink:#111827;--muted:#6b7280;--line:#e5e7eb;--bg:#f6f7f9;--card:#fff;--brand:#e11d48;--accent:#0f766e} *{box-sizing:border-box} body{margin:0;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--ink);line-height:1.5} a{color:var(--accent)} .wrap{max-width:1120px;margin:0 auto;padding:0 16px} header.up{background:#0f172a;color:#fff;padding:14px 0} header.up .wrap{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap} .logo{font-weight:800;letter-spacing:.5px;color:#fff;text-decoration:none;font-size:1.05rem} .logo span{color:var(--brand)} header.up nav a{color:#cbd5e1;text-decoration:none;margin-left:18px;font-size:.92rem} header.up nav a:hover{color:#fff} .hero{padding:26px 0 6px} .hero h1{font-size:1.6rem;margin:0 0 6px} .hero p{color:var(--muted);margin:0} .filters{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px;margin:18px 0;display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end} .filters label{display:block;font-size:.72rem;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:4px} .filters select,.filters input{padding:9px 10px;border:1px solid var(--line);border-radius:9px;font:inherit;background:#fff;min-width:150px} .filters button{padding:10px 18px;border:0;border-radius:9px;background:var(--accent);color:#fff;font-weight:600;cursor:pointer} .filters a.clear{align-self:center;font-size:.85rem;color:var(--muted);text-decoration:none} .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(238px,1fr));gap:18px;margin:8px 0 34px} .card{background:var(--card);border:1px solid var(--line);border-radius:14px;overflow:hidden;display:flex;flex-direction:column;transition:transform .12s ease,box-shadow .12s ease} .card:hover{transform:translateY(-2px);box-shadow:0 10px 24px rgba(15,23,42,.08)} .card .thumb{position:relative;aspect-ratio:4/3;background:#eef1f5;display:flex;align-items:center;justify-content:center;overflow:hidden;text-decoration:none} .card .thumb img{width:100%;height:100%;object-fit:cover;display:block} .card .thumb .noimg{color:#94a3b8;font-size:.8rem} .badge{position:absolute;top:10px;left:10px;background:rgba(15,23,42,.82);color:#fff;font-size:.72rem;font-weight:700;padding:4px 9px;border-radius:999px;letter-spacing:.03em} .badge.g-A{background:#15803d}.badge.g-B{background:#0f766e}.badge.g-C{background:#b45309}.badge.g-D{background:#b91c1c} .cond{position:absolute;top:10px;right:10px;background:rgba(225,29,72,.92);color:#fff;font-size:.68rem;font-weight:700;padding:4px 8px;border-radius:999px;text-transform:uppercase} .card .body{padding:13px 14px 15px;display:flex;flex-direction:column;gap:5px;flex:1} .card .name{font-weight:700;font-size:.98rem;text-decoration:none;color:var(--ink)} .card .name:hover{color:var(--brand)} .card .oem{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.74rem;color:var(--muted)} .card .donor{font-size:.82rem;color:var(--muted)} .card .price{margin-top:auto;font-size:1.12rem;font-weight:800;color:var(--brand)} .card .stock{font-size:.75rem;color:var(--muted)} .empty{background:var(--card);border:1px dashed var(--line);border-radius:14px;padding:40px;text-align:center;color:var(--muted)} .pager{display:flex;gap:8px;justify-content:center;margin:0 0 40px;flex-wrap:wrap} .pager a,.pager span{padding:8px 13px;border-radius:9px;border:1px solid var(--line);background:#fff;text-decoration:none;color:var(--ink);font-size:.88rem} .pager .on{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:700} .detail{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(0,1fr);gap:28px;margin:22px 0 44px} .detail .photo{background:var(--card);border:1px solid var(--line);border-radius:16px;overflow:hidden} .detail .photo img{width:100%;display:block} .detail h1{font-size:1.5rem;margin:0 0 6px} .spec{width:100%;border-collapse:collapse;margin-top:14px;background:var(--card);border:1px solid var(--line);border-radius:12px;overflow:hidden} .spec td{padding:9px 13px;border-bottom:1px solid var(--line);font-size:.9rem} .spec td:first-child{color:var(--muted);width:42%} .spec tr:last-child td{border-bottom:0} .buy{margin-top:18px;padding:16px;background:var(--card);border:1px solid var(--line);border-radius:14px} .buy .big{font-size:1.9rem;font-weight:800;color:var(--brand)} .wa{display:inline-block;margin-top:12px;padding:13px 22px;background:#25D366;color:#fff;border-radius:11px;text-decoration:none;font-weight:700} .crumb{font-size:.84rem;color:var(--muted);padding-top:12px} .crumb a{color:var(--muted)} .dir-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:16px;margin:20px 0 44px} .dir-card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:18px;text-decoration:none;color:var(--ink);display:block} .dir-card:hover{border-color:var(--accent)} .dir-card .n{font-weight:800;font-size:1.05rem} .dir-card .c{color:var(--muted);font-size:.85rem;margin-top:4px} footer{background:#0f172a;color:#94a3b8;padding:26px 0;font-size:.85rem;text-align:center} @media(max-width:760px){.detail{grid-template-columns:1fr}} """ _USED_LIST_TEMPLATE = """ {{ page_title }} {% if og_image %}{% endif %}
Inicio › Partes usadas › {{ tenant.name }}{% if heading_extra %} › {{ heading_extra }}{% endif %}

{{ heading }}

{{ subheading }}

Limpiar
{% if items %}
{% for p in items %} {% endfor %}
{% if pager %}
{% for pg in pager %}{% if pg.url %}{{ pg.label }}{% else %}{{ pg.label }}{% endif %}{% endfor %}
{% endif %} {% else %}

No hay piezas usadas que coincidan con tu búsqueda.

Ver todo el inventario usado

{% endif %}
{{ tenant.name }} — Vitrina de partes usadas · Nexus Autoparts
""" _USED_DETAIL_TEMPLATE = """ {{ title }} {% if image %}{% endif %}
Inicio › Partes usadas › {{ tenant.name }} › {{ part.name }}
{% if image %}{{ part.name }} para {{ part.donor_label }}{% else %}
Sin fotografía disponible
{% endif %}

{{ part.name }}

{{ part.part_number or ('ID #' ~ part.id) }}
{{ part.condition_label }}{% if part.grade %} · Grado {{ part.grade }}{% endif %} {% if part.is_unique_piece %}Pieza única{% endif %}
{% if part.grade %}{% endif %} {% if part.mileage_label %}{% endif %} {% if part.donor.vin %}{% endif %} {% if part.brand %}{% endif %}
Condición{{ part.condition_label }}
Grado de condición{{ part.grade }}
Vehículo donador{{ part.donor_label or 'No especificado' }}
Kilometraje del donador{{ part.mileage_label }}
VIN{{ part.donor.vin }}
Marca de la pieza{{ part.brand }}
Disponibilidad{% if part.in_stock %}{{ part.stock_label }}{% else %}Por confirmar{% endif %}
{% if part.description %}

{{ part.description }}

{% endif %}
{{ part.price_label }}
{% if wa_href %}{% endif %}

{{ tenant.name }} · Pieza usada verificada. Sujeto a disponibilidad y confirmación de compatibilidad.

{{ tenant.name }} — Vitrina de partes usadas · Nexus Autoparts
""" _USED_DIRECTORY_TEMPLATE = """ Partes usadas y reconstruidas — Nexus Autoparts
Inicio › Partes usadas

Vitrinas de partes usadas

Yonkes, deshuesaderos y refaccionarias con inventario usado verificado. Elige una tienda para ver sus piezas, grado de condición y vehículo donador.

{% if stores %} {% else %}

Aún no hay tiendas con piezas usadas publicadas.

Ver el catálogo general

{% endif %}
Nexus Autoparts — Partes nuevas, usadas y reconstruidas
""" _USED_404_TEMPLATE = """ Pieza no encontrada — Nexus Autoparts

Pieza usada no disponible

La pieza que buscas no existe, ya se vendió o el enlace es incorrecto.

Nexus Autoparts
""" def _used_html_response(html, status=200, cache='public, max-age=300'): resp = Flask.response_class(html, mimetype='text/html', status=status) resp.headers['Cache-Control'] = cache return resp def _used_abs_image(image_url): if not image_url: return '' return image_url if image_url.startswith('http') else PUBLIC_BASE_URL + image_url def _used_not_found(): html = render_template_string(_USED_404_TEMPLATE, css=_USED_CSS) return _used_html_response(html, status=404, cache='no-store') def _used_render_list(tenant, base_path, heading, subheading, heading_extra=None, canonical_path=None, q='', make='', model='', year='', grade='', page=1, per_page=24): items, total = _used_parts_query(tenant['id'], q=q, make=make, model=model, year=year, grade=grade, page=page, per_page=per_page) facets = _used_facets(tenant['id'], make=make) total_pages = max(1, (total + per_page - 1) // per_page) page = min(max(1, int(page or 1)), total_pages) canonical = PUBLIC_BASE_URL + (canonical_path or base_path) page_title = f"{heading} — Partes usadas | {tenant['name']}" meta_description = ( f"{total} pieza{'s' if total != 1 else ''} usada{'s' if total != 1 else ''} y " f"reconstruida{'s' if total != 1 else ''} en {tenant['name']}. " f"Fotos reales, grado de condición, vehículo donador y precio. Cotiza por WhatsApp." )[:300] og_image = '' for it in items: if it['image_url']: og_image = _used_abs_image(it['image_url']) break pager = [] if total_pages > 1: def _plink(n, label, active=False): return {'url': base_path + _used_qs(q, make, model, year, grade, page=n), 'label': label, 'active': active} if page > 1: pager.append(_plink(page - 1, '‹')) for n in range(max(1, page - 2), min(total_pages, page + 2) + 1): pager.append(_plink(n, str(n), n == page)) if page < total_pages: pager.append(_plink(page + 1, '›')) breadcrumb_ld = _json_ld({ "@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [ {"@type": "ListItem", "position": 1, "name": "Inicio", "item": PUBLIC_BASE_URL + "/"}, {"@type": "ListItem", "position": 2, "name": "Partes usadas", "item": PUBLIC_BASE_URL + "/usados"}, {"@type": "ListItem", "position": 3, "name": tenant['name'], "item": PUBLIC_BASE_URL + base_path}, ], }) html = render_template_string( _USED_LIST_TEMPLATE, css=_USED_CSS, tenant=tenant, base_path=base_path, heading=heading, subheading=subheading, heading_extra=heading_extra, page_title=page_title, meta_description=meta_description, canonical=canonical, og_image=og_image, breadcrumb_ld=breadcrumb_ld, items=items, facets=facets, pager=pager, f_q=q, f_make=make, f_model=model, f_year=str(year or ''), f_grade=grade) return _used_html_response(html) def _used_render_detail(tenant, part): base_path = f"/usado/{tenant['subdomain']}" canonical = PUBLIC_BASE_URL + f"{base_path}/{part['id']}" image = _used_abs_image(part['image_url']) donor_label = part['donor_label'] or 'vehículo no especificado' mileage = part['donor'].get('mileage_km') part['mileage_label'] = f"{int(mileage):,} km" if mileage else '' grade_txt = f" grado {part['grade']}" if part['grade'] else '' title = f"{part['name']} — pieza {part['condition_fem'].lower()}{grade_txt} para {donor_label} — {tenant['name']}" meta_description = ( f"Pieza {part['condition_fem'].lower()}: {part['name']}{grade_txt} para {donor_label}. " f"Precio {part['price_label']}. {tenant['name']} · Nexus Autoparts. " f"Fotos reales y cotización por WhatsApp." )[:300] offers = { "@type": "Offer", "url": canonical, "priceCurrency": "MXN", "itemCondition": "https://schema.org/UsedCondition", "availability": "https://schema.org/InStock" if part['in_stock'] else "https://schema.org/OutOfStock", "seller": {"@type": "Organization", "name": tenant['name']}, } if part['price'] > 0: offers['price'] = f"{part['price']:.2f}" props = [] if part['grade']: props.append({"@type": "PropertyValue", "name": "Grado de condición", "value": part['grade']}) if part['mileage_label']: props.append({"@type": "PropertyValue", "name": "Kilometraje del donador", "value": part['mileage_label']}) if part['donor'].get('vin'): props.append({"@type": "PropertyValue", "name": "VIN del donador", "value": part['donor']['vin']}) if part['part_number']: props.append({"@type": "PropertyValue", "name": "Número de parte", "value": part['part_number']}) product_ld = { "@context": "https://schema.org", "@type": "Product", "name": part['name'], "sku": part['part_number'] or f"USED-{part['id']}", "description": part['description'] or meta_description, "itemCondition": "https://schema.org/UsedCondition", "condition": part['condition_label'], "offers": offers, "additionalProperty": props, } if image: product_ld['image'] = [image] if part['brand']: product_ld['brand'] = {"@type": "Brand", "name": part['brand']} if part['is_unique_piece']: product_ld['numberOfItems'] = 1 wa = (os.environ.get('PUBLIC_WA_NUMBER') or '').replace(' ', '').replace('+', '') wa_href = '' if wa: from urllib.parse import quote text = f"Hola, me interesa esta pieza usada: {part['name']} (ID {part['id']}) — {donor_label}" wa_href = f"https://wa.me/{wa}?text={quote(text)}" html = render_template_string( _USED_DETAIL_TEMPLATE, css=_USED_CSS, tenant=tenant, part=part, base_path=base_path, canonical=canonical, image=image, title=title, meta_description=meta_description, jsonld=_json_ld(product_ld), wa_href=wa_href) return _used_html_response(html) def _used_render_directory(): stores = _active_tenants_with_used() html = render_template_string( _USED_DIRECTORY_TEMPLATE, css=_USED_CSS, base_url=PUBLIC_BASE_URL, stores=stores) return _used_html_response(html) def _used_filter_args(): grade = (request.args.get('grade') or '').strip().upper()[:2] if grade not in _USED_GRADES: grade = '' year = (request.args.get('year') or '').strip()[:4] return { 'q': (request.args.get('q') or '').strip()[:120], 'make': (request.args.get('make') or '').strip()[:60], 'model': (request.args.get('model') or '').strip()[:60], 'year': year, 'grade': grade, 'page': max(1, request.args.get('page', type=int) or 1), 'per_page': max(1, min(48, request.args.get('per_page', type=int) or 24)), } @app.route('/api/public/used-parts') def api_public_used_parts(): """Lista pública de piezas usadas/reconstruidas de un tenant (JSON).""" tenant = _public_tenant() if not tenant: return jsonify({'error': 'tenant no encontrado', 'items': [], 'total': 0}), 404 f = _used_filter_args() items, total = _used_parts_query( tenant['id'], q=f['q'], make=f['make'], model=f['model'], year=f['year'], grade=f['grade'], page=f['page'], per_page=f['per_page']) return jsonify({ 'tenant': {'id': tenant['id'], 'name': tenant['name'], 'subdomain': tenant['subdomain']}, 'items': items, 'page': f['page'], 'per_page': f['per_page'], 'total': total, 'total_pages': max(1, (total + f['per_page'] - 1) // f['per_page']), 'facets': _used_facets(tenant['id'], make=f['make']), 'showcase_url': f"{PUBLIC_BASE_URL}/usado/{tenant['subdomain']}", }) @app.route('/usado') @app.route('/usados') def used_parts_directory(): """Directorio de tiendas con piezas usadas publicadas.""" return _used_render_directory() @app.route('/usado/') def used_parts_showcase(tenant): """Vitrina pública de piezas usadas de un tenant (server-rendered).""" t = _public_tenant(tenant) if not t: return _used_not_found() f = _used_filter_args() base_path = f"/usado/{t['subdomain']}" return _used_render_list( t, base_path=base_path, heading=f"Partes usadas en {t['name']}", subheading=('Inventario usado y reconstruido verificado: fotos reales, grado de condición, ' 'vehículo donador y precio. Filtra por marca, modelo, año o grado.'), canonical_path=base_path, **{k: v for k, v in f.items() if k != 'per_page'}) @app.route('/usado//marca/') def used_parts_by_make(tenant, make): """Piezas usadas filtradas por marca del vehículo donador (SEO).""" t = _public_tenant(tenant) if not t: return _used_not_found() f = _used_filter_args() make = (make or '').strip()[:60] base_path = f"/usado/{t['subdomain']}" from urllib.parse import quote canonical_path = f"{base_path}/marca/{quote(make, safe='')}" f['make'] = make return _used_render_list( t, base_path=base_path, heading=f"Partes usadas para {make} en {t['name']}", subheading=f'Piezas usadas y reconstruidas de vehículos {make} deshuesados.', heading_extra=make, canonical_path=canonical_path, **{k: v for k, v in f.items() if k != 'per_page'}) @app.route('/usado//marca//') def used_parts_by_model(tenant, make, model): """Piezas usadas por marca + modelo del vehículo donador (SEO).""" t = _public_tenant(tenant) if not t: return _used_not_found() f = _used_filter_args() make = (make or '').strip()[:60] model = (model or '').strip()[:60] base_path = f"/usado/{t['subdomain']}" from urllib.parse import quote canonical_path = f"{base_path}/marca/{quote(make, safe='')}/{quote(model, safe='')}" f['make'] = make f['model'] = model return _used_render_list( t, base_path=base_path, heading=f"Partes usadas para {make} {model} en {t['name']}", subheading=f'Piezas usadas y reconstruidas de vehículos {make} {model}.', heading_extra=f"{make} {model}", canonical_path=canonical_path, **{k: v for k, v in f.items() if k != 'per_page'}) @app.route('/usado//') def used_part_detail(tenant, part_id): """Detalle server-rendered de una pieza usada con schema.org Product.""" t = _public_tenant(tenant) if not t: return _used_not_found() part = _used_part(t['id'], part_id) if not part: return _used_not_found() return _used_render_detail(t, part) @app.route('/') def serve_root_static(filename): if filename.endswith(('.css', '.js', '.html')) and os.path.isfile(filename): return send_from_directory('.', filename) abort(404) # ============================================================================ # Main Block # ============================================================================ if __name__ == '__main__': print("Starting Nexus Autoparts Dashboard Server...") print("Visit http://localhost:5000 to access the dashboard locally") app.run(debug=False, host='0.0.0.0', port=5000)