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 tenant_db import get_master_conn
from services.inventory_vehicle_compat import ( from services.inventory_vehicle_compat import (
auto_match_vehicle_compatibility, add_compatibility, remove_compatibility, 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') 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) compat_source = get_compat_source(g.tenant_id)
qwen_added = 0 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'): if compat_source in ('tecdoc', 'both'):
try: try:
master = get_master_conn() master = get_master_conn()
@@ -297,7 +311,6 @@ def create_item():
except Exception as am_err: except Exception as am_err:
print(f"[auto_match] Error for item {item_id}: {am_err}") print(f"[auto_match] Error for item {item_id}: {am_err}")
# QWEN AI fitment
if compat_source in ('qwen', 'both'): if compat_source in ('qwen', 'both'):
try: try:
from services.qwen_fitment import get_vehicle_fitment from services.qwen_fitment import get_vehicle_fitment
@@ -317,6 +330,7 @@ def create_item():
'barcode': barcode, 'barcode': barcode,
'message': 'Item created', 'message': 'Item created',
'vehicle_compatibilities_added': qwen_added, 'vehicle_compatibilities_added': qwen_added,
'vehicle_compat_queued': compat_background,
}), 201 }), 201
except Exception as e: except Exception as e:
@@ -1482,13 +1496,16 @@ def add_item_vehicle(item_id):
conn.close() 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') @require_auth('inventory.edit')
def delete_item_vehicle(item_id, mye_id): def delete_item_vehicle(item_id, compat_id):
"""Remove a vehicle compatibility.""" """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) conn = get_tenant_conn(g.tenant_id)
try: try:
deleted = remove_compatibility(conn, item_id, mye_id) deleted = remove_compatibility_by_id(conn, compat_id)
return jsonify({'message': 'Compatibility removed', 'deleted': deleted}) return jsonify({'message': 'Compatibility removed', 'deleted': deleted})
finally: finally:
conn.close() 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 JOIN brands b ON b.id_brand = m.brand_id
WHERE vp.part_id = ANY(%s) WHERE vp.part_id = ANY(%s)
AND b.name_brand = %s AND b.name_brand = %s
LIMIT 200 LIMIT 500
""", (oem_ids, brand_hint)) """, (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: else:
# No brand hint — return all MYEs for these parts # No brand hint — return all MYEs for these parts
cur.execute(""" cur.execute("""
SELECT DISTINCT model_year_engine_id SELECT DISTINCT model_year_engine_id
FROM vehicle_parts FROM vehicle_parts
WHERE part_id = ANY(%s) WHERE part_id = ANY(%s)
LIMIT 200 LIMIT 500
""", (oem_ids,)) """, (oem_ids,))
mye_ids = [r[0] for r in cur.fetchall()] mye_ids = [r[0] for r in cur.fetchall()]
cur.close() cur.close()
# ── Insert into tenant table ───────────────────────────────────────── # ── Insert into tenant table ─────────────────────────────────────────
@@ -243,6 +256,20 @@ def remove_compatibility(tenant_conn, inventory_id, model_year_engine_id):
return deleted 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): def remove_all_compatibility(tenant_conn, inventory_id):
cur = tenant_conn.cursor() cur = tenant_conn.cursor()
cur.execute(""" 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 Queries inventory_vehicle_compat from the tenant DB, then resolves
vehicle details (brand/model/year/engine) from the master DB. 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 = tenant_conn.cursor()
cur_t.execute(""" 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 FROM inventory_vehicle_compat
WHERE inventory_id = %s WHERE inventory_id = %s
ORDER BY model_year_engine_id ORDER BY COALESCE(make, ''), COALESCE(model, ''), COALESCE(year, 0)
""", (inventory_id,)) """, (inventory_id,))
rows = cur_t.fetchall() rows = cur_t.fetchall()
cur_t.close() cur_t.close()
@@ -274,9 +305,10 @@ def get_compatibility(tenant_conn, master_conn, inventory_id):
if not rows: if not rows:
return [] return []
mye_ids = [r[0] for r in rows] # 2. Resolve MYE-linked vehicles from master DB
mye_ids = [r[0] for r in rows if r[0] is not None]
# 2. Resolve vehicle details from master DB details = {}
if mye_ids:
cur_m = master_conn.cursor() cur_m = master_conn.cursor()
cur_m.execute(""" cur_m.execute("""
SELECT mye.id_mye, b.name_brand, m.name_model, y.year_car, e.name_engine 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 # 3. Merge
result = [] result = []
for mye_id, source, confidence, created_at in rows: for (compat_id, mye_id, make, model, year, engine, engine_code,
d = details.get(mye_id) source, confidence, created_at) in rows:
if d: if mye_id is not None and mye_id in details:
d = details[mye_id]
result.append({ result.append({
'id': compat_id,
'model_year_engine_id': mye_id, 'model_year_engine_id': mye_id,
'brand': d[1], 'brand': d[1],
'model': d[2], 'model': d[2],
'year': d[3], 'year': d[3],
'engine': d[4], '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, 'source': source,
'confidence': float(confidence), 'confidence': float(confidence),
'created_at': str(created_at), '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): def save_qwen_fitment(tenant_conn, inventory_id, fitment_result):
"""Save QWEN fitment results into inventory_vehicle_compat. """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: Args:
tenant_conn: Connection to tenant DB. tenant_conn: Connection to tenant DB.
inventory_id: The inventory item ID. inventory_id: The inventory item ID.
@@ -390,14 +442,30 @@ def save_qwen_fitment(tenant_conn, inventory_id, fitment_result):
cur = tenant_conn.cursor() cur = tenant_conn.cursor()
for v in vehicles: for v in vehicles:
mye_id = v.get('mye_id') mye_id = v.get('mye_id')
if not mye_id: if mye_id is not None and mye_id:
continue # TecDoc-linked vehicle
cur.execute(""" cur.execute("""
INSERT INTO inventory_vehicle_compat INSERT INTO inventory_vehicle_compat
(inventory_id, model_year_engine_id, source, confidence, created_at) (inventory_id, model_year_engine_id, source, confidence, created_at)
VALUES (%s, %s, 'qwen_ai', %s, NOW()) 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))) """, (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: if cur.rowcount > 0:
inserted += 1 inserted += 1
tenant_conn.commit() tenant_conn.commit()

View File

@@ -42,16 +42,22 @@ def get_vehicle_fitment(part_number, name, brand):
{'role': 'user', 'content': prompt} {'role': 'user', 'content': prompt}
], ],
'temperature': 0.2, 'temperature': 0.2,
'max_tokens': 4096, 'max_tokens': 8192,
}, },
timeout=45, timeout=120,
) )
response.raise_for_status() response.raise_for_status()
raw = response.json() raw = response.json()
finish_reason = None
if raw.get('choices') and len(raw['choices']) > 0: 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: if msg:
content = msg.get('content') or '' 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: if content:
break break
except requests.RequestException as exc: except requests.RequestException as exc:
@@ -62,6 +68,8 @@ def get_vehicle_fitment(part_number, name, brand):
if not content: if not content:
err_msg = f'QWEN request failed: {last_error}' if last_error else 'Empty response from QWEN after 3 attempts' 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} return {'vehicles': [], 'confidence': 0, 'notes': err_msg}
# Parse JSON from QWEN response (sometimes wrapped in markdown) # Parse JSON from QWEN response (sometimes wrapped in markdown)
@@ -91,32 +99,21 @@ def _build_prompt(part_number, name, brand):
- Nombre/descripcion: {name} - Nombre/descripcion: {name}
- Marca del fabricante: {brand_str} - Marca del fabricante: {brand_str}
Devuelve UNICAMENTE un JSON valido (sin markdown, sin backticks, sin texto adicional) con esta estructura exacta: 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":""}}
"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."
}}
Reglas obligatorias: REGLAS OBLIGATORIAS:
1. "make" = marca del vehiculo (ej: Toyota, Nissan, Ford, Volkswagen, Chevrolet, Honda, Hyundai, Kia, Mazda, Subaru). 1. "make" = marca del vehiculo.
2. "model" = modelo exacto. Si hay variantes (ej: Civic Sedan vs Civic Coupe), incluye la variante. 2. "model" = modelo exacto (incluye variante si aplica).
3. "year" = ano numerico (int). Si hay rango de anos (ej: 2003-2008), genera una entrada POR CADA ANO del rango. NO uses rangos. 3. USA "year_range" = string "YYYY-YYYY" cuando el MISMO modelo/motor abarca multiples anos consecutivos. Esto ahorra tokens y permite mas resultados.
4. "engine" = descripcion del motor (ej: "1.8L", "2.0L TDI", "V6 3.5L", "1.6L Turbo"). Si no conoces el motor, usa "desconocido". 4. USA "year" = int SOLO cuando sea un ano aislado sin rangos adyacentes.
5. "engine_code" = codigo exacto del motor SI LO CONOCES (ej: "2ZR-FE", "K24Z7", "EA888"). Si no lo conoces, usa "" (string vacio). 5. "engine" = descripcion corta del motor (ej: "1.8L", "V6 3.5L"). Si no lo conoces, usa "".
6. Devuelve TODOS los vehiculos compatibles que conozcas. Minimo 1, maximo 100. Para piezas genericas (bujias, filtros, balatas, amortiguadores) incluye TODOS los modelos aplicables. 6. "engine_code" = codigo exacto SI LO CONOCES. Si no, usa "".
7. "confidence" entre 0.0 y 1.0. Usa valores altos (>0.85) solo si estas muy seguro. 7. "notes" = string vacio "" para ahorrar tokens, salvo que haya una advertencia critica.
8. Incluye marcas y modelos populares en Mexico (Nissan Tsuru, VW Sedan/Vocho, Chevy Monza, Ford Ka, etc.) cuando apliquen. 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. Si la pieza es universal o de alta compatibilidad, indicalo en "notes". 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): 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 '' make = v.get('make') or v.get('marca') or ''
model = v.get('model') or v.get('modelo') 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 = 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 '' 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 = [] 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): if isinstance(year_raw, int):
years = [year_raw] years = [year_raw]
elif isinstance(year_raw, str): elif isinstance(year_raw, str):
# Try range "2003-2008"
m = re.match(r'(\d{4})\s*[-]\s*(\d{4})', year_raw) m = re.match(r'(\d{4})\s*[-]\s*(\d{4})', year_raw)
if m: if m:
start, end = int(m.group(1)), int(m.group(2)) start, end = int(m.group(1)), int(m.group(2))
years = list(range(start, end + 1)) years = list(range(start, end + 1))
else: else:
# Try single year
m2 = re.match(r'(\d{4})', year_raw) m2 = re.match(r'(\d{4})', year_raw)
if m2: if m2:
years = [int(m2.group(1))] years = [int(m2.group(1))]
@@ -200,16 +211,30 @@ def _validate_vehicles(vehicles):
1. Exact engine_code match (most precise) 1. Exact engine_code match (most precise)
2. Displacement-based match (e.g. all 1.8L engines for that make/model/year) 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) 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 from tenant_db import get_master_conn
try: try:
master = get_master_conn() master = get_master_conn()
cur = master.cursor() cur = master.cursor()
except Exception: 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 = [] validated = []
seen_mye = set() seen_mye = set()
seen_text = set() # (make, model, year) for text-only dedup
for v in vehicles: for v in vehicles:
make, model, years, engine, engine_code = _normalize_vehicle(v) 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()] matched_myes = [r[0] for r in cur.fetchall()]
# Deduplicate and add to results # Deduplicate and add to results
if matched_myes:
for mye_id in matched_myes: for mye_id in matched_myes:
if mye_id not in seen_mye: if mye_id not in seen_mye:
seen_mye.add(mye_id) seen_mye.add(mye_id)
@@ -296,6 +322,19 @@ def _validate_vehicles(vehicles):
'engine_code': engine_code, 'engine_code': engine_code,
'mye_id': mye_id, '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() cur.close()
master.close() master.close()

View File

@@ -843,7 +843,7 @@
list.forEach(function(c) { 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 || '')); 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 += '<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>'; html2 += '</tbody></table>';
} else { } else {
@@ -907,9 +907,9 @@
}).catch(function() { alert('Error en auto-match'); }); }).catch(function() { alert('Error en auto-match'); });
} }
function removeCompat(itemId, myeId) { function removeCompat(itemId, compatId) {
if (!confirm('Quitar compatibilidad con este vehiculo?')) return; 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', method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token } headers: { 'Authorization': 'Bearer ' + token }
}).then(function(r) { return r.json(); }) }).then(function(r) { return r.json(); })

View File

@@ -96,3 +96,47 @@ def generate_report_task(self, report_type, params, tenant_id):
'status': 'completed', 'status': 'completed',
'url': f'/pos/static/reports/{report_type}_{tenant_id}.pdf', '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()