- 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
6119 lines
261 KiB
Python
6119 lines
261 KiB
Python
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)
|