diff --git a/pos/blueprints/inventory_bp.py b/pos/blueprints/inventory_bp.py index 68880ce..7981e8a 100644 --- a/pos/blueprints/inventory_bp.py +++ b/pos/blueprints/inventory_bp.py @@ -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//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//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//vehicles/', 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//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() diff --git a/pos/migrations/runner.py b/pos/migrations/runner.py index 602469a..2e96434 100755 --- a/pos/migrations/runner.py +++ b/pos/migrations/runner.py @@ -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', } diff --git a/pos/migrations/v3.1_inventory_vehicle_compat.sql b/pos/migrations/v3.1_inventory_vehicle_compat.sql new file mode 100644 index 0000000..1563944 --- /dev/null +++ b/pos/migrations/v3.1_inventory_vehicle_compat.sql @@ -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; diff --git a/pos/services/catalog_service.py b/pos/services/catalog_service.py index 855b3e7..8297cca 100644 --- a/pos/services/catalog_service.py +++ b/pos/services/catalog_service.py @@ -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'} diff --git a/pos/services/inventory_vehicle_compat.py b/pos/services/inventory_vehicle_compat.py new file mode 100644 index 0000000..9653557 --- /dev/null +++ b/pos/services/inventory_vehicle_compat.py @@ -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 diff --git a/pos/static/js/catalog.js b/pos/static/js/catalog.js index 294c08e..69d5f1f 100644 --- a/pos/static/js/catalog.js +++ b/pos/static/js/catalog.js @@ -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 = 'Sin stock'; } + // Local inventory native badge + var sourceBadge = p.source === 'local_inventory' + ? 'Stock Local' + : ''; var imgHtml = p.image_url ? '' + esc(p.name) + '' @@ -961,10 +969,11 @@ ? esc(p.part_number) + ' · OEM: ' + esc(p.oem_part_number) + '' : esc(p.oem_part_number); - return '
' + + return '
' + '
' + imgHtml + '
' + '
' + manuBadge + + sourceBadge + '
' + skuLine + '
' + '
' + esc(p.name) + '
' + '
' + @@ -975,10 +984,15 @@ '
'; }).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)); }); }); }); diff --git a/pos/static/js/inventory.js b/pos/static/js/inventory.js index 1e8367e..a269aef 100644 --- a/pos/static/js/inventory.js +++ b/pos/static/js/inventory.js @@ -653,6 +653,40 @@ el.innerHTML = html2; } + // Vehicle compatibility section + html += '
Vehiculos Compatibles
'; + html += '
'; + html += '

Cargando compatibilidades...

'; + html += '
'; + + // 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 += ''; + list.forEach(function(c) { + html2 += ''; + html2 += ''; + }); + html2 += '
MarcaModeloAnoMotorOrigen
' + esc(c.brand || '') + '' + esc(c.model || '') + '' + esc(c.year || '') + '' + esc(c.engine || '') + '' + esc(c.source || '') + '
'; + } else { + html2 += '

Sin vehiculos vinculados.

'; + } + html2 += '
Busca en catalogo central y vincula automaticamente
'; + el.innerHTML = html2; + }) + .catch(function() { + var el = document.getElementById('compatContent'); + if (el) el.innerHTML = '

Error al cargar compatibilidades.

'; + }); + })(); + // Movement history html += '
Historial de Movimientos
'; 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) // ===================================================================== diff --git a/pos/tests/test_compatibility.py b/pos/tests/test_compatibility.py new file mode 100644 index 0000000..383bb71 --- /dev/null +++ b/pos/tests/test_compatibility.py @@ -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)