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:
@@ -15,6 +15,11 @@ from services.inventory_engine import (
|
||||
)
|
||||
from services.barcode_generator import generate_barcode
|
||||
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,
|
||||
)
|
||||
|
||||
inventory_bp = Blueprint('inventory', __name__, url_prefix='/pos/api/inventory')
|
||||
|
||||
@@ -276,7 +281,18 @@ def create_item():
|
||||
new_value={'part_number': data['part_number'], 'name': data['name'], 'initial_stock': initial_stock})
|
||||
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
cur.close()
|
||||
|
||||
# Auto-match vehicle compatibility via TecDoc
|
||||
try:
|
||||
master = get_master_conn()
|
||||
auto_match_vehicle_compatibility(master, conn, item_id, data['part_number'],
|
||||
brand=data.get('brand'), name=data.get('name'))
|
||||
master.close()
|
||||
except Exception as am_err:
|
||||
print(f"[auto_match] Error for item {item_id}: {am_err}")
|
||||
|
||||
conn.close()
|
||||
return jsonify({'id': item_id, 'barcode': barcode, 'message': 'Item created'}), 201
|
||||
|
||||
except Exception as e:
|
||||
@@ -338,7 +354,21 @@ def update_item(item_id):
|
||||
new_value={k: data[k] for k in changing_prices})
|
||||
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
cur.close()
|
||||
|
||||
# Re-run auto-match if part_number changed
|
||||
if 'part_number' in data and data['part_number'] != old_dict.get('part_number'):
|
||||
try:
|
||||
master = get_master_conn()
|
||||
# Clear old compatibilities and re-match
|
||||
remove_all_compatibility(conn, item_id)
|
||||
auto_match_vehicle_compatibility(master, conn, item_id, data['part_number'],
|
||||
brand=data.get('brand'), name=data.get('name'))
|
||||
master.close()
|
||||
except Exception as am_err:
|
||||
print(f"[auto_match] Re-match error for item {item_id}: {am_err}")
|
||||
|
||||
conn.close()
|
||||
return jsonify({'message': 'Item updated'})
|
||||
|
||||
|
||||
@@ -1244,3 +1274,88 @@ def api_reorder_suggest_po():
|
||||
suggestion = suggest_po_from_alerts(conn, supplier_id=supplier_id, branch_id=branch_id)
|
||||
conn.close()
|
||||
return jsonify(suggestion)
|
||||
|
||||
|
||||
# ─── Vehicle Compatibility ───────────────────────────
|
||||
|
||||
@inventory_bp.route('/items/<int:item_id>/vehicles', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def get_item_vehicles(item_id):
|
||||
"""Get all vehicle compatibilities for an inventory item."""
|
||||
tenant = get_tenant_conn(g.tenant_id)
|
||||
master = get_master_conn()
|
||||
try:
|
||||
vehicles = get_compatibility(tenant, master, item_id)
|
||||
return jsonify({'vehicles': vehicles})
|
||||
finally:
|
||||
tenant.close()
|
||||
master.close()
|
||||
|
||||
|
||||
@inventory_bp.route('/items/<int:item_id>/vehicles', methods=['POST'])
|
||||
@require_auth('inventory.edit')
|
||||
def add_item_vehicle(item_id):
|
||||
"""Manually add a vehicle compatibility."""
|
||||
data = request.get_json() or {}
|
||||
mye_id = data.get('model_year_engine_id')
|
||||
if not mye_id:
|
||||
return jsonify({'error': 'model_year_engine_id required'}), 400
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cid = add_compatibility(conn, item_id, mye_id, source='manual')
|
||||
return jsonify({'id': cid, 'message': 'Compatibility added'}), 201
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@inventory_bp.route('/items/<int:item_id>/vehicles/<int:mye_id>', methods=['DELETE'])
|
||||
@require_auth('inventory.edit')
|
||||
def delete_item_vehicle(item_id, mye_id):
|
||||
"""Remove a vehicle compatibility."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
deleted = remove_compatibility(conn, item_id, mye_id)
|
||||
return jsonify({'message': 'Compatibility removed', 'deleted': deleted})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@inventory_bp.route('/items/<int:item_id>/vehicles/auto-match', methods=['POST'])
|
||||
@require_auth('inventory.edit')
|
||||
def auto_match_item_vehicles(item_id):
|
||||
"""Run auto-match for an existing inventory item."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT part_number, brand, name FROM inventory WHERE id = %s", (item_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if not row:
|
||||
conn.close()
|
||||
return jsonify({'error': 'Item not found'}), 404
|
||||
|
||||
part_number, brand, name = row
|
||||
master = get_master_conn()
|
||||
try:
|
||||
result = auto_match_vehicle_compatibility(master, conn, item_id, part_number,
|
||||
brand=brand, name=name)
|
||||
return jsonify(result)
|
||||
finally:
|
||||
master.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@inventory_bp.route('/mye/search', methods=['GET'])
|
||||
@require_auth()
|
||||
def search_mye_endpoint():
|
||||
"""Search model_year_engine records for manual compatibility assignment."""
|
||||
brand_id = request.args.get('brand_id', type=int)
|
||||
model_id = request.args.get('model_id', type=int)
|
||||
year_id = request.args.get('year_id', type=int)
|
||||
engine_id = request.args.get('engine_id', type=int)
|
||||
master = get_master_conn()
|
||||
try:
|
||||
results = search_mye(master, brand_id=brand_id, model_id=model_id,
|
||||
year_id=year_id, engine_id=engine_id)
|
||||
return jsonify({'data': results})
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
@@ -31,6 +31,7 @@ MIGRATIONS = {
|
||||
'v2.8': 'v2.8_savings.sql',
|
||||
'v2.9': 'v2.9_logistics.sql',
|
||||
'v3.0': 'v3.0_public_api.sql',
|
||||
'v3.1': 'v3.1_inventory_vehicle_compat.sql',
|
||||
}
|
||||
|
||||
|
||||
|
||||
34
pos/migrations/v3.1_inventory_vehicle_compat.sql
Normal file
34
pos/migrations/v3.1_inventory_vehicle_compat.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- v3.1 Inventory Vehicle Compatibility
|
||||
-- Links local inventory items to vehicles for Local catalog browsing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory_vehicle_compat (
|
||||
id SERIAL PRIMARY KEY,
|
||||
inventory_id INTEGER NOT NULL REFERENCES inventory(id) ON DELETE CASCADE,
|
||||
model_year_engine_id INTEGER NOT NULL,
|
||||
source VARCHAR(20) DEFAULT 'manual', -- 'auto_match', 'manual', 'import'
|
||||
confidence NUMERIC(4,3) DEFAULT 1.0, -- auto-match confidence (0-1)
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(inventory_id, model_year_engine_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ivc_inventory ON inventory_vehicle_compat(inventory_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ivc_mye ON inventory_vehicle_compat(model_year_engine_id);
|
||||
|
||||
-- View to easily query compatibility with vehicle details (join with master DB in app)
|
||||
CREATE OR REPLACE VIEW v_inventory_vehicle_compat AS
|
||||
SELECT
|
||||
ivc.id,
|
||||
ivc.inventory_id,
|
||||
ivc.model_year_engine_id,
|
||||
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;
|
||||
@@ -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'}
|
||||
|
||||
|
||||
|
||||
249
pos/services/inventory_vehicle_compat.py
Normal file
249
pos/services/inventory_vehicle_compat.py
Normal 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
|
||||
@@ -875,7 +875,11 @@
|
||||
|
||||
partsGrid.querySelectorAll('.part-card').forEach(function (card) {
|
||||
card.addEventListener('click', function () {
|
||||
openPartDetail(parseInt(this.dataset.partId));
|
||||
var pid = this.dataset.partId;
|
||||
if (typeof pid === 'string' && pid.indexOf('inv:') === 0) {
|
||||
return;
|
||||
}
|
||||
openPartDetail(parseInt(pid));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -940,6 +944,10 @@
|
||||
} else {
|
||||
stockBadge = '<span class="stock-badge stock-badge--none">Sin stock</span>';
|
||||
}
|
||||
// Local inventory native badge
|
||||
var sourceBadge = p.source === 'local_inventory'
|
||||
? '<span class="stock-badge stock-badge--local" style="margin-left:4px;background:#4f46e5;">Stock Local</span>'
|
||||
: '';
|
||||
|
||||
var imgHtml = p.image_url
|
||||
? '<img src="' + esc(p.image_url) + '" alt="' + esc(p.name) + '">'
|
||||
@@ -961,10 +969,11 @@
|
||||
? esc(p.part_number) + '<span class="part-card__oem-sub"> · OEM: ' + esc(p.oem_part_number) + '</span>'
|
||||
: esc(p.oem_part_number);
|
||||
|
||||
return '<article class="part-card' + tierClass + '" role="listitem" data-part-id="' + p.id_part + '">' +
|
||||
return '<article class="part-card' + tierClass + '" role="listitem" data-part-id="' + p.id_part + '" data-source="' + (p.source || '') + '">' +
|
||||
'<div class="part-card__image">' + imgHtml + '</div>' +
|
||||
'<div class="part-card__body">' +
|
||||
manuBadge +
|
||||
sourceBadge +
|
||||
'<div class="part-card__oem">' + skuLine + '</div>' +
|
||||
'<div class="part-card__name">' + esc(p.name) + '</div>' +
|
||||
'</div>' +
|
||||
@@ -975,10 +984,15 @@
|
||||
'</article>';
|
||||
}).join('');
|
||||
|
||||
// Wire part card clicks → open detail panel
|
||||
// Wire part card clicks → open detail panel (skip local-inventory items)
|
||||
partsGrid.querySelectorAll('.part-card').forEach(function (card) {
|
||||
card.addEventListener('click', function () {
|
||||
openPartDetail(parseInt(this.dataset.partId));
|
||||
var pid = this.dataset.partId;
|
||||
if (typeof pid === 'string' && pid.indexOf('inv:') === 0) {
|
||||
// local-inventory item: info already visible on card
|
||||
return;
|
||||
}
|
||||
openPartDetail(parseInt(pid));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1345,7 +1359,11 @@
|
||||
searchDropdown.querySelectorAll('.search-result-item').forEach(function (el) {
|
||||
el.addEventListener('click', function () {
|
||||
searchDropdown.classList.remove('is-visible');
|
||||
openPartDetail(parseInt(this.dataset.partId));
|
||||
var pid = this.dataset.partId;
|
||||
if (typeof pid === 'string' && pid.indexOf('inv:') === 0) {
|
||||
return;
|
||||
}
|
||||
openPartDetail(parseInt(pid));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -653,6 +653,40 @@
|
||||
el.innerHTML = html2;
|
||||
}
|
||||
|
||||
// Vehicle compatibility section
|
||||
html += '<div style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:var(--tracking-widest);margin-bottom:8px;">Vehiculos Compatibles</div>';
|
||||
html += '<div id="compatContent" style="margin-bottom:16px;padding-bottom:16px;border-bottom:1px solid var(--color-border);">';
|
||||
html += '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Cargando compatibilidades...</p>';
|
||||
html += '</div>';
|
||||
|
||||
// Load vehicle compatibilities
|
||||
(function loadCompat() {
|
||||
fetch('/pos/api/inventory/items/' + itemId + '/vehicles', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
var el = document.getElementById('compatContent');
|
||||
if (!el) return;
|
||||
var list = d.vehicles || [];
|
||||
var html2 = '';
|
||||
if (list.length > 0) {
|
||||
html2 += '<table class="data-table"><thead><tr><th>Marca</th><th>Modelo</th><th>Ano</th><th>Motor</th><th>Origen</th><th></th></tr></thead><tbody>';
|
||||
list.forEach(function(c) {
|
||||
html2 += '<tr><td>' + esc(c.brand || '') + '</td><td>' + esc(c.model || '') + '</td><td>' + esc(c.year || '') + '</td><td>' + esc(c.engine || '') + '</td><td>' + esc(c.source || '') + '</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 += '</tbody></table>';
|
||||
} else {
|
||||
html2 += '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Sin vehiculos vinculados.</p>';
|
||||
}
|
||||
html2 += '<div style="margin-top:8px;"><button class="btn btn--primary btn--sm" onclick="autoMatchCompat(' + itemId + ')">Auto-Match por TecDoc</button> <span style="font-size:var(--text-caption);color:var(--color-text-muted);">Busca en catalogo central y vincula automaticamente</span></div>';
|
||||
el.innerHTML = html2;
|
||||
})
|
||||
.catch(function() {
|
||||
var el = document.getElementById('compatContent');
|
||||
if (el) el.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Error al cargar compatibilidades.</p>';
|
||||
});
|
||||
})();
|
||||
|
||||
// Movement history
|
||||
html += '<div style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:var(--tracking-widest);margin-bottom:8px;">Historial de Movimientos</div>';
|
||||
if (!history.length) {
|
||||
@@ -677,6 +711,29 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Vehicle compatibility actions
|
||||
function autoMatchCompat(itemId) {
|
||||
fetch('/pos/api/inventory/items/' + itemId + '/vehicles/auto-match', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
}).then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
alert('Auto-match completado. Vehiculos vinculados: ' + (d.matched || 0));
|
||||
viewProductDetail(itemId);
|
||||
}).catch(function() { alert('Error en auto-match'); });
|
||||
}
|
||||
|
||||
function removeCompat(itemId, myeId) {
|
||||
if (!confirm('Quitar compatibilidad con este vehiculo?')) return;
|
||||
fetch('/pos/api/inventory/items/' + itemId + '/compatibility/' + myeId, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
}).then(function(r) { return r.json(); })
|
||||
.then(function() {
|
||||
viewProductDetail(itemId);
|
||||
}).catch(function() { alert('Error al quitar compatibilidad'); });
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// EXPOSE GLOBALS (for onclick handlers in HTML)
|
||||
// =====================================================================
|
||||
|
||||
141
pos/tests/test_compatibility.py
Normal file
141
pos/tests/test_compatibility.py
Normal file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test vehicle compatibility engine (Opción C):
|
||||
- auto_match_vehicle_compatibility
|
||||
- get_compatibility / add / remove
|
||||
- get_inventory_by_vehicle (local items in catalog)
|
||||
"""
|
||||
|
||||
import os, sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
os.environ.setdefault('MASTER_DB_URL', 'postgresql://postgres@/nexus_autoparts')
|
||||
os.environ.setdefault('TENANT_DB_URL_TEMPLATE', 'postgresql://postgres@/{db_name}')
|
||||
os.environ.setdefault('POS_JWT_SECRET', 'test-secret-12345678901234567890123456789012')
|
||||
|
||||
from tenant_db import get_master_conn, get_tenant_conn_by_dbname
|
||||
from services.inventory_vehicle_compat import (
|
||||
auto_match_vehicle_compatibility,
|
||||
get_compatibility,
|
||||
add_compatibility,
|
||||
remove_compatibility,
|
||||
get_inventory_by_vehicle,
|
||||
)
|
||||
|
||||
PASS = '\033[92mPASS\033[0m'
|
||||
FAIL = '\033[91mFAIL\033[0m'
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
|
||||
def ok(label, condition, detail=''):
|
||||
global passed, failed
|
||||
if condition:
|
||||
print(f" [{PASS}] {label}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" [{FAIL}] {label} {detail}")
|
||||
failed += 1
|
||||
|
||||
|
||||
# ─── Setup ─────────────────────────────────────
|
||||
master = get_master_conn()
|
||||
cur = master.cursor()
|
||||
cur.execute("SELECT db_name FROM tenants WHERE is_active = true ORDER BY id LIMIT 1")
|
||||
row = cur.fetchone()
|
||||
cur.close(); master.close()
|
||||
|
||||
if not row:
|
||||
print("No active tenant found!")
|
||||
sys.exit(1)
|
||||
|
||||
db_name = row[0]
|
||||
tenant = get_tenant_conn_by_dbname(db_name)
|
||||
master = get_master_conn()
|
||||
|
||||
# Pick a part_number known to have vehicle_parts
|
||||
TEST_PN = 'A700X6714GA'
|
||||
|
||||
print("=" * 60)
|
||||
print("VEHICLE COMPATIBILITY ENGINE — VALIDATION")
|
||||
print("=" * 60)
|
||||
|
||||
# ─── 1. Create test inventory item ─────────────
|
||||
cur = tenant.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO inventory (part_number, name, brand, barcode, unit, cost, price_1, price_2, price_3, is_active)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, true)
|
||||
RETURNING id
|
||||
""", (TEST_PN, 'Test Compat Item', 'TestBrand', 'COMPAT-TEST-001', 'PZA', 100, 150, 140, 130))
|
||||
item_id = cur.fetchone()[0]
|
||||
tenant.commit(); cur.close()
|
||||
print(f"\n[Test item created: id={item_id}]")
|
||||
|
||||
# ─── 2. Auto-match ─────────────────────────────
|
||||
print("\n[AUTO-MATCH]")
|
||||
try:
|
||||
result = auto_match_vehicle_compatibility(master, tenant, item_id, TEST_PN)
|
||||
matched = result.get('matched', 0)
|
||||
ok("Auto-match returned count", matched > 0, f"matched={matched}")
|
||||
|
||||
compat = get_compatibility(tenant, master, item_id)
|
||||
ok("Compatibilities created", len(compat) > 0, f"count={len(compat)}")
|
||||
ok("Compat has vehicle details", all(c.get('brand') for c in compat), f"sample={compat[0] if compat else None}")
|
||||
ok("Source is auto_match", all(c.get('source') == 'auto_match' for c in compat))
|
||||
except Exception as e:
|
||||
ok("Auto-match", False, str(e))
|
||||
|
||||
# ─── 3. Manual add/remove ──────────────────────
|
||||
print("\n[MANUAL COMPATIBILITY]")
|
||||
try:
|
||||
# Find an MYE not already linked
|
||||
cur = master.cursor()
|
||||
cur.execute("""
|
||||
SELECT mye.id_mye FROM model_year_engine mye
|
||||
JOIN models m ON m.id_model = mye.model_id
|
||||
LIMIT 1
|
||||
""")
|
||||
mye_id = cur.fetchone()[0]
|
||||
cur.close()
|
||||
|
||||
cid = add_compatibility(tenant, item_id, mye_id, source='manual')
|
||||
ok("Manual add", cid is not None, f"id={cid}")
|
||||
|
||||
compat_after_add = get_compatibility(tenant, master, item_id)
|
||||
manual_items = [c for c in compat_after_add if c.get('source') == 'manual']
|
||||
ok("Manual item in list", len(manual_items) >= 1)
|
||||
|
||||
removed = remove_compatibility(tenant, item_id, mye_id)
|
||||
ok("Manual remove", removed > 0, f"deleted={removed}")
|
||||
|
||||
compat_after_remove = get_compatibility(tenant, master, item_id)
|
||||
manual_items_after = [c for c in compat_after_remove if c.get('source') == 'manual']
|
||||
ok("Manual item removed", len(manual_items_after) == 0)
|
||||
except Exception as e:
|
||||
ok("Manual add/remove", False, str(e))
|
||||
|
||||
# ─── 4. Local inventory in catalog ─────────────
|
||||
print("\n[CATALOG INTEGRATION]")
|
||||
try:
|
||||
# Use the first MYE from auto-match
|
||||
compat = get_compatibility(tenant, master, item_id)
|
||||
if compat:
|
||||
test_mye = compat[0]['model_year_engine_id']
|
||||
local_rows = get_inventory_by_vehicle(tenant, master, test_mye)
|
||||
ok("Local items found for vehicle", any(r[0] == item_id for r in local_rows), f"count={len(local_rows)}")
|
||||
else:
|
||||
ok("Local items found for vehicle", False, "no compatibilities")
|
||||
except Exception as e:
|
||||
ok("Catalog integration", False, str(e))
|
||||
|
||||
# ─── Cleanup ───────────────────────────────────
|
||||
cur = tenant.cursor()
|
||||
cur.execute("DELETE FROM inventory_vehicle_compat WHERE inventory_id = %s", (item_id,))
|
||||
cur.execute("DELETE FROM inventory WHERE id = %s", (item_id,))
|
||||
tenant.commit(); cur.close()
|
||||
tenant.close(); master.close()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"RESULTS: {PASS} {passed} passed, {FAIL} {failed} failed")
|
||||
print("=" * 60)
|
||||
|
||||
sys.exit(0 if failed == 0 else 1)
|
||||
Reference in New Issue
Block a user