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) <noreply@anthropic.com>
This commit is contained in:
2026-04-04 08:14:45 +00:00
parent 5d5a2777eb
commit f9589f4a4e
6 changed files with 406 additions and 5 deletions

View File

@@ -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/<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()

View File

@@ -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

View File

@@ -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})

View File

@@ -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 "",
}

View File

@@ -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 = '<option value="">Marca...</option>';
brands.forEach(function (b) {
vsBrand.innerHTML += '<option value="' + b.id_brand + '">' + esc(b.name_brand) + '</option>';
});
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 = '<option value="">Modelo...</option>';
models.forEach(function (m) {
vsModel.innerHTML += '<option value="' + m.id_model + '">' + esc(m.display_name || m.name_model) + '</option>';
});
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 = '<option value="">Motor...</option>';
engines.forEach(function (e) {
var elabel = e.name_engine + (e.trim_level ? ' (' + e.trim_level + ')' : '');
vsEngine.innerHTML += '<option value="' + e.id_mye + '">' + esc(elabel) + '</option>';
});
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 ───

View File

@@ -634,6 +634,17 @@
</select>
</div>
<button class="vs-clear" id="vsClear" onclick="CatalogApp.vsClear()" title="Limpiar seleccion" style="display:none;"></button>
<span class="vs-vin-divider" style="color:var(--color-text-disabled);padding-bottom:6px;flex-shrink:0;">|</span>
<div class="vs-group" id="vinGroup" style="position:relative;">
<a class="vs-label" id="vinToggle" href="#" onclick="event.preventDefault();CatalogApp.toggleVin();" style="color:var(--color-primary);cursor:pointer;text-decoration:underline;white-space:nowrap;">Tienes el VIN?</a>
<div id="vinInputWrap" style="display:none;">
<div style="display:flex;gap:4px;">
<input type="text" class="vs-select" id="vinInput" placeholder="Ej: 1HGBH41JXMN109186" maxlength="17" style="text-transform:uppercase;font-family:var(--font-mono,monospace);letter-spacing:0.05em;flex:1;" />
<button class="btn btn-primary" id="vinDecodeBtn" onclick="CatalogApp.decodeVin()" style="height:auto;padding:var(--space-2) var(--space-3);font-size:var(--text-body-sm);">Decodificar</button>
</div>
<div id="vinStatus" style="font-size:var(--text-caption);margin-top:4px;color:var(--color-text-muted);display:none;"></div>
</div>
</div>
</div>
</div>