feat(inventory): Qwen vehicle compatibility — store AI unmatched vehicles as text, Celery background sync, fix brand filter fallback, increase vehicle limits

This commit is contained in:
2026-05-18 19:32:35 +00:00
parent 0b1dc89faf
commit 50c0dbe7d4
6 changed files with 330 additions and 109 deletions

View File

@@ -18,8 +18,10 @@ from services.audit import log_action
from tenant_db import get_master_conn
from services.inventory_vehicle_compat import (
auto_match_vehicle_compatibility, add_compatibility, remove_compatibility,
remove_all_compatibility, get_compatibility, search_mye, get_compat_source,
remove_compatibility_by_id, remove_all_compatibility, get_compatibility,
search_mye, get_compat_source,
)
from tasks import sync_vehicle_compatibility_task
inventory_bp = Blueprint('inventory', __name__, url_prefix='/pos/api/inventory')
@@ -287,7 +289,19 @@ def create_item():
compat_source = get_compat_source(g.tenant_id)
qwen_added = 0
# TecDoc auto-match
# Offload to Celery background task if possible (QWEN can take 90s+)
try:
sync_vehicle_compatibility_task.delay(
g.tenant_id, item_id, data['part_number'],
data['name'], data.get('brand', ''), compat_source
)
compat_background = True
except Exception as celery_err:
print(f"[celery] Failed to queue compatibility task for item {item_id}: {celery_err}")
compat_background = False
if not compat_background:
# Fallback: synchronous processing
if compat_source in ('tecdoc', 'both'):
try:
master = get_master_conn()
@@ -297,7 +311,6 @@ def create_item():
except Exception as am_err:
print(f"[auto_match] Error for item {item_id}: {am_err}")
# QWEN AI fitment
if compat_source in ('qwen', 'both'):
try:
from services.qwen_fitment import get_vehicle_fitment
@@ -317,6 +330,7 @@ def create_item():
'barcode': barcode,
'message': 'Item created',
'vehicle_compatibilities_added': qwen_added,
'vehicle_compat_queued': compat_background,
}), 201
except Exception as e:
@@ -1482,13 +1496,16 @@ def add_item_vehicle(item_id):
conn.close()
@inventory_bp.route('/items/<int:item_id>/vehicles/<int:mye_id>', methods=['DELETE'])
@inventory_bp.route('/items/<int:item_id>/vehicles/<int:compat_id>', methods=['DELETE'])
@require_auth('inventory.edit')
def delete_item_vehicle(item_id, mye_id):
"""Remove a vehicle compatibility."""
def delete_item_vehicle(item_id, compat_id):
"""Remove a vehicle compatibility by its row id.
Works for both TecDoc-linked (mye_id present) and text-only QWEN records.
"""
conn = get_tenant_conn(g.tenant_id)
try:
deleted = remove_compatibility(conn, item_id, mye_id)
deleted = remove_compatibility_by_id(conn, compat_id)
return jsonify({'message': 'Compatibility removed', 'deleted': deleted})
finally:
conn.close()

View File

@@ -0,0 +1,53 @@
-- v3.2 QWEN Vehicle Compatibility — store unmatched AI vehicles as text
-- Allows saving QWEN fitment results even when the vehicle is not in TecDoc.
-- 1. Allow NULL model_year_engine_id for QWEN vehicles not in master DB
ALTER TABLE inventory_vehicle_compat
ALTER COLUMN model_year_engine_id DROP NOT NULL;
-- 2. Add text columns for QWEN vehicle details
ALTER TABLE inventory_vehicle_compat
ADD COLUMN IF NOT EXISTS make VARCHAR(100),
ADD COLUMN IF NOT EXISTS model VARCHAR(100),
ADD COLUMN IF NOT EXISTS year INTEGER,
ADD COLUMN IF NOT EXISTS engine VARCHAR(100),
ADD COLUMN IF NOT EXISTS engine_code VARCHAR(50);
-- 3. Drop old unique constraint and recreate to handle NULL mye_id
-- (PostgreSQL allows multiple NULLs in a UNIQUE constraint)
ALTER TABLE inventory_vehicle_compat
DROP CONSTRAINT IF EXISTS inventory_vehicle_compat_inventory_id_model_year_engine_id_key;
ALTER TABLE inventory_vehicle_compat
ADD CONSTRAINT inventory_vehicle_compat_unique_match
UNIQUE (inventory_id, model_year_engine_id, make, model, year);
-- 4. Index for fast filtering by inventory + text vehicles
CREATE INDEX IF NOT EXISTS idx_ivc_text_vehicle
ON inventory_vehicle_compat(inventory_id, make, model, year)
WHERE model_year_engine_id IS NULL;
-- 5. Update view to include new columns
DROP VIEW IF EXISTS v_inventory_vehicle_compat;
CREATE VIEW v_inventory_vehicle_compat AS
SELECT
ivc.id,
ivc.inventory_id,
ivc.model_year_engine_id,
ivc.make,
ivc.model,
ivc.year,
ivc.engine,
ivc.engine_code,
ivc.source,
ivc.confidence,
ivc.created_at,
i.part_number,
i.name as item_name,
i.brand as item_brand,
i.price_1,
i.price_2,
i.price_3,
i.image_url
FROM inventory_vehicle_compat ivc
JOIN inventory i ON i.id = ivc.inventory_id;

View File

@@ -178,18 +178,31 @@ def auto_match_vehicle_compatibility(master_conn, tenant_conn, inventory_id, par
JOIN brands b ON b.id_brand = m.brand_id
WHERE vp.part_id = ANY(%s)
AND b.name_brand = %s
LIMIT 200
LIMIT 500
""", (oem_ids, brand_hint))
mye_ids = [r[0] for r in cur.fetchall()]
# Fallback: if brand filter yields nothing, the brand hint may be an
# aftermarket supplier (e.g. Motorcraft, NGK, Bosch) rather than an
# OEM vehicle brand. Search without brand filter.
if not mye_ids:
cur.execute("""
SELECT DISTINCT model_year_engine_id
FROM vehicle_parts
WHERE part_id = ANY(%s)
LIMIT 500
""", (oem_ids,))
mye_ids = [r[0] for r in cur.fetchall()]
else:
# No brand hint — return all MYEs for these parts
cur.execute("""
SELECT DISTINCT model_year_engine_id
FROM vehicle_parts
WHERE part_id = ANY(%s)
LIMIT 200
LIMIT 500
""", (oem_ids,))
mye_ids = [r[0] for r in cur.fetchall()]
cur.close()
# ── Insert into tenant table ─────────────────────────────────────────
@@ -243,6 +256,20 @@ def remove_compatibility(tenant_conn, inventory_id, model_year_engine_id):
return deleted
def remove_compatibility_by_id(tenant_conn, compat_id):
"""Remove a compatibility by its primary key (works for both MYE-linked
and text-only QWEN records)."""
cur = tenant_conn.cursor()
cur.execute("""
DELETE FROM inventory_vehicle_compat
WHERE id = %s
""", (compat_id,))
deleted = cur.rowcount
tenant_conn.commit()
cur.close()
return deleted
def remove_all_compatibility(tenant_conn, inventory_id):
cur = tenant_conn.cursor()
cur.execute("""
@@ -259,14 +286,18 @@ def get_compatibility(tenant_conn, master_conn, inventory_id):
Queries inventory_vehicle_compat from the tenant DB, then resolves
vehicle details (brand/model/year/engine) from the master DB.
Vehicles with model_year_engine_id IS NULL are text-only QWEN records
(master DB lacks the vehicle) and are returned using their stored text.
"""
# 1. Get MYE IDs + metadata from tenant
# 1. Get all rows from tenant
cur_t = tenant_conn.cursor()
cur_t.execute("""
SELECT model_year_engine_id, source, confidence, created_at
SELECT id, model_year_engine_id, make, model, year, engine, engine_code,
source, confidence, created_at
FROM inventory_vehicle_compat
WHERE inventory_id = %s
ORDER BY model_year_engine_id
ORDER BY COALESCE(make, ''), COALESCE(model, ''), COALESCE(year, 0)
""", (inventory_id,))
rows = cur_t.fetchall()
cur_t.close()
@@ -274,9 +305,10 @@ def get_compatibility(tenant_conn, master_conn, inventory_id):
if not rows:
return []
mye_ids = [r[0] for r in rows]
# 2. Resolve vehicle details from master DB
# 2. Resolve MYE-linked vehicles from master DB
mye_ids = [r[0] for r in rows if r[0] is not None]
details = {}
if mye_ids:
cur_m = master_conn.cursor()
cur_m.execute("""
SELECT mye.id_mye, b.name_brand, m.name_model, y.year_car, e.name_engine
@@ -293,15 +325,32 @@ def get_compatibility(tenant_conn, master_conn, inventory_id):
# 3. Merge
result = []
for mye_id, source, confidence, created_at in rows:
d = details.get(mye_id)
if d:
for (compat_id, mye_id, make, model, year, engine, engine_code,
source, confidence, created_at) in rows:
if mye_id is not None and mye_id in details:
d = details[mye_id]
result.append({
'id': compat_id,
'model_year_engine_id': mye_id,
'brand': d[1],
'model': d[2],
'year': d[3],
'engine': d[4],
'engine_code': '',
'source': source,
'confidence': float(confidence),
'created_at': str(created_at),
})
else:
# Text-only QWEN record
result.append({
'id': compat_id,
'model_year_engine_id': None,
'brand': make or '',
'model': model or '',
'year': year,
'engine': engine or '',
'engine_code': engine_code or '',
'source': source,
'confidence': float(confidence),
'created_at': str(created_at),
@@ -374,6 +423,9 @@ def batch_add_compatibilities(tenant_conn, inventory_id, mye_ids, source='manual
def save_qwen_fitment(tenant_conn, inventory_id, fitment_result):
"""Save QWEN fitment results into inventory_vehicle_compat.
Supports both TecDoc-linked vehicles (mye_id present) and text-only
QWEN vehicles (mye_id=None) when the master DB lacks the vehicle.
Args:
tenant_conn: Connection to tenant DB.
inventory_id: The inventory item ID.
@@ -390,14 +442,30 @@ def save_qwen_fitment(tenant_conn, inventory_id, fitment_result):
cur = tenant_conn.cursor()
for v in vehicles:
mye_id = v.get('mye_id')
if not mye_id:
continue
if mye_id is not None and mye_id:
# TecDoc-linked vehicle
cur.execute("""
INSERT INTO inventory_vehicle_compat
(inventory_id, model_year_engine_id, source, confidence, created_at)
VALUES (%s, %s, 'qwen_ai', %s, NOW())
ON CONFLICT (inventory_id, model_year_engine_id) DO NOTHING
ON CONFLICT (inventory_id, model_year_engine_id, make, model, year) DO NOTHING
""", (inventory_id, mye_id, fitment_result.get('confidence', 0)))
else:
# Text-only QWEN vehicle (master DB doesn't have this vehicle)
cur.execute("""
INSERT INTO inventory_vehicle_compat
(inventory_id, model_year_engine_id, make, model, year, engine, engine_code, source, confidence, created_at)
VALUES (%s, NULL, %s, %s, %s, %s, %s, 'qwen_ai', %s, NOW())
ON CONFLICT (inventory_id, model_year_engine_id, make, model, year) DO NOTHING
""", (
inventory_id,
v.get('make', '') or '',
v.get('model', '') or '',
v.get('year', 0) or 0,
v.get('engine', '') or '',
v.get('engine_code', '') or '',
fitment_result.get('confidence', 0),
))
if cur.rowcount > 0:
inserted += 1
tenant_conn.commit()

View File

@@ -42,16 +42,22 @@ def get_vehicle_fitment(part_number, name, brand):
{'role': 'user', 'content': prompt}
],
'temperature': 0.2,
'max_tokens': 4096,
'max_tokens': 8192,
},
timeout=45,
timeout=120,
)
response.raise_for_status()
raw = response.json()
finish_reason = None
if raw.get('choices') and len(raw['choices']) > 0:
msg = raw['choices'][0].get('message', {})
choice = raw['choices'][0]
msg = choice.get('message', {})
finish_reason = choice.get('finish_reason')
if msg:
content = msg.get('content') or ''
if not content:
# Fallback for reasoning models that return output in reasoning_content
content = msg.get('reasoning_content') or ''
if content:
break
except requests.RequestException as exc:
@@ -62,6 +68,8 @@ def get_vehicle_fitment(part_number, name, brand):
if not content:
err_msg = f'QWEN request failed: {last_error}' if last_error else 'Empty response from QWEN after 3 attempts'
if finish_reason == 'length':
err_msg += ' (response truncated by token limit — consider reducing prompt or increasing max_tokens)'
return {'vehicles': [], 'confidence': 0, 'notes': err_msg}
# Parse JSON from QWEN response (sometimes wrapped in markdown)
@@ -91,32 +99,21 @@ def _build_prompt(part_number, name, brand):
- Nombre/descripcion: {name}
- Marca del fabricante: {brand_str}
Devuelve UNICAMENTE un JSON valido (sin markdown, sin backticks, sin texto adicional) con esta estructura exacta:
{{
"vehicles": [
{{
"make": "Toyota",
"model": "Corolla",
"year": 2015,
"engine": "1.8L 16V",
"engine_code": "2ZR-FE",
"notes": "Sedan y hatchback"
}}
],
"confidence": 0.92,
"notes": "Compatible con plataforma E170. Verificar traccion delantera."
}}
Devuelve UNICAMENTE un JSON valido (sin markdown, sin backticks, sin texto adicional) con esta estructura:
{{"vehicles":[{{"make":"Toyota","model":"Corolla","year_range":"2014-2019","engine":"1.8L","engine_code":"2ZR-FE","notes":""}}],"confidence":0.92,"notes":""}}
Reglas obligatorias:
1. "make" = marca del vehiculo (ej: Toyota, Nissan, Ford, Volkswagen, Chevrolet, Honda, Hyundai, Kia, Mazda, Subaru).
2. "model" = modelo exacto. Si hay variantes (ej: Civic Sedan vs Civic Coupe), incluye la variante.
3. "year" = ano numerico (int). Si hay rango de anos (ej: 2003-2008), genera una entrada POR CADA ANO del rango. NO uses rangos.
4. "engine" = descripcion del motor (ej: "1.8L", "2.0L TDI", "V6 3.5L", "1.6L Turbo"). Si no conoces el motor, usa "desconocido".
5. "engine_code" = codigo exacto del motor SI LO CONOCES (ej: "2ZR-FE", "K24Z7", "EA888"). Si no lo conoces, usa "" (string vacio).
6. Devuelve TODOS los vehiculos compatibles que conozcas. Minimo 1, maximo 100. Para piezas genericas (bujias, filtros, balatas, amortiguadores) incluye TODOS los modelos aplicables.
7. "confidence" entre 0.0 y 1.0. Usa valores altos (>0.85) solo si estas muy seguro.
8. Incluye marcas y modelos populares en Mexico (Nissan Tsuru, VW Sedan/Vocho, Chevy Monza, Ford Ka, etc.) cuando apliquen.
9. Si la pieza es universal o de alta compatibilidad, indicalo en "notes".
REGLAS OBLIGATORIAS:
1. "make" = marca del vehiculo.
2. "model" = modelo exacto (incluye variante si aplica).
3. USA "year_range" = string "YYYY-YYYY" cuando el MISMO modelo/motor abarca multiples anos consecutivos. Esto ahorra tokens y permite mas resultados.
4. USA "year" = int SOLO cuando sea un ano aislado sin rangos adyacentes.
5. "engine" = descripcion corta del motor (ej: "1.8L", "V6 3.5L"). Si no lo conoces, usa "".
6. "engine_code" = codigo exacto SI LO CONOCES. Si no, usa "".
7. "notes" = string vacio "" para ahorrar tokens, salvo que haya una advertencia critica.
8. Devuelve TODOS los vehiculos compatibles que conozcas. Minimo 1, maximo 200. Para piezas genericas (filtros de aceite, bujias, balatas, amortiguadores) incluye TODOS los modelos aplicables.
9. "confidence" entre 0.0 y 1.0. Valores >0.85 solo si estas muy seguro.
10. Incluye marcas y modelos populares en Mexico cuando apliquen.
11. Si la pieza es universal, indicalo en "notes".
"""
@@ -153,25 +150,39 @@ def _extract_vehicles(parsed):
def _normalize_vehicle(v):
"""Normalize vehicle dict from QWEN to standard keys."""
"""Normalize vehicle dict from QWEN to standard keys.
Supports:
- year: int or str (single year)
- year_range: str like "2003-2008" or "2003-2008"
- legacy: year as range string
"""
make = v.get('make') or v.get('marca') or ''
model = v.get('model') or v.get('modelo') or ''
year_raw = v.get('year') or v.get('ano') or v.get('año') or v.get('years') or v.get('anos') or ''
engine = v.get('engine') or v.get('motor') or ''
engine_code = v.get('engine_code') or v.get('codigo_motor') or v.get('motor_code') or ''
# Parse year (may be int, string, or range like "2003-2008")
years = []
# Prefer explicit year_range
year_range = v.get('year_range') or v.get('rango_ano') or ''
if isinstance(year_range, str):
m = re.match(r'(\d{4})\s*[-]\s*(\d{4})', year_range)
if m:
start, end = int(m.group(1)), int(m.group(2))
years = list(range(start, end + 1))
# Fallback to year (int or str)
if not years:
year_raw = v.get('year') or v.get('ano') or v.get('año') or v.get('years') or v.get('anos') or ''
if isinstance(year_raw, int):
years = [year_raw]
elif isinstance(year_raw, str):
# Try range "2003-2008"
m = re.match(r'(\d{4})\s*[-]\s*(\d{4})', year_raw)
if m:
start, end = int(m.group(1)), int(m.group(2))
years = list(range(start, end + 1))
else:
# Try single year
m2 = re.match(r'(\d{4})', year_raw)
if m2:
years = [int(m2.group(1))]
@@ -200,16 +211,30 @@ def _validate_vehicles(vehicles):
1. Exact engine_code match (most precise)
2. Displacement-based match (e.g. all 1.8L engines for that make/model/year)
3. Broad make/model/year match (all engines for that make/model/year)
If the master DB does not contain the vehicle (e.g. North-American models
missing from TecDoc), the vehicle is returned with mye_id=None so it can
be stored as a text-only QWEN record.
"""
from tenant_db import get_master_conn
try:
master = get_master_conn()
cur = master.cursor()
except Exception:
return []
# Master DB unreachable — return all vehicles as unmatched text
return [
{'make': v.get('make') or v.get('marca') or '',
'model': v.get('model') or v.get('modelo') or '',
'year': v.get('year') or v.get('ano') or v.get('año') or 0,
'engine': v.get('engine') or v.get('motor') or '',
'engine_code': v.get('engine_code') or v.get('codigo_motor') or '',
'mye_id': None}
for v in vehicles
]
validated = []
seen_mye = set()
seen_text = set() # (make, model, year) for text-only dedup
for v in vehicles:
make, model, years, engine, engine_code = _normalize_vehicle(v)
@@ -285,6 +310,7 @@ def _validate_vehicles(vehicles):
matched_myes = [r[0] for r in cur.fetchall()]
# Deduplicate and add to results
if matched_myes:
for mye_id in matched_myes:
if mye_id not in seen_mye:
seen_mye.add(mye_id)
@@ -296,6 +322,19 @@ def _validate_vehicles(vehicles):
'engine_code': engine_code,
'mye_id': mye_id,
})
else:
# No match in master DB — store as text-only QWEN record
text_key = (make.upper(), model.upper(), year)
if text_key not in seen_text:
seen_text.add(text_key)
validated.append({
'make': make,
'model': model,
'year': year,
'engine': engine,
'engine_code': engine_code,
'mye_id': None,
})
cur.close()
master.close()

View File

@@ -843,7 +843,7 @@
list.forEach(function(c) {
var sourceLabel = c.source === 'qwen_ai' ? '<span style="background:var(--color-primary);color:#000;padding:2px 6px;border-radius:4px;font-size:10px;font-weight:600;">IA</span>' : (c.source === 'auto_match' ? '<span style="background:var(--color-success);color:#fff;padding:2px 6px;border-radius:4px;font-size:10px;font-weight:600;">TecDoc</span>' : esc(c.source || ''));
html2 += '<tr><td>' + esc(c.brand || '') + '</td><td>' + esc(c.model || '') + '</td><td>' + esc(c.year || '') + '</td><td>' + esc(c.engine || '') + '</td><td>' + sourceLabel + '</td>';
html2 += '<td><button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="removeCompat(' + itemId + ',' + c.model_year_engine_id + ')">Quitar</button></td></tr>';
html2 += '<td><button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="removeCompat(' + itemId + ',' + c.id + ')">Quitar</button></td></tr>';
});
html2 += '</tbody></table>';
} else {
@@ -907,9 +907,9 @@
}).catch(function() { alert('Error en auto-match'); });
}
function removeCompat(itemId, myeId) {
function removeCompat(itemId, compatId) {
if (!confirm('Quitar compatibilidad con este vehiculo?')) return;
fetch('/pos/api/inventory/items/' + itemId + '/compatibility/' + myeId, {
fetch('/pos/api/inventory/items/' + itemId + '/vehicles/' + compatId, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token }
}).then(function(r) { return r.json(); })

View File

@@ -96,3 +96,47 @@ def generate_report_task(self, report_type, params, tenant_id):
'status': 'completed',
'url': f'/pos/static/reports/{report_type}_{tenant_id}.pdf',
}
@celery.task(bind=True, max_retries=2)
def sync_vehicle_compatibility_task(self, tenant_id, item_id, part_number, name, brand, compat_source):
"""Fetch AI/TecDoc vehicle compatibility in background after item creation."""
from tenant_db import get_tenant_conn, get_master_conn
from services.inventory_vehicle_compat import auto_match_vehicle_compatibility, save_qwen_fitment
from services.qwen_fitment import get_vehicle_fitment
tenant_conn = None
master_conn = None
try:
tenant_conn = get_tenant_conn(tenant_id)
if compat_source in ('tecdoc', 'both'):
try:
master_conn = get_master_conn()
auto_match_vehicle_compatibility(
master_conn, tenant_conn, item_id, part_number,
brand=brand, name=name
)
master_conn.close()
master_conn = None
except Exception as am_err:
print(f"[sync_vehicle_compat] TecDoc error for item {item_id}: {am_err}")
if compat_source in ('qwen', 'both'):
try:
fitment = get_vehicle_fitment(part_number, name, brand or '')
save_qwen_fitment(tenant_conn, item_id, fitment)
except Exception as qwen_err:
print(f"[sync_vehicle_compat] QWEN error for item {item_id}: {qwen_err}")
tenant_conn.commit()
return {'status': 'ok', 'item_id': item_id, 'tenant_id': tenant_id}
except Exception as exc:
if tenant_conn:
tenant_conn.rollback()
raise self.retry(exc=exc, countdown=10)
finally:
if tenant_conn:
tenant_conn.close()
if master_conn:
master_conn.close()