Files
Autoparts-DB/dashboard/server.py
consultoria-as a386ca5b4a
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos
- F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado
- F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment
- F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings)
- F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant
- Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas
- Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant)
- Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant
- Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
2026-10-12 00:57:31 +00:00

6119 lines
261 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/<path:path>', 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/<path:path>')
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/<int:part_id>')
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/<int:category_id>/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/<int:part_id>')
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/<int:mye_id>/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/<int:mye_id>/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/<int:mye_id>/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/<int:part_id>/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/<int:part_id>/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/<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/<int:diagram_id>')
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/<int:diagram_id>/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/<int:group_id>/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/<int:mye_id>/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/<int:diagram_id>/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/<int:hotspot_id>')
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/<vin>')
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/<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/<vin>'}), 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/<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/<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/<int:category_id>', 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/<int:category_id>', 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/<int:group_id>', 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/<int:group_id>', 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/<int:part_id>', 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/<int:part_id>', 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/<int:manufacturer_id>', 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/<int:manufacturer_id>', 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/<int:aftermarket_id>', 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/<int:aftermarket_id>', 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/<int:crossref_id>', 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/<int:crossref_id>', 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/<int:fitment_id>', 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/<import_type>', 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/<export_type>')
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/<int:mye_id>/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/<int:hotspot_id>', 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/<int:hotspot_id>', 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/<int:mye_id>/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/<int:mye_id>/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/<int:part_id>/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/<int:part_id>/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/<int:customer_id>')
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/<int:customer_id>', 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/<int:invoice_id>')
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/<int:invoice_id>/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/<int:customer_id>/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/<int:user_id>/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/<int:part_id>/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/<int:part_id>/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/<int:tenant_id>/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/<int:tenant_id>/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 = """<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ part.name | replace("_", " ") }} {{ part.sku }} — Refacción y disponibilidad | Nexus Autoparts</title>
<meta name="description" content="Compra {{ part.name }} ({{ part.sku }}). Disponible en {{ avail_count }} bodega(s) en la red Nexus Autoparts. Cotiza por WhatsApp al instante.">
<link rel="canonical" href="https://nexusautoparts.com.mx/parte/{{ part.id }}">
<meta property="og:title" content="{{ part.name }} — {{ part.sku }}">
<meta property="og:description" content="Disponibilidad real en bodegas de la red. Cotización inmediata por WhatsApp.">
<meta property="og:type" content="product">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Product",
"name": "{{ part.name }}",
"sku": "{{ part.sku }}",
"category": "{{ part.category or '' }}",
"description": "{{ (part.description or part.name) | replace('"', '') }}",
"brand": { "@type": "Brand", "name": "{{ part.supplier_name }}" },
"offers": {
"@type": "AggregateOffer",
"availability": "{{ availability }}",
"offerCount": {{ avail_count }},
"priceCurrency": "MXN"
}
}
</script>
<style>
body { font-family: system-ui, -apple-system, sans-serif; max-width: 860px; margin: 0 auto; padding: 24px; color: #1a1a2e; }
.card { border: 1px solid #e5e7eb; border-radius: 12px; padding: 24px; }
h1 { font-size: 1.5rem; margin: 0 0 4px; }
.oem { font-family: ui-monospace, monospace; color: #6b7280; }
table { width: 100%%; border-collapse: collapse; margin-top: 16px; }
td, th { text-align: left; padding: 8px; border-bottom: 1px solid #f0f0f0; }
.wa { display: inline-block; margin-top: 16px; padding: 12px 22px; background: #25D366; color: #fff;
border-radius: 10px; text-decoration: none; font-weight: 600; }
img { max-width: 320px; border-radius: 12px; }
.brand { margin-bottom: 24px; font-weight: 800; color: #e11d48; }
</style>
</head>
<body>
<div class="brand">NEXUS AUTOPARTS</div>
<div class="card">
<h1>{{ part.name | replace("_", " ") }}</h1>
<div class="oem">OEM / SKU: {{ part.sku }}</div>
{% if part.category %}<p>Categoría: {{ part.category }}</p>{% endif %}
{% if part.description %}<p>{{ part.description }}</p>{% endif %}
{% if part.image_url %}<img src="{{ part.image_url }}" alt="{{ part.name }}">{% endif %}
<h3>Disponibilidad en la red ({{ avail_count }} bodegas)</h3>
{% if avail_count %}
<table><tr><th>Bodega</th><th>Stock</th><th>Ubicación</th></tr>
{% for b in bodegas %}<tr><td>{{ b.name }}</td><td>{{ b.stock }}</td><td>{{ b.location or '—' }}</td></tr>{% endfor %}
</table>
{% else %}
<p>Consultar disponibilidad con tu refaccionaria Nexus más cercana.</p>
{% endif %}
<a class="wa" target="_blank" rel="noopener"
href="https://wa.me/{{ wa_number }}?text={{ wa_text }}">Cotizar por WhatsApp</a>
</div>
<p><a href="/">← Volver al catálogo</a></p>
</body>
</html>"""
_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/<int:catalog_id>')
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 = ['<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
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"<url><loc>{loc}</loc><lastmod>{lastmod}</lastmod></url>")
else:
body.append(f"<url><loc>{loc}</loc></url>")
body.append('</urlset>')
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/<tenant> — vitrina server-rendered + filtros
# GET /usado/<tenant>/<id> — detalle con schema.org Product
# GET /usado/<tenant>/marca/<make> — 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 = """<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ page_title }}</title>
<meta name="description" content="{{ meta_description }}">
<link rel="canonical" href="{{ canonical }}">
<meta property="og:type" content="website">
<meta property="og:title" content="{{ page_title }}">
<meta property="og:description" content="{{ meta_description }}">
{% if og_image %}<meta property="og:image" content="{{ og_image }}">{% endif %}
<script type="application/ld+json">{{ breadcrumb_ld | safe }}</script>
<style>{{ css | safe }}</style>
</head>
<body>
<header class="up"><div class="wrap">
<a class="logo" href="/catalog">NEXUS<span>AUTOPARTS</span></a>
<nav>
<a href="/catalog">Catálogo</a>
<a href="/usados">Tiendas usados</a>
<a href="{{ base_path }}">{{ tenant.name }}</a>
</nav>
</div></header>
<div class="wrap">
<div class="crumb"><a href="/">Inicio</a> › <a href="/usados">Partes usadas</a> › <a href="{{ base_path }}">{{ tenant.name }}</a>{% if heading_extra %} › {{ heading_extra }}{% endif %}</div>
<div class="hero">
<h1>{{ heading }}</h1>
<p>{{ subheading }}</p>
</div>
<form class="filters" method="get" action="{{ base_path }}">
<div><label for="q">Buscar</label><input id="q" name="q" type="text" value="{{ f_q }}" placeholder="Faro, ECM, puerta..."></div>
<div><label for="make">Vehículo donador</label>
<select id="make" name="make"><option value="">Todas las marcas</option>
{% for m in facets.makes %}<option value="{{ m.value }}"{% if f_make == m.value %} selected{% endif %}>{{ m.value }} ({{ m.count }})</option>{% endfor %}
</select></div>
<div><label for="model">Modelo</label>
<select id="model" name="model"><option value="">Todos</option>
{% for m in facets.models %}<option value="{{ m.value }}"{% if f_model == m.value %} selected{% endif %}>{{ m.value }} ({{ m.count }})</option>{% endfor %}
</select></div>
<div><label for="year">Año</label>
<select id="year" name="year"><option value="">Todos</option>
{% for y in facets.years %}<option value="{{ y.value }}"{% if f_year == y.value|string %} selected{% endif %}>{{ y.value }} ({{ y.count }})</option>{% endfor %}
</select></div>
<div><label for="grade">Grado</label>
<select id="grade" name="grade"><option value="">Todos</option>
{% for g in facets.grades %}<option value="{{ g.value }}"{% if f_grade == g.value %} selected{% endif %}>Grado {{ g.value }} ({{ g.count }})</option>{% endfor %}
</select></div>
<button type="submit">Aplicar</button>
<a class="clear" href="{{ base_path }}">Limpiar</a>
</form>
{% if items %}
<div class="grid">
{% for p in items %}
<article class="card">
<a class="thumb" href="{{ base_path }}/{{ p.id }}">
{% if p.image_url %}<img src="{{ p.image_url }}" alt="{{ p.name }} para {{ p.donor_label }}" loading="lazy">{% else %}<span class="noimg">Sin foto</span>{% endif %}
{% if p.grade %}<span class="badge g-{{ p.grade }}">Grado {{ p.grade }}</span>{% endif %}
<span class="cond">{{ p.condition_label }}</span>
</a>
<div class="body">
<a class="name" href="{{ base_path }}/{{ p.id }}">{{ p.name }}</a>
{% if p.part_number %}<div class="oem">{{ p.part_number }}</div>{% endif %}
<div class="donor">{{ p.donor_label or 'Vehículo no especificado' }}</div>
<div class="price">{{ p.price_label }}</div>
<div class="stock">{% if p.in_stock %}{{ p.stock_label }}{% else %}Existencia por confirmar{% endif %}</div>
</div>
</article>
{% endfor %}
</div>
{% if pager %}<div class="pager">{% for pg in pager %}{% if pg.url %}<a href="{{ pg.url }}"{% if pg.active %} class="on" aria-current="page"{% endif %}>{{ pg.label }}</a>{% else %}<span>{{ pg.label }}</span>{% endif %}{% endfor %}</div>{% endif %}
{% else %}
<div class="empty"><p>No hay piezas usadas que coincidan con tu búsqueda.</p><p><a href="{{ base_path }}">Ver todo el inventario usado</a></p></div>
{% endif %}
</div>
<footer><div class="wrap">{{ tenant.name }} — Vitrina de partes usadas · Nexus Autoparts</div></footer>
</body>
</html>"""
_USED_DETAIL_TEMPLATE = """<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title>
<meta name="description" content="{{ meta_description }}">
<link rel="canonical" href="{{ canonical }}">
<meta property="og:type" content="product">
<meta property="og:title" content="{{ title }}">
<meta property="og:description" content="{{ meta_description }}">
<meta property="og:url" content="{{ canonical }}">
{% if image %}<meta property="og:image" content="{{ image }}">{% endif %}
<script type="application/ld+json">{{ jsonld | safe }}</script>
<style>{{ css | safe }}</style>
</head>
<body>
<header class="up"><div class="wrap">
<a class="logo" href="/catalog">NEXUS<span>AUTOPARTS</span></a>
<nav><a href="/catalog">Catálogo</a><a href="/usados">Tiendas usados</a><a href="{{ base_path }}">{{ tenant.name }}</a></nav>
</div></header>
<div class="wrap">
<div class="crumb"><a href="/">Inicio</a> › <a href="/usados">Partes usadas</a> › <a href="{{ base_path }}">{{ tenant.name }}</a> › {{ part.name }}</div>
<div class="detail">
<div class="photo">
{% if image %}<img src="{{ image }}" alt="{{ part.name }} para {{ part.donor_label }}">{% else %}<div class="empty" style="border:0">Sin fotografía disponible</div>{% endif %}
</div>
<div>
<h1>{{ part.name }}</h1>
<div class="oem" style="font-family:ui-monospace,monospace;color:var(--muted)">{{ part.part_number or ('ID #' ~ part.id) }}</div>
<div style="margin:10px 0">
<span class="badge g-{{ part.grade }}" style="position:static;display:inline-block">{{ part.condition_label }}{% if part.grade %} · Grado {{ part.grade }}{% endif %}</span>
{% if part.is_unique_piece %}<span class="badge" style="position:static;display:inline-block">Pieza única</span>{% endif %}
</div>
<table class="spec">
<tr><td>Condición</td><td>{{ part.condition_label }}</td></tr>
{% if part.grade %}<tr><td>Grado de condición</td><td>{{ part.grade }}</td></tr>{% endif %}
<tr><td>Vehículo donador</td><td>{{ part.donor_label or 'No especificado' }}</td></tr>
{% if part.mileage_label %}<tr><td>Kilometraje del donador</td><td>{{ part.mileage_label }}</td></tr>{% endif %}
{% if part.donor.vin %}<tr><td>VIN</td><td>{{ part.donor.vin }}</td></tr>{% endif %}
{% if part.brand %}<tr><td>Marca de la pieza</td><td>{{ part.brand }}</td></tr>{% endif %}
<tr><td>Disponibilidad</td><td>{% if part.in_stock %}{{ part.stock_label }}{% else %}Por confirmar{% endif %}</td></tr>
</table>
{% if part.description %}<p>{{ part.description }}</p>{% endif %}
<div class="buy">
<div class="big">{{ part.price_label }}</div>
{% if wa_href %}<a class="wa" target="_blank" rel="noopener" href="{{ wa_href }}">Cotizar por WhatsApp</a>{% endif %}
<p style="color:var(--muted);font-size:.82rem;margin:10px 0 0">{{ tenant.name }} · Pieza usada verificada. Sujeto a disponibilidad y confirmación de compatibilidad.</p>
</div>
</div>
</div>
</div>
<footer><div class="wrap">{{ tenant.name }} — Vitrina de partes usadas · Nexus Autoparts</div></footer>
</body>
</html>"""
_USED_DIRECTORY_TEMPLATE = """<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Partes usadas y reconstruidas — Nexus Autoparts</title>
<meta name="description" content="Vitrinas de partes usadas y reconstruidas de la red Nexus Autoparts: agencias, yonkes y deshuesaderos con inventario verificado, grado de condición y vehículo donador.">
<link rel="canonical" href="{{ base_url }}/usados">
<meta property="og:type" content="website">
<meta property="og:title" content="Partes usadas y reconstruidas — Nexus Autoparts">
<style>{{ css | safe }}</style>
</head>
<body>
<header class="up"><div class="wrap">
<a class="logo" href="/catalog">NEXUS<span>AUTOPARTS</span></a>
<nav><a href="/catalog">Catálogo</a><a href="/usados">Tiendas usados</a></nav>
</div></header>
<div class="wrap">
<div class="crumb"><a href="/">Inicio</a> › Partes usadas</div>
<div class="hero">
<h1>Vitrinas de partes usadas</h1>
<p>Yonkes, deshuesaderos y refaccionarias con inventario usado verificado. Elige una tienda para ver sus piezas, grado de condición y vehículo donador.</p>
</div>
{% if stores %}
<div class="dir-grid">
{% for s in stores %}
<a class="dir-card" href="{{ base_url }}/usado/{{ s.subdomain }}">
<div class="n">{{ s.name }}</div>
<div class="c">{{ s.count }} pieza{{ 's' if s.count != 1 else '' }} usada{{ 's' if s.count != 1 else '' }} en vitrina</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="empty"><p>Aún no hay tiendas con piezas usadas publicadas.</p><p><a href="/catalog">Ver el catálogo general</a></p></div>
{% endif %}
</div>
<footer><div class="wrap">Nexus Autoparts — Partes nuevas, usadas y reconstruidas</div></footer>
</body>
</html>"""
_USED_404_TEMPLATE = """<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pieza no encontrada — Nexus Autoparts</title>
<meta name="robots" content="noindex">
<style>{{ css | safe }}</style>
</head>
<body>
<header class="up"><div class="wrap">
<a class="logo" href="/catalog">NEXUS<span>AUTOPARTS</span></a>
<nav><a href="/catalog">Catálogo</a><a href="/usados">Tiendas usados</a></nav>
</div></header>
<div class="wrap">
<div class="hero"><h1>Pieza usada no disponible</h1>
<p>La pieza que buscas no existe, ya se vendió o el enlace es incorrecto.</p></div>
<div class="empty"><p><a href="/usados">Ver todas las vitrinas de partes usadas</a> · <a href="/catalog">Ir al catálogo</a></p></div>
</div>
<footer><div class="wrap">Nexus Autoparts</div></footer>
</body>
</html>"""
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/<tenant>')
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/<tenant>/marca/<make>')
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/<tenant>/marca/<make>/<model>')
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/<tenant>/<int:part_id>')
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('/<filename>')
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)