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:
@@ -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,29 +289,40 @@ 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+)
|
||||||
if compat_source in ('tecdoc', 'both'):
|
try:
|
||||||
try:
|
sync_vehicle_compatibility_task.delay(
|
||||||
master = get_master_conn()
|
g.tenant_id, item_id, data['part_number'],
|
||||||
auto_match_vehicle_compatibility(master, conn, item_id, data['part_number'],
|
data['name'], data.get('brand', ''), compat_source
|
||||||
brand=data.get('brand'), name=data.get('name'))
|
)
|
||||||
master.close()
|
compat_background = True
|
||||||
except Exception as am_err:
|
except Exception as celery_err:
|
||||||
print(f"[auto_match] Error for item {item_id}: {am_err}")
|
print(f"[celery] Failed to queue compatibility task for item {item_id}: {celery_err}")
|
||||||
|
compat_background = False
|
||||||
|
|
||||||
# QWEN AI fitment
|
if not compat_background:
|
||||||
if compat_source in ('qwen', 'both'):
|
# Fallback: synchronous processing
|
||||||
try:
|
if compat_source in ('tecdoc', 'both'):
|
||||||
from services.qwen_fitment import get_vehicle_fitment
|
try:
|
||||||
from services.inventory_vehicle_compat import save_qwen_fitment
|
master = get_master_conn()
|
||||||
fitment = get_vehicle_fitment(
|
auto_match_vehicle_compatibility(master, conn, item_id, data['part_number'],
|
||||||
data['part_number'],
|
brand=data.get('brand'), name=data.get('name'))
|
||||||
data['name'],
|
master.close()
|
||||||
data.get('brand', '')
|
except Exception as am_err:
|
||||||
)
|
print(f"[auto_match] Error for item {item_id}: {am_err}")
|
||||||
qwen_added = save_qwen_fitment(conn, item_id, fitment)
|
|
||||||
except Exception as qwen_err:
|
if compat_source in ('qwen', 'both'):
|
||||||
print(f"[qwen_fitment] Error for item {item_id}: {qwen_err}")
|
try:
|
||||||
|
from services.qwen_fitment import get_vehicle_fitment
|
||||||
|
from services.inventory_vehicle_compat import save_qwen_fitment
|
||||||
|
fitment = get_vehicle_fitment(
|
||||||
|
data['part_number'],
|
||||||
|
data['name'],
|
||||||
|
data.get('brand', '')
|
||||||
|
)
|
||||||
|
qwen_added = save_qwen_fitment(conn, item_id, fitment)
|
||||||
|
except Exception as qwen_err:
|
||||||
|
print(f"[qwen_fitment] Error for item {item_id}: {qwen_err}")
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -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()
|
||||||
|
|||||||
53
pos/migrations/v3.2_qwen_vehicle_compat.sql
Normal file
53
pos/migrations/v3.2_qwen_vehicle_compat.sql
Normal 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;
|
||||||
@@ -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,34 +305,52 @@ 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 = {}
|
||||||
cur_m = master_conn.cursor()
|
if mye_ids:
|
||||||
cur_m.execute("""
|
cur_m = master_conn.cursor()
|
||||||
SELECT mye.id_mye, b.name_brand, m.name_model, y.year_car, e.name_engine
|
cur_m.execute("""
|
||||||
FROM model_year_engine mye
|
SELECT mye.id_mye, b.name_brand, m.name_model, y.year_car, e.name_engine
|
||||||
JOIN models m ON m.id_model = mye.model_id
|
FROM model_year_engine mye
|
||||||
JOIN brands b ON b.id_brand = m.brand_id
|
JOIN models m ON m.id_model = mye.model_id
|
||||||
JOIN years y ON y.id_year = mye.year_id
|
JOIN brands b ON b.id_brand = m.brand_id
|
||||||
JOIN engines e ON e.id_engine = mye.engine_id
|
JOIN years y ON y.id_year = mye.year_id
|
||||||
WHERE mye.id_mye = ANY(%s)
|
JOIN engines e ON e.id_engine = mye.engine_id
|
||||||
ORDER BY b.name_brand, m.name_model, y.year_car
|
WHERE mye.id_mye = ANY(%s)
|
||||||
""", (mye_ids,))
|
ORDER BY b.name_brand, m.name_model, y.year_car
|
||||||
details = {r[0]: r for r in cur_m.fetchall()}
|
""", (mye_ids,))
|
||||||
cur_m.close()
|
details = {r[0]: r for r in cur_m.fetchall()}
|
||||||
|
cur_m.close()
|
||||||
|
|
||||||
# 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()
|
||||||
|
|||||||
@@ -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,28 +150,42 @@ 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 = []
|
||||||
if isinstance(year_raw, int):
|
|
||||||
years = [year_raw]
|
# Prefer explicit year_range
|
||||||
elif isinstance(year_raw, str):
|
year_range = v.get('year_range') or v.get('rango_ano') or ''
|
||||||
# Try range "2003-2008"
|
if isinstance(year_range, str):
|
||||||
m = re.match(r'(\d{4})\s*[-–]\s*(\d{4})', year_raw)
|
m = re.match(r'(\d{4})\s*[-–]\s*(\d{4})', year_range)
|
||||||
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:
|
|
||||||
# Try single year
|
# Fallback to year (int or str)
|
||||||
m2 = re.match(r'(\d{4})', year_raw)
|
if not years:
|
||||||
if m2:
|
year_raw = v.get('year') or v.get('ano') or v.get('año') or v.get('years') or v.get('anos') or ''
|
||||||
years = [int(m2.group(1))]
|
if isinstance(year_raw, int):
|
||||||
|
years = [year_raw]
|
||||||
|
elif isinstance(year_raw, str):
|
||||||
|
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:
|
||||||
|
m2 = re.match(r'(\d{4})', year_raw)
|
||||||
|
if m2:
|
||||||
|
years = [int(m2.group(1))]
|
||||||
|
|
||||||
return make, model, years, engine, engine_code
|
return make, model, years, engine, engine_code
|
||||||
|
|
||||||
@@ -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,16 +310,30 @@ 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
|
||||||
for mye_id in matched_myes:
|
if matched_myes:
|
||||||
if mye_id not in seen_mye:
|
for mye_id in matched_myes:
|
||||||
seen_mye.add(mye_id)
|
if mye_id not in seen_mye:
|
||||||
|
seen_mye.add(mye_id)
|
||||||
|
validated.append({
|
||||||
|
'make': make,
|
||||||
|
'model': model,
|
||||||
|
'year': year,
|
||||||
|
'engine': engine,
|
||||||
|
'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({
|
validated.append({
|
||||||
'make': make,
|
'make': make,
|
||||||
'model': model,
|
'model': model,
|
||||||
'year': year,
|
'year': year,
|
||||||
'engine': engine,
|
'engine': engine,
|
||||||
'engine_code': engine_code,
|
'engine_code': engine_code,
|
||||||
'mye_id': mye_id,
|
'mye_id': None,
|
||||||
})
|
})
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|||||||
@@ -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(); })
|
||||||
|
|||||||
44
pos/tasks.py
44
pos/tasks.py
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user