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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
54
pos/services/vin_decoder.py
Normal file
54
pos/services/vin_decoder.py
Normal 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 "",
|
||||
}
|
||||
@@ -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 ───
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user