From f9589f4a4ee6766daf9f603a16b337ce398231a9 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Sat, 4 Apr 2026 08:14:45 +0000 Subject: [PATCH] feat(pos): add inventory-aware chat context (#31) and VIN decoder (#17) Chat now fetches tenant inventory summary (brands, counts, low-stock) and injects it into the AI system prompt so responses prioritize local stock. VIN decoder uses free NHTSA vPIC API to decode 17-char VINs and auto-fills the vehicle selector dropdowns when a catalog match is found. Co-Authored-By: Claude Opus 4.6 (1M context) --- pos/blueprints/catalog_bp.py | 124 ++++++++++++++++++++++++++++++++++ pos/blueprints/chat_bp.py | 19 +++++- pos/services/ai_chat.py | 76 ++++++++++++++++++++- pos/services/vin_decoder.py | 54 +++++++++++++++ pos/static/js/catalog.js | 127 +++++++++++++++++++++++++++++++++++ pos/templates/catalog.html | 11 +++ 6 files changed, 406 insertions(+), 5 deletions(-) create mode 100644 pos/services/vin_decoder.py diff --git a/pos/blueprints/catalog_bp.py b/pos/blueprints/catalog_bp.py index bab0e1f..9e3877f 100644 --- a/pos/blueprints/catalog_bp.py +++ b/pos/blueprints/catalog_bp.py @@ -17,6 +17,7 @@ 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 catalog_bp = Blueprint('catalog', __name__, url_prefix='/pos/api/catalog') @@ -184,3 +185,126 @@ def search(): data = catalog_service.smart_search(master, q, tenant, branch_id, limit) return jsonify({'data': data}) return _with_conns(_do) + + +# ─── VIN Decoder ─── + +@catalog_bp.route('/vin/', methods=['GET']) +@require_auth('catalog.view') +def decode_vin_route(vin): + """Decode a VIN and try to match to a brand/model/year in our catalog DB.""" + vin = (vin or "").strip().upper() + if len(vin) != 17: + return jsonify({'error': 'VIN debe tener exactamente 17 caracteres.'}), 400 + + try: + info = decode_vin(vin) + except Exception as e: + return jsonify({'error': f'Error al decodificar VIN: {str(e)}'}), 502 + + if info.get('error'): + return jsonify(info), 200 # Return info even with partial errors + + # Try to match the decoded vehicle to our catalog DB + db_match = None + master = None + try: + master = get_master_conn() + db_match = _match_vin_to_catalog(master, info) + except Exception: + pass + finally: + if master: + try: + master.close() + except Exception: + pass + + result = {**info} + if db_match: + result['catalog_match'] = db_match + + return jsonify(result) + + +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() diff --git a/pos/blueprints/chat_bp.py b/pos/blueprints/chat_bp.py index 0272bae..c56fb8d 100644 --- a/pos/blueprints/chat_bp.py +++ b/pos/blueprints/chat_bp.py @@ -23,8 +23,23 @@ def chat(): history = body.get("history") or [] - # Call AI - ai_response = ai_chat.chat(user_message, history) + # Fetch inventory context so the AI knows what this tenant has in stock + inventory_context = None + tenant_for_context = None + try: + tenant_for_context = get_tenant_conn(g.tenant_id) + inventory_context = ai_chat.get_inventory_context(tenant_for_context, g.branch_id) + except Exception: + pass + finally: + if tenant_for_context: + try: + tenant_for_context.close() + except Exception: + pass + + # Call AI with inventory context + ai_response = ai_chat.chat(user_message, history, inventory_context=inventory_context) search_results = [] vehicle_match = None diff --git a/pos/services/ai_chat.py b/pos/services/ai_chat.py index 2e1bd88..10b2955 100644 --- a/pos/services/ai_chat.py +++ b/pos/services/ai_chat.py @@ -89,10 +89,80 @@ El search_query SIEMPRE debe ser en ingles (el catalogo TecDoc esta en ingles). """ -def chat(user_message, conversation_history=None): - """Send a message to the AI and get a response with search suggestions.""" +def get_inventory_context(tenant_conn, branch_id=None): + """Build a summary string of the tenant's inventory for AI context. + + Returns a string like: + Este negocio tiene 1234 productos en inventario. + Categorias: BOSCH (45), MONROE (32), ACDelco (28), ... + Productos con stock bajo (<=3): 15 + """ + cur = tenant_conn.cursor() + try: + # Total items + where = "i.is_active = true" + params = [] + if branch_id: + where += " AND i.branch_id = %s" + params.append(branch_id) + + cur.execute(f"SELECT COUNT(*) FROM inventory i WHERE {where}", params) + total = cur.fetchone()[0] or 0 + + if total == 0: + return "CONTEXTO DEL INVENTARIO:\nEste negocio aun no tiene productos en inventario." + + # Top brands with counts + cur.execute(f""" + SELECT i.brand, COUNT(*) as cnt + FROM inventory i + WHERE {where} AND i.brand IS NOT NULL AND i.brand != '' + GROUP BY i.brand + ORDER BY cnt DESC + LIMIT 15 + """, params) + brands = cur.fetchall() + brand_list = ", ".join(f"{row[0]} ({row[1]})" for row in brands if row[0]) + + # Products with low stock (<=3) + cur.execute(f""" + SELECT COUNT(*) FROM inventory i + WHERE {where} + AND COALESCE((SELECT SUM(quantity) FROM inventory_operations WHERE inventory_id = i.id), 0) <= 3 + """, params) + low_stock = cur.fetchone()[0] or 0 + + lines = [ + "CONTEXTO DEL INVENTARIO:", + f"Este negocio tiene {total} productos en inventario.", + ] + if brand_list: + lines.append(f"Marcas disponibles: {brand_list}") + lines.append(f"Productos con stock bajo (<=3 unidades): {low_stock}") + lines.append("IMPORTANTE: Cuando busques partes, SIEMPRE prioriza lo que el negocio tiene en inventario local.") + + return "\n".join(lines) + except Exception: + return "" + finally: + cur.close() + + +def chat(user_message, conversation_history=None, inventory_context=None): + """Send a message to the AI and get a response with search suggestions. + + Args: + user_message: The user's chat message. + conversation_history: Previous messages in the conversation. + inventory_context: Optional inventory summary string to inject into the system prompt. + """ _validate_model(MODEL) # Block paid models - messages = [{"role": "system", "content": SYSTEM_PROMPT}] + + system_content = SYSTEM_PROMPT + if inventory_context: + system_content = SYSTEM_PROMPT + "\n\n" + inventory_context + + messages = [{"role": "system", "content": system_content}] if conversation_history: messages.extend(conversation_history) messages.append({"role": "user", "content": user_message}) diff --git a/pos/services/vin_decoder.py b/pos/services/vin_decoder.py new file mode 100644 index 0000000..06c69c2 --- /dev/null +++ b/pos/services/vin_decoder.py @@ -0,0 +1,54 @@ +"""VIN Decoder using NHTSA vPIC API (free, no key needed).""" +import re +import requests + + +def decode_vin(vin): + """Decode a VIN number using NHTSA API. + Returns: {make, model, year, engine, body_type, plant_country, fuel_type, error} + """ + vin = (vin or "").strip().upper() + + # Validate: 17 alphanumeric chars, no I/O/Q + if not re.match(r'^[A-HJ-NPR-Z0-9]{17}$', vin): + return {"error": "VIN debe tener exactamente 17 caracteres alfanumericos (sin I, O, Q)."} + + url = f"https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVinValues/{vin}?format=json" + resp = requests.get(url, timeout=10) + resp.raise_for_status() + data = resp.json()["Results"][0] + + error_text = data.get("ErrorText", "") or "" + # NHTSA returns error codes like "0 - ..." for no error + has_real_error = error_text and not error_text.startswith("0") + + displacement = data.get("DisplacementL", "") or "" + cylinders = data.get("EngineCylinders", "") or "" + engine_parts = [] + if displacement: + engine_parts.append(f"{displacement}L") + if cylinders: + engine_parts.append(f"{cylinders}cyl") + engine_str = " ".join(engine_parts) + + model_year = data.get("ModelYear", "") + year_int = None + if model_year: + try: + year_int = int(model_year) + except (ValueError, TypeError): + pass + + return { + "vin": vin, + "make": data.get("Make", "") or "", + "model": data.get("Model", "") or "", + "year": year_int, + "engine": engine_str, + "body_type": data.get("BodyClass", "") or "", + "plant_country": data.get("PlantCountry", "") or "", + "fuel_type": data.get("FuelTypePrimary", "") or "", + "drive_type": data.get("DriveType", "") or "", + "trim": data.get("Trim", "") or "", + "error": error_text if has_real_error else "", + } diff --git a/pos/static/js/catalog.js b/pos/static/js/catalog.js index c5dd697..27af5e2 100644 --- a/pos/static/js/catalog.js +++ b/pos/static/js/catalog.js @@ -1019,6 +1019,131 @@ }); } + // ─── VIN DECODER ─── + var vinInputWrap = document.getElementById('vinInputWrap'); + var vinInput = document.getElementById('vinInput'); + var vinStatus = document.getElementById('vinStatus'); + var vinToggle = document.getElementById('vinToggle'); + + function toggleVin() { + var isVisible = vinInputWrap.style.display !== 'none'; + vinInputWrap.style.display = isVisible ? 'none' : ''; + vinToggle.textContent = isVisible ? 'Tienes el VIN?' : 'Ocultar VIN'; + if (!isVisible && vinInput) vinInput.focus(); + } + + function decodeVin() { + var vin = (vinInput.value || '').trim().toUpperCase(); + if (vin.length !== 17) { + showVinStatus('El VIN debe tener exactamente 17 caracteres.', true); + return; + } + showVinStatus('Decodificando VIN...', false); + + apiFetch(API + '/vin/' + encodeURIComponent(vin)).then(function (data) { + if (!data) { + showVinStatus('Error de conexion al decodificar VIN.', true); + return; + } + if (data.error && !data.make) { + showVinStatus(data.error, true); + return; + } + + var parts = []; + if (data.year) parts.push(data.year); + if (data.make) parts.push(data.make); + if (data.model) parts.push(data.model); + if (data.engine) parts.push(data.engine); + var label = parts.join(' ') || 'Vehiculo no reconocido'; + + // If we got a catalog match, auto-fill the dropdowns + var match = data.catalog_match; + if (match && match.brand_id) { + showVinStatus(label + ' — Encontrado en catalogo, cargando...', false); + _autoFillFromVin(match, data); + } else { + showVinStatus(label + ' — No encontrado en el catalogo TecDoc.', false); + } + }); + } + + function _autoFillFromVin(match, vinData) { + // Set year dropdown + if (match.year_id) { + vsYear.value = String(match.year_id); + // Trigger brand load + apiFetch(API + '/brands?year_id=' + match.year_id).then(function (brandData) { + var brands = brandData && (brandData.data || brandData); + if (!brands) return; + vsBrand.innerHTML = ''; + brands.forEach(function (b) { + vsBrand.innerHTML += ''; + }); + vsBrand.disabled = false; + vsClear.style.display = ''; + + if (match.brand_id) { + vsBrand.value = String(match.brand_id); + // Load models + apiFetch(API + '/models?brand_id=' + match.brand_id + '&year_id=' + match.year_id).then(function (modelData) { + var models = modelData && (modelData.data || modelData); + if (!models) return; + vsModel.innerHTML = ''; + models.forEach(function (m) { + vsModel.innerHTML += ''; + }); + vsModel.disabled = false; + + if (match.model_id) { + vsModel.value = String(match.model_id); + // Load engines + apiFetch(API + '/engines?model_id=' + match.model_id + '&year_id=' + match.year_id).then(function (engData) { + var engines = engData && (engData.data || engData); + if (!engines) return; + vsEngine.innerHTML = ''; + engines.forEach(function (e) { + var elabel = e.name_engine + (e.trim_level ? ' (' + e.trim_level + ')' : ''); + vsEngine.innerHTML += ''; + }); + vsEngine.disabled = false; + + // Auto-select engine if only one or if match specifies it + if (match.id_mye) { + vsEngine.value = String(match.id_mye); + vsEngineChanged(); + showVinStatus('Vehiculo cargado desde VIN.', false); + } else if (engines.length === 1) { + vsEngine.value = engines[0].id_mye; + vsEngineChanged(); + showVinStatus('Vehiculo cargado desde VIN.', false); + } else { + showVinStatus('Selecciona el motor para continuar.', false); + } + }); + } + }); + } + }); + } + } + + function showVinStatus(msg, isError) { + vinStatus.style.display = msg ? '' : 'none'; + vinStatus.textContent = msg; + vinStatus.style.color = isError ? 'var(--color-error)' : 'var(--color-text-muted)'; + } + + // Allow Enter key in VIN input to trigger decode + if (vinInput) { + vinInput.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + e.preventDefault(); + decodeVin(); + } + }); + } + window.CatalogApp = { toggleCart: toggleCart, goToCheckout: goToCheckout, @@ -1033,6 +1158,8 @@ vsEngineChanged: vsEngineChanged, vsClear: vsClearAll, startBarcodeScan: startBarcodeScan, + toggleVin: toggleVin, + decodeVin: decodeVin, }; // ─── INIT ─── diff --git a/pos/templates/catalog.html b/pos/templates/catalog.html index 827915f..876a57f 100644 --- a/pos/templates/catalog.html +++ b/pos/templates/catalog.html @@ -634,6 +634,17 @@ + | +
+ Tienes el VIN? + +