Files
Autoparts-DB/pos/blueprints/catalog_bp.py
consultoria-as e95f7cf684 feat: complete session — catalog, marketplace, WhatsApp, peer-to-peer, install scripts
Major features:
- Pixel-Perfect glassmorphism design (landing + POS + public catalog)
- OEM/Local catalog toggle with Nexpart taxonomy (14 groups, 108 subgroups, 558 part types)
- Marketplace B2B Phase 1 (bodegas, POs, status machine, WA+email notifications)
- Peer-to-peer inventory (multi-instance, LAN discovery)
- WhatsApp: photo→Vision AI, voice→Whisper, conversational quotations
- Smart unified search (VIN/plate/part_number/keyword auto-detect)
- Shop Supplies tab (vehicle-independent parts)
- Chatbot AI fallback chain (5 models) + response cache
- CSV inventory import tool + setup_instance.sh installer
- Tablet-responsive CSS + sidebar toggle
- Filters, export CSV, employee edit, business data save
- Quotation system (WA→POS) with auto-print on confirmation
- Live stats on landing page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:35:53 +00:00

571 lines
20 KiB
Python

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