Opción C: Vinculación híbrida de inventario local con vehículos

- Nueva tabla inventory_vehicle_compat (v3.1)
- Motor inventory_vehicle_compat.py: auto-match + gestión manual
- catalog_service.get_parts_local() ahora incluye piezas locales vinculadas
- inventory_bp: auto-match en create/update + endpoints REST /vehicles
- Frontend catalog.js: badge 'Stock Local' para piezas nativas del tenant
- Frontend inventory.js: panel de vehículos compatibles con auto-match
- Tests: test_compatibility.py (9/9 pasan)

Piezas locales aparecen en navegación por vehículo aunque no estén en TecDoc.
Auto-match busca part_number en parts/aftermarket_parts y copia MYEs compatibles.
This commit is contained in:
2026-04-27 06:52:30 +00:00
parent 142abbc217
commit efbd763e43
8 changed files with 690 additions and 14 deletions

View File

@@ -629,18 +629,42 @@ def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug,
.get(subgroup_slug, {})
.get(part_type_slug, [])
)
if not part_ids:
return {
'data': [],
'pagination': _pagination(page, per_page, 0),
'mode': 'local',
}
return get_parts_local(
result = get_parts_local(
master_conn, mye_id=None, group_id=None,
tenant_conn=tenant_conn, branch_id=branch_id,
page=page, per_page=per_page,
oem_part_ids=part_ids,
)
# Inject local inventory items linked to this vehicle
# (get_parts_local with oem_part_ids skips mye_id, so we call it separately)
if tenant_conn and mye_id:
from services.inventory_vehicle_compat import get_inventory_by_vehicle
local_rows = get_inventory_by_vehicle(tenant_conn, master_conn, mye_id, branch_id)
for lr in local_rows:
inv_id, pn, name, brand, p1, p2, p3, img, desc, stock = lr
# Only include if name roughly matches the Nexpart part_type
if part_type_slug and part_type_slug.lower() not in (name or '').lower():
continue
result['data'].append({
'id_part': f'inv:{inv_id}',
'id_aftermarket': None,
'oem_part_number': None,
'part_number': pn,
'name': name,
'description': desc,
'image_url': img,
'manufacturer': brand,
'priority_tier': 1,
'local_stock': int(stock) if stock else 0,
'local_price': float(p1) if p1 else None,
'bodega_count': 0,
'warehouse_stock': 0,
'warehouse_price': None,
'in_stock_network': False,
'price_usd': None,
'source': 'local_inventory',
})
return result
def get_nexpart_part_types_for_vehicle(master_conn, mye_id, group_slug, subgroup_slug):
@@ -983,6 +1007,8 @@ def get_parts_local(master_conn, mye_id, group_id, tenant_conn, branch_id,
local_map = _get_local_stock_bulk(tenant_conn, branch_id, oem_numbers, result_oem_ids)
items = []
seen_part_numbers = set()
for r in rows:
aft_id = r[0]
oem_part_id = r[1]
@@ -1003,6 +1029,10 @@ def get_parts_local(master_conn, mye_id, group_id, tenant_conn, branch_id,
local = local_map.get(oem_number) or local_map.get(f'cat:{oem_part_id}')
image_url = (local.get('image_url') if local else None) or oem_image
part_number = aft_number or oem_number
if part_number:
seen_part_numbers.add(part_number.upper())
items.append({
# Keep fields compatible with OEM mode output so the frontend
# can render with minimal branching.
@@ -1022,8 +1052,39 @@ def get_parts_local(master_conn, mye_id, group_id, tenant_conn, branch_id,
'warehouse_price': float(warehouse_price) if warehouse_price is not None else None,
'in_stock_network': bodega_count > 0,
'price_usd': float(price_usd) if price_usd is not None else None,
'source': 'aftermarket',
})
# ─── Inject local inventory items linked to this vehicle ──────────────────
if mye_id and tenant_conn:
from services.inventory_vehicle_compat import get_inventory_by_vehicle
local_rows = get_inventory_by_vehicle(tenant_conn, master_conn, mye_id, branch_id)
for lr in local_rows:
inv_id, pn, name, brand, p1, p2, p3, img, desc, stock = lr
if pn and pn.upper() in seen_part_numbers:
continue # deduplicate: already shown via aftermarket match
seen_part_numbers.add(pn.upper() if pn else '')
items.append({
'id_part': f'inv:{inv_id}',
'id_aftermarket': None,
'oem_part_number': None,
'part_number': pn,
'name': name,
'description': desc,
'image_url': img,
'manufacturer': brand,
'priority_tier': 1, # treat as tier 1 since it's local stock
'local_stock': int(stock) if stock else 0,
'local_price': float(p1) if p1 else None,
'bodega_count': 0,
'warehouse_stock': 0,
'warehouse_price': None,
'in_stock_network': False,
'price_usd': None,
'source': 'local_inventory',
})
total += 1
return {'data': items, 'pagination': _pagination(page, per_page, total), 'mode': 'local'}

View File

@@ -0,0 +1,249 @@
"""Inventory Vehicle Compatibility Engine.
Links local inventory items to model_year_engine_ids so they appear
when browsing the Local catalog by vehicle.
Features:
- Auto-match by part_number against parts/aftermarket_parts/cross_refs
- Manual add/remove MYE compatibility
- Batch operations
"""
from typing import List, Dict, Optional
def auto_match_vehicle_compatibility(master_conn, tenant_conn, inventory_id, part_number,
brand=None, name=None):
"""Find vehicle compatibility for an inventory item by part_number.
Searches:
1. parts.oem_part_number (exact, case-insensitive, spaces stripped)
2. aftermarket_parts.part_number
3. part_cross_references.cross_ref_number
Returns:
dict: {'matched': bool, 'matches': [...], 'myes': [...]}
"""
cur = master_conn.cursor()
clean_pn = part_number.replace(' ', '').upper() if part_number else ''
# 1. Direct OEM match
cur.execute("""
SELECT id_part FROM parts
WHERE REPLACE(UPPER(oem_part_number), ' ', '') = %s
""", (clean_pn,))
oem_ids = [r[0] for r in cur.fetchall()]
# 2. Aftermarket match → get oem_part_id
if not oem_ids:
cur.execute("""
SELECT DISTINCT oem_part_id FROM aftermarket_parts
WHERE REPLACE(UPPER(part_number), ' ', '') = %s
""", (clean_pn,))
oem_ids = [r[0] for r in cur.fetchall() if r[0]]
# 3. Cross-reference match
if not oem_ids:
cur.execute("""
SELECT DISTINCT part_id FROM part_cross_references
WHERE REPLACE(UPPER(cross_ref_number), ' ', '') = %s
""", (clean_pn,))
oem_ids = [r[0] for r in cur.fetchall() if r[0]]
if not oem_ids:
cur.close()
return {'matched': False, 'matches': [], 'myes': []}
# Get MYEs for these part IDs
cur.execute("""
SELECT DISTINCT model_year_engine_id
FROM vehicle_parts
WHERE part_id = ANY(%s)
""", (oem_ids,))
mye_ids = [r[0] for r in cur.fetchall()]
cur.close()
# Insert into tenant table
inserted = 0
cur2 = tenant_conn.cursor()
for mye_id in mye_ids:
cur2.execute("""
INSERT INTO inventory_vehicle_compat
(inventory_id, model_year_engine_id, source, confidence)
VALUES (%s, %s, 'auto_match', 1.0)
ON CONFLICT (inventory_id, model_year_engine_id) DO NOTHING
""", (inventory_id, mye_id))
if cur2.rowcount > 0:
inserted += 1
tenant_conn.commit()
cur2.close()
return {
'matched': True,
'matches': oem_ids,
'myes': mye_ids,
'inserted': inserted,
}
def add_compatibility(tenant_conn, inventory_id, model_year_engine_id, source='manual'):
"""Manually add a vehicle compatibility."""
cur = tenant_conn.cursor()
cur.execute("""
INSERT INTO inventory_vehicle_compat
(inventory_id, model_year_engine_id, source, confidence)
VALUES (%s, %s, %s, 1.0)
ON CONFLICT (inventory_id, model_year_engine_id) DO NOTHING
RETURNING id
""", (inventory_id, model_year_engine_id, source))
row = cur.fetchone()
tenant_conn.commit()
cur.close()
return row[0] if row else None
def remove_compatibility(tenant_conn, inventory_id, model_year_engine_id):
cur = tenant_conn.cursor()
cur.execute("""
DELETE FROM inventory_vehicle_compat
WHERE inventory_id = %s AND model_year_engine_id = %s
""", (inventory_id, model_year_engine_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("""
DELETE FROM inventory_vehicle_compat WHERE inventory_id = %s
""", (inventory_id,))
deleted = cur.rowcount
tenant_conn.commit()
cur.close()
return deleted
def get_compatibility(tenant_conn, master_conn, inventory_id):
"""Get all vehicle compatibilities for an inventory item with vehicle details."""
cur = tenant_conn.cursor()
cur.execute("""
SELECT model_year_engine_id, source, confidence, created_at
FROM inventory_vehicle_compat
WHERE inventory_id = %s
ORDER BY created_at DESC
""", (inventory_id,))
rows = cur.fetchall()
cur.close()
if not rows:
return []
mye_ids = [r[0] for r in rows]
# Fetch vehicle details from master
cur2 = master_conn.cursor()
cur2.execute("""
SELECT mye.id_mye, b.name_brand, m.name_model, y.year_car, e.name_engine
FROM model_year_engine mye
JOIN models m ON m.id_model = mye.model_id
JOIN brands b ON b.id_brand = m.brand_id
JOIN years y ON y.id_year = mye.year_id
JOIN engines e ON e.id_engine = mye.engine_id
WHERE mye.id_mye = ANY(%s)
""", (mye_ids,))
vehicle_map = {}
for r in cur2.fetchall():
vehicle_map[r[0]] = {
'brand': r[1], 'model': r[2], 'year': r[3], 'engine': r[4],
}
cur2.close()
results = []
for mye_id, source, confidence, created_at in rows:
v = vehicle_map.get(mye_id, {})
results.append({
'model_year_engine_id': mye_id,
'brand': v.get('brand', ''),
'model': v.get('model', ''),
'year': v.get('year', ''),
'engine': v.get('engine', ''),
'source': source,
'confidence': float(confidence) if confidence else 1.0,
'created_at': str(created_at),
})
return results
def get_inventory_by_vehicle(tenant_conn, master_conn, mye_id, branch_id=None):
"""Get local inventory items compatible with a specific vehicle.
Used by catalog_service to inject local items into Local mode browsing.
"""
cur = tenant_conn.cursor()
branch_filter = ""
params = [mye_id]
if branch_id:
branch_filter = "AND i.branch_id = %s"
params.append(branch_id)
cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.brand, i.price_1, i.price_2, i.price_3,
i.image_url, i.description, COALESCE(s.stock, 0) as stock
FROM inventory i
JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id
LEFT JOIN (
SELECT inventory_id, SUM(quantity) as stock
FROM inventory_operations
GROUP BY inventory_id
) s ON s.inventory_id = i.id
WHERE ivc.model_year_engine_id = %s {branch_filter}
AND i.is_active = true
ORDER BY i.name
""", params)
rows = cur.fetchall()
cur.close()
return rows
def search_mye(master_conn, brand_id=None, model_id=None, year_id=None, engine_id=None):
"""Search model_year_engine records. Returns list of MYE IDs."""
cur = master_conn.cursor()
where = ["true"]
params = []
if brand_id:
where.append("m.brand_id = %s")
params.append(brand_id)
if model_id:
where.append("m.id_model = %s")
params.append(model_id)
if year_id:
where.append("mye.year_id = %s")
params.append(year_id)
if engine_id:
where.append("mye.engine_id = %s")
params.append(engine_id)
cur.execute(f"""
SELECT mye.id_mye, b.name_brand, m.name_model, y.year_car, e.name_engine
FROM model_year_engine mye
JOIN models m ON m.id_model = mye.model_id
JOIN brands b ON b.id_brand = m.brand_id
JOIN years y ON y.id_year = mye.year_id
JOIN engines e ON e.id_engine = mye.engine_id
WHERE {' AND '.join(where)}
ORDER BY b.name_brand, m.name_model, y.year_car, e.name_engine
LIMIT 500
""", params)
results = []
for r in cur.fetchall():
results.append({
'id_mye': r[0], 'brand': r[1], 'model': r[2],
'year': r[3], 'engine': r[4],
})
cur.close()
return results