From ea29cc31c03c715438531894f59cfd499ee14019 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Tue, 9 Jun 2026 07:47:42 +0000 Subject: [PATCH] feat(catalog): supplier catalog cleanup, fuzzy matching, and navigation fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cleaned 137+ fake engine-displacement models from supplier imports (v3/v4 scripts: Chevrolet, Ford, Chrysler, Dodge, Jeep, Nissan, etc.) - Removed 1,251+ corrupted models (INT. prefixes, year-suffix, torque specs, empty names, trailing-year variants) - Migrated supplier tables to master DB (supplier_catalog, supplier_catalog_compat, supplier_catalog_interchange) - Fixed _get_mye_ids_with_parts() to query supplier_catalog_compat from master DB so supplier-only vehicles appear for all tenants - Added fuzzy model matcher with parenthesis stripping, noise suffix removal, compact matching, prefix/substring fallback, model aliases, and ±3 year proximity - Matched compat rows: KEEP GREEN +14,152, KNADIAN +3,021, VAZLO +127,500, LUK +477, RAYBESTOS +1,743 - Added KNADIAN catalog importer with year-range expansion and future-year filtering - Added VAZLO catalog importer with position parsing and SKU-in-model cleanup - Added Keep Green, LUK, Yokomitsu, Raybestos catalog importers - Cache clearing after cleanups (_classify_cache_*, nexus:mye_ids:*, nexus:brand_mye_counts:*) Final match rates: - KEEP GREEN: 90.3% - VAZLO: 93.6% - YOKOMITSU: 100.0% - KNADIAN: 57.4% - LUK: 51.0% - RAYBESTOS: 55.9% --- package.json | 3 + pos/app.py | 10 + pos/blueprints/catalog_bp.py | 77 +- pos/blueprints/config_bp.py | 21 +- pos/blueprints/dashboard_stats_bp.py | 145 +-- pos/blueprints/dropshipping_bp.py | 128 ++ pos/blueprints/inventory_bp.py | 603 +++++++++- pos/blueprints/marketplace_external_bp.py | 111 +- pos/blueprints/supplier_catalog_bp.py | 278 +++++ pos/blueprints/supplier_portal_bp.py | 122 +- pos/migrations/runner.py | 1 + pos/migrations/v1.0_initial.sql | 1 + pos/migrations/v3.5_meli_questions.sql | 30 + pos/migrations/v3.6_dropshipping.sql | 18 + pos/migrations/v3.7_sku_aliases.sql | 22 + pos/migrations/v3.8_supplier_catalog.sql | 63 + pos/services/catalog_import_service.py | 157 +++ pos/services/catalog_service.py | 1035 ++++++++++++----- pos/services/dropshipping_service.py | 168 +++ pos/services/marketplace_external_service.py | 271 ++++- pos/services/meli_service.py | 17 + pos/services/pos_engine.py | 29 + pos/services/webhook_service.py | 65 ++ pos/static/css/dashboard.css | 12 + pos/static/js/app-init.js | 10 + pos/static/js/catalog.js | 133 ++- pos/static/js/dashboard-stats.js | 36 +- pos/static/js/inventory.js | 397 ++++++- pos/static/js/marketplace_external.js | 152 ++- pos/static/js/supplier_catalog.js | 299 +++++ pos/static/pwa/sw.js | 50 +- pos/tasks.py | 14 +- pos/templates/catalog.html | 6 +- pos/templates/dashboard.html | 10 +- pos/templates/inventory.html | 59 +- pos/templates/marketplace_external.html | 24 + pos/templates/supplier_catalog.html | 135 +++ pos/tests/test_bulk_import.py | 109 ++ scripts/clean_fake_models.py | 274 +++++ scripts/clean_supplier_corrupted_models.py | 240 ++++ scripts/clean_supplier_corrupted_models_v2.py | 118 ++ scripts/clean_supplier_corrupted_models_v3.py | 228 ++++ scripts/clean_supplier_corrupted_models_v4.py | 126 ++ scripts/clean_year_suffix_models.py | 57 + scripts/import_keepgreen_catalog.py | 240 ++++ scripts/import_knadian_catalog.py | 312 +++++ scripts/import_luk_catalog.py | 235 ++++ scripts/import_rached_excel.py | 183 +++ scripts/import_raybestos_catalog.py | 303 +++++ scripts/import_vazlo_catalog.py | 285 +++++ scripts/import_yokomitsu_catalog.py | 393 +++++++ scripts/match_supplier_compat_to_mye.py | 369 ++++++ test_catalog.js | 91 ++ 53 files changed, 7727 insertions(+), 548 deletions(-) create mode 100644 pos/blueprints/dropshipping_bp.py create mode 100644 pos/blueprints/supplier_catalog_bp.py create mode 100644 pos/migrations/v3.5_meli_questions.sql create mode 100644 pos/migrations/v3.6_dropshipping.sql create mode 100644 pos/migrations/v3.7_sku_aliases.sql create mode 100644 pos/migrations/v3.8_supplier_catalog.sql create mode 100644 pos/services/catalog_import_service.py create mode 100644 pos/services/dropshipping_service.py create mode 100644 pos/services/webhook_service.py create mode 100644 pos/static/js/supplier_catalog.js create mode 100644 pos/templates/supplier_catalog.html create mode 100644 pos/tests/test_bulk_import.py create mode 100755 scripts/clean_fake_models.py create mode 100755 scripts/clean_supplier_corrupted_models.py create mode 100644 scripts/clean_supplier_corrupted_models_v2.py create mode 100644 scripts/clean_supplier_corrupted_models_v3.py create mode 100755 scripts/clean_supplier_corrupted_models_v4.py create mode 100755 scripts/clean_year_suffix_models.py create mode 100644 scripts/import_keepgreen_catalog.py create mode 100644 scripts/import_knadian_catalog.py create mode 100644 scripts/import_luk_catalog.py create mode 100755 scripts/import_rached_excel.py create mode 100644 scripts/import_raybestos_catalog.py create mode 100644 scripts/import_vazlo_catalog.py create mode 100755 scripts/import_yokomitsu_catalog.py create mode 100755 scripts/match_supplier_compat_to_mye.py create mode 100644 test_catalog.js diff --git a/package.json b/package.json index 32ba934..d91c582 100644 --- a/package.json +++ b/package.json @@ -19,5 +19,8 @@ "type": "commonjs", "devDependencies": { "@playwright/test": "^1.59.1" + }, + "dependencies": { + "playwright": "^1.60.0" } } diff --git a/pos/app.py b/pos/app.py index 112186e..7bf6c1d 100644 --- a/pos/app.py +++ b/pos/app.py @@ -62,6 +62,9 @@ def create_app(): from blueprints.marketplace_external_bp import marketplace_ext_bp app.register_blueprint(marketplace_ext_bp) + from blueprints.dropshipping_bp import dropship_bp + app.register_blueprint(dropship_bp) + from blueprints.peer_bp import peer_bp app.register_blueprint(peer_bp) @@ -110,6 +113,9 @@ def create_app(): from blueprints.supplier_portal_bp import supplier_portal_bp app.register_blueprint(supplier_portal_bp) + from blueprints.supplier_catalog_bp import supplier_catalog_bp + app.register_blueprint(supplier_catalog_bp) + from blueprints.internal_bp import internal_bp app.register_blueprint(internal_bp) @@ -131,6 +137,10 @@ def create_app(): tenant_name=getattr(g, 'tenant_name', None), tenant_subdomain=getattr(g, 'tenant_subdomain', None)) + @app.route('/pos/supplier-catalog') + def supplier_catalog_page(): + return render_template('supplier_catalog.html') + @app.route('/pos/catalog') def pos_catalog(): return render_template('catalog.html') diff --git a/pos/blueprints/catalog_bp.py b/pos/blueprints/catalog_bp.py index ef4c9fe..8c6afeb 100644 --- a/pos/blueprints/catalog_bp.py +++ b/pos/blueprints/catalog_bp.py @@ -98,7 +98,9 @@ def _filter_parts_by_allowed_brands(master_conn, parts_data, allowed_brands): part_ids = [] for p in parts_data: pid = p.get('id_part') or p.get('id') - if pid is not None: + # Skip local inventory IDs (strings like 'inv:3') — aftermarket filter + # only applies to catalog parts with integer OEM part IDs. + if pid is not None and isinstance(pid, int): part_ids.append(pid) if not part_ids: return parts_data @@ -124,10 +126,11 @@ def brands(): from services.catalog_modes import normalize_mode year_id = request.args.get('year_id', type=int) mode = normalize_mode(request.args.get('mode')) - def _do(master): - data = catalog_service.get_brands(master, year_id=year_id, mode=mode) + def _do(master, tenant, branch_id): + mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None + data = catalog_service.get_brands(master, year_id=year_id, mode=mode, mye_ids=mye_ids) return jsonify({'data': data, 'mode': mode}) - return _master_only(_do) + return _with_conns(_do) @catalog_bp.route('/models', methods=['GET']) @@ -137,10 +140,11 @@ def models(): year_id = request.args.get('year_id', type=int) if not brand_id: return jsonify({'error': 'brand_id required'}), 400 - def _do(master): - data = catalog_service.get_models(master, brand_id, year_id=year_id) + def _do(master, tenant, branch_id): + mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None + data = catalog_service.get_models(master, brand_id, year_id=year_id, mye_ids=mye_ids) return jsonify({'data': data}) - return _master_only(_do) + return _with_conns(_do) @catalog_bp.route('/years', methods=['GET']) @@ -149,10 +153,11 @@ def years(): model_id = request.args.get('model_id', type=int) if not model_id: return jsonify({'error': 'model_id required'}), 400 - def _do(master): - data = catalog_service.get_years(master, model_id) + def _do(master, tenant, branch_id): + mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None + data = catalog_service.get_years(master, model_id, mye_ids=mye_ids) return jsonify({'data': data}) - return _master_only(_do) + return _with_conns(_do) @catalog_bp.route('/years-all', methods=['GET']) @@ -175,10 +180,11 @@ def engines(): year_id = request.args.get('year_id', type=int) if not model_id or not year_id: return jsonify({'error': 'model_id and year_id required'}), 400 - def _do(master): - data = catalog_service.get_engines(master, model_id, year_id) + def _do(master, tenant, branch_id): + mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None + data = catalog_service.get_engines(master, model_id, year_id, mye_ids=mye_ids) return jsonify({'data': data}) - return _master_only(_do) + return _with_conns(_do) @catalog_bp.route('/categories', methods=['GET']) @@ -198,7 +204,7 @@ def categories(): def _do(master, tenant, branch_id): allowed_brands = _get_allowed_brands(tenant) if tenant else None if mode == 'local': - data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id) + data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id, tenant) else: data = catalog_service.get_categories(master, mye_id, allowed_brands) return jsonify({'data': data, 'mode': mode, 'allowed_brands': allowed_brands or []}) @@ -220,17 +226,17 @@ def groups(): mode = normalize_mode(request.args.get('mode')) if not mye_id: return jsonify({'error': 'mye_id required'}), 400 - def _do(master): + def _do(master, tenant, branch_id): if mode == 'local': if not category_slug: return jsonify({'error': 'category_slug required for local mode'}), 400 - data = catalog_service.get_nexpart_subgroups_for_vehicle(master, mye_id, category_slug) + data = catalog_service.get_nexpart_subgroups_for_vehicle(master, mye_id, category_slug, tenant) else: if not category_id: return jsonify({'error': 'category_id required for oem mode'}), 400 data = catalog_service.get_groups(master, mye_id, category_id) return jsonify({'data': data, 'mode': mode}) - return _master_only(_do) + return _with_conns(_do) # ─── Parts with stock enrichment (master + tenant) ─── @@ -251,19 +257,19 @@ def part_types(): mode = normalize_mode(request.args.get('mode')) if not mye_id: return jsonify({'error': 'mye_id required'}), 400 - def _do(master): + def _do(master, tenant, branch_id): if mode == 'local': if not group_slug or not subgroup_slug: return jsonify({'error': 'group_slug and subgroup_slug required for local mode'}), 400 data = catalog_service.get_nexpart_part_types_for_vehicle( - master, mye_id, group_slug, subgroup_slug + master, mye_id, group_slug, subgroup_slug, tenant ) else: if not group_id: return jsonify({'error': 'group_id required for oem mode'}), 400 data = catalog_service.get_part_types(master, mye_id, group_id) return jsonify({'data': data, 'mode': mode}) - return _master_only(_do) + return _with_conns(_do) @catalog_bp.route('/shop-supplies/groups', methods=['GET']) @@ -307,8 +313,8 @@ def shop_supplies_parts(): group_slug = request.args.get('group_slug') subgroup_slug = request.args.get('subgroup_slug') part_type_slug = request.args.get('part_type_slug') - page = request.args.get('page', 1, type=int) - per_page = request.args.get('per_page', 30, type=int) + page = max(1, request.args.get('page', 1, type=int) or 1) + per_page = max(1, min(request.args.get('per_page', 30, type=int) or 30, 100)) if not group_slug or not subgroup_slug or not part_type_slug: return jsonify({'error': 'group_slug, subgroup_slug, part_type_slug required'}), 400 def _do(master, tenant, branch_id): @@ -344,8 +350,8 @@ def parts(): nexpart_subgroup = request.args.get('nexpart_subgroup') nexpart_part_type = request.args.get('nexpart_part_type') - page = request.args.get('page', 1, type=int) - per_page = request.args.get('per_page', 30, type=int) + page = max(1, request.args.get('page', 1, type=int) or 1) + per_page = max(1, min(request.args.get('per_page', 30, type=int) or 30, 100)) mode = normalize_mode(request.args.get('mode')) if not mye_id: @@ -364,14 +370,20 @@ def parts(): def _do(master, tenant, branch_id): allowed_brands = _get_allowed_brands(tenant) if tenant else None + # For local mode with allowed_brands, fetch everything first so filtering + # happens before pagination. OEM mode keeps post-filter for now. + fetch_all_for_filter = bool(allowed_brands) and (mode == 'local' or use_nexpart_nav) + _page = 1 if fetch_all_for_filter else page + _per_page = 9999 if fetch_all_for_filter else per_page + if use_nexpart_nav: result = catalog_service.get_parts_for_nexpart_triple( master, mye_id, nexpart_group, nexpart_subgroup, nexpart_part_type, - tenant, branch_id, page, per_page, + tenant, branch_id, _page, _per_page, ) elif mode == 'local': result = catalog_service.get_parts_local( - master, mye_id, group_id, tenant, branch_id, page, per_page, part_type=part_type, + master, mye_id, group_id, tenant, branch_id, _page, _per_page, part_type=part_type, ) else: result = catalog_service.get_parts( @@ -379,6 +391,11 @@ def parts(): ) if allowed_brands: result['data'] = _filter_parts_by_allowed_brands(master, result.get('data', []), allowed_brands) + if fetch_all_for_filter: + total = len(result['data']) + offset = (page - 1) * per_page + result['data'] = result['data'][offset:offset + per_page] + result['pagination'] = catalog_service._pagination(page, per_page, total) result['allowed_brands'] = allowed_brands or [] return jsonify(result) return _with_conns(_do) @@ -811,7 +828,7 @@ def brand_parts(): if tenant and oem_ids: try: from services.catalog_service import _get_local_stock_bulk - local_stock = _get_local_stock_bulk(tenant, oem_ids) + local_stock = _get_local_stock_bulk(tenant, branch_id, oem_ids, []) except Exception: pass @@ -886,7 +903,7 @@ def brand_parts(): if tenant and part_ids: try: from services.catalog_service import _get_local_stock_bulk - local_stock = _get_local_stock_bulk(tenant, part_ids) + local_stock = _get_local_stock_bulk(tenant, branch_id, [], part_ids) except Exception: pass @@ -1007,7 +1024,7 @@ def mye_parts(): if tenant and oem_ids: try: from services.catalog_service import _get_local_stock_bulk - local_stock = _get_local_stock_bulk(tenant, oem_ids) + local_stock = _get_local_stock_bulk(tenant, branch_id, oem_ids, []) except Exception: pass @@ -1082,7 +1099,7 @@ def mye_parts(): if tenant and part_ids: try: from services.catalog_service import _get_local_stock_bulk - local_stock = _get_local_stock_bulk(tenant, part_ids) + local_stock = _get_local_stock_bulk(tenant, branch_id, [], part_ids) except Exception: pass diff --git a/pos/blueprints/config_bp.py b/pos/blueprints/config_bp.py index 62c58fb..219d3fc 100644 --- a/pos/blueprints/config_bp.py +++ b/pos/blueprints/config_bp.py @@ -469,21 +469,12 @@ _ALLOWED_PART_BRANDS = [ @config_bp.route('/available-brands', methods=['GET']) @require_auth() def get_available_brands(): - """Return whitelisted aftermarket manufacturer names from master DB.""" - from tenant_db import get_master_conn - conn = get_master_conn() - cur = conn.cursor() - cur.execute(""" - SELECT DISTINCT m.name_manufacture - FROM manufacturers m - JOIN aftermarket_parts ap ON ap.manufacturer_id = m.id_manufacture - WHERE m.name_manufacture IS NOT NULL AND m.name_manufacture != '' - AND LOWER(m.name_manufacture) = ANY(%s) - ORDER BY m.name_manufacture ASC - """, ([b.lower() for b in _ALLOWED_PART_BRANDS],)) - brands = [r[0] for r in cur.fetchall()] - cur.close() - conn.close() + """Return the whitelisted part manufacturer names. + + The master DB manufacturers/aftermarket_parts tables were removed with + TecDoc, so we return the curated whitelist directly. + """ + brands = sorted({b.strip() for b in _ALLOWED_PART_BRANDS if b and b.strip()}) return jsonify({'brands': brands}) diff --git a/pos/blueprints/dashboard_stats_bp.py b/pos/blueprints/dashboard_stats_bp.py index a25fae3..3479520 100644 --- a/pos/blueprints/dashboard_stats_bp.py +++ b/pos/blueprints/dashboard_stats_bp.py @@ -12,6 +12,7 @@ dashboard_stats_bp = Blueprint('dashboard_stats', __name__, url_prefix='/pos/api from middleware import require_auth +from tenant_db import get_tenant_conn class DecimalEncoder(json.JSONEncoder): @@ -25,83 +26,95 @@ class DecimalEncoder(json.JSONEncoder): @require_auth() def get_stats(): """Summary stats for today and this month.""" - from tenant_db import get_tenant_db - db = get_tenant_db() + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() today = datetime.utcnow().date() month_start = today.replace(day=1) - # Sales today - today_sales = db.execute( - """SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total - FROM sales WHERE DATE(created_at) = %s""", (today,) - ).fetchone() + try: + # Sales today + cur.execute( + """SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total + FROM sales WHERE DATE(created_at) = %s""", (today,) + ) + today_sales = cur.fetchone() - # Sales this month - month_sales = db.execute( - """SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total - FROM sales WHERE DATE(created_at) >= %s""", (month_start,) - ).fetchone() + # Sales this month + cur.execute( + """SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total + FROM sales WHERE DATE(created_at) >= %s""", (month_start,) + ) + month_sales = cur.fetchone() - # Top 5 products today - top_products = db.execute( - """SELECT p.name, SUM(si.quantity) as qty, SUM(si.total) as revenue - FROM sale_items si - JOIN sales s ON si.sale_id = s.id_sale - JOIN parts p ON si.part_id = p.id_part - WHERE DATE(s.created_at) = %s - GROUP BY p.name - ORDER BY revenue DESC - LIMIT 5""", (today,) - ).fetchall() + # Top 5 products today + cur.execute( + """SELECT si.name, SUM(si.quantity) as qty, SUM(si.subtotal) as revenue + FROM sale_items si + JOIN sales s ON si.sale_id = s.id + WHERE DATE(s.created_at) = %s + GROUP BY si.name + ORDER BY revenue DESC + LIMIT 5""", (today,) + ) + top_products = cur.fetchall() - # Hourly sales today (0-23) - hourly = db.execute( - """SELECT EXTRACT(HOUR FROM created_at)::int as hour, - COUNT(*) as count, COALESCE(SUM(total), 0) as total - FROM sales WHERE DATE(created_at) = %s - GROUP BY hour ORDER BY hour""", (today,) - ).fetchall() - hourly_map = {row['hour']: {'count': row['count'], 'total': row['total']} for row in hourly} + # Hourly sales today (0-23) + cur.execute( + """SELECT EXTRACT(HOUR FROM created_at)::int as hour, + COUNT(*) as count, COALESCE(SUM(total), 0) as total + FROM sales WHERE DATE(created_at) = %s + GROUP BY hour ORDER BY hour""", (today,) + ) + hourly = cur.fetchall() + hourly_map = {row[0]: {'count': row[1], 'total': row[2]} for row in hourly} - return jsonify({ - 'today': { - 'sales_count': today_sales['count'], - 'sales_total': today_sales['total'], - }, - 'month': { - 'sales_count': month_sales['count'], - 'sales_total': month_sales['total'], - }, - 'top_products': [ - {'name': row['name'], 'quantity': row['qty'], 'revenue': row['revenue']} - for row in top_products - ], - 'hourly_sales': [ - {'hour': h, 'count': hourly_map.get(h, {}).get('count', 0), - 'total': hourly_map.get(h, {}).get('total', 0)} - for h in range(24) - ], - }, cls=DecimalEncoder) + return jsonify({ + 'today': { + 'sales_count': today_sales[0], + 'sales_total': float(today_sales[1]) if today_sales[1] is not None else 0, + }, + 'month': { + 'sales_count': month_sales[0], + 'sales_total': float(month_sales[1]) if month_sales[1] is not None else 0, + }, + 'top_products': [ + {'name': row[0], 'quantity': row[1], 'revenue': float(row[2]) if row[2] is not None else 0} + for row in top_products + ], + 'hourly_sales': [ + {'hour': h, 'count': hourly_map.get(h, {}).get('count', 0), + 'total': float(hourly_map.get(h, {}).get('total', 0))} + for h in range(24) + ], + }) + finally: + cur.close() + conn.close() @dashboard_stats_bp.route('/stats/employees', methods=['GET']) @require_auth() def get_employee_stats(): """Sales per employee today.""" - from tenant_db import get_tenant_db - db = get_tenant_db() + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() today = datetime.utcnow().date() - rows = db.execute( - """SELECT e.name, COUNT(s.id_sale) as sales, COALESCE(SUM(s.total), 0) as total - FROM sales s - JOIN employees e ON s.employee_id = e.id_employee - WHERE DATE(s.created_at) = %s - GROUP BY e.name - ORDER BY total DESC""", (today,) - ).fetchall() - return jsonify({ - 'employees': [ - {'name': row['name'], 'sales': row['sales'], 'total': row['total']} - for row in rows - ] - }, cls=DecimalEncoder) + try: + cur.execute( + """SELECT e.name, COUNT(s.id) as sales, COALESCE(SUM(s.total), 0) as total + FROM sales s + JOIN employees e ON s.employee_id = e.id + WHERE DATE(s.created_at) = %s + GROUP BY e.name + ORDER BY total DESC""", (today,) + ) + rows = cur.fetchall() + return jsonify({ + 'employees': [ + {'name': row[0], 'sales': row[1], 'total': float(row[2]) if row[2] is not None else 0} + for row in rows + ] + }) + finally: + cur.close() + conn.close() diff --git a/pos/blueprints/dropshipping_bp.py b/pos/blueprints/dropshipping_bp.py new file mode 100644 index 0000000..2c958ad --- /dev/null +++ b/pos/blueprints/dropshipping_bp.py @@ -0,0 +1,128 @@ +"""Dropshipping API — public read-only inventory endpoints. + +Authentication: X-Dropshipping-Key header (per-tenant). +Optional: X-Tenant-Subdomain for faster resolution. +""" + +from flask import Blueprint, request, jsonify, g +from tenant_db import get_tenant_conn, get_master_conn +from services import dropshipping_service as ds_svc +from services.webhook_service import dispatch_webhooks_bulk + +dropship_bp = Blueprint("dropship", __name__, url_prefix="/pos/api/dropship") + + +def _resolve_tenant_by_key(api_key: str, subdomain_hint: str = None): + """Return (tenant_conn, tenant_id) for a valid dropshipping API key. + + If subdomain_hint is provided, validate only that tenant. + Otherwise scan active tenants (acceptable for small tenant count). + """ + master = get_master_conn() + try: + cur = master.cursor() + if subdomain_hint: + cur.execute( + "SELECT id, db_name FROM tenants WHERE subdomain = %s AND is_active = true", + (subdomain_hint,), + ) + rows = cur.fetchall() + else: + cur.execute("SELECT id, db_name FROM tenants WHERE is_active = true") + rows = cur.fetchall() + cur.close() + + for tid, db_name in rows: + try: + tconn = get_tenant_conn(tid) + if ds_svc.validate_api_key(tconn, api_key): + return tconn, tid + tconn.close() + except Exception: + continue + return None, None + finally: + master.close() + + +def _require_dropship_auth(): + key = request.headers.get("X-Dropshipping-Key") + subdomain = request.headers.get("X-Tenant-Subdomain") + if not key: + return jsonify({"error": "Missing X-Dropshipping-Key header"}), 401 + tconn, tid = _resolve_tenant_by_key(key, subdomain_hint=subdomain) + if not tconn: + return jsonify({"error": "Invalid API key or tenant inactive"}), 401 + g.tenant_id = tid + g.tenant_conn = tconn + return None + + +def _release_tenant(): + if hasattr(g, "tenant_conn") and g.tenant_conn: + g.tenant_conn.close() + + +@dropship_bp.route("/inventory", methods=["GET"]) +def list_inventory(): + err = _require_dropship_auth() + if err: + return err + try: + page = int(request.args.get("page", 1)) + per_page = min(int(request.args.get("per_page", 50)), 200) + search = request.args.get("q") + result = ds_svc.get_inventory_list(g.tenant_conn, search=search, page=page, per_page=per_page) + return jsonify(result) + finally: + _release_tenant() + + +@dropship_bp.route("/inventory/", methods=["GET"]) +def get_inventory_item(sku): + err = _require_dropship_auth() + if err: + return err + try: + item = ds_svc.get_inventory_by_sku(g.tenant_conn, sku) + if not item: + return jsonify({"error": "SKU not found"}), 404 + return jsonify(item) + finally: + _release_tenant() + + +@dropship_bp.route("/stock", methods=["GET"]) +def get_stock(): + err = _require_dropship_auth() + if err: + return err + try: + skus = request.args.get("skus", "") + sku_list = [s.strip() for s in skus.split(",") if s.strip()] + if not sku_list: + return jsonify({"error": "Provide ?skus=SKU1,SKU2,SKU3"}), 400 + result = ds_svc.get_stock_by_skus(g.tenant_conn, sku_list) + return jsonify({"stock": result}) + finally: + _release_tenant() + + +@dropship_bp.route("/webhooks/test", methods=["POST"]) +def test_webhook(): + """Test endpoint to trigger a sample webhook to all configured targets.""" + err = _require_dropship_auth() + if err: + return err + try: + urls = ds_svc.get_webhook_targets(g.tenant_conn, "stock_updated") + if not urls: + return jsonify({"error": "No webhook targets configured"}), 400 + results = dispatch_webhooks_bulk( + urls, + "test", + {"message": "Webhook test from Nexus POS", "tenant_id": g.tenant_id}, + ) + return jsonify({"dispatched": len(results), "results": results}) + finally: + _release_tenant() diff --git a/pos/blueprints/inventory_bp.py b/pos/blueprints/inventory_bp.py index 8a8ee2e..1471b7e 100644 --- a/pos/blueprints/inventory_bp.py +++ b/pos/blueprints/inventory_bp.py @@ -4,6 +4,7 @@ import io import json import os +import csv from datetime import datetime, timedelta from flask import Blueprint, request, jsonify, g from middleware import require_auth, has_permission @@ -46,6 +47,24 @@ def _apply_tier_discounts(price_1, discounts): return p2, p3 +def _to_decimal(val, default=0): + if val is None or val == '': + return default + try: + return float(str(val).replace(',', '')) + except (ValueError, TypeError): + return default + + +def _to_int(val, default=0): + if val is None or val == '': + return default + try: + return int(float(str(val).replace(',', ''))) + except (ValueError, TypeError): + return default + + # ─── AI Classification ─────────────────────────── @inventory_bp.route('/classify/', methods=['GET']) @@ -203,9 +222,10 @@ def get_item(item_id): conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" - SELECT i.*, b.name as branch_name + SELECT i.*, b.name as branch_name, c.name as category_name FROM inventory i LEFT JOIN branches b ON i.branch_id = b.id + LEFT JOIN categories c ON i.category_id = c.id WHERE i.id = %s """, (item_id,)) row = cur.fetchone() @@ -309,6 +329,23 @@ def create_item(): if initial_stock > 0: record_initial(conn, item_id, branch_id, initial_stock, data.get('cost')) + # Insert SKU aliases if provided + sku_aliases = data.get('sku_aliases', []) + if sku_aliases: + for alias in sku_aliases: + sku = (alias.get('sku') or '').strip() + label = (alias.get('label') or '').strip() + if sku: + cur.execute( + """ + INSERT INTO inventory_sku_aliases (inventory_id, sku, label) + VALUES (%s, %s, %s) + ON CONFLICT (inventory_id, sku) DO UPDATE SET + is_active = true, label = EXCLUDED.label + """, + (item_id, sku, label or None), + ) + log_action(conn, 'INVENTORY_CREATE', 'inventory', item_id, new_value={'part_number': data['part_number'], 'name': data['name'], 'initial_stock': initial_stock}) @@ -333,13 +370,19 @@ def create_item(): if not compat_background: # Fallback: synchronous processing if compat_source in ('tecdoc', 'both'): + master = None 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}") + finally: + if master: + try: + master.close() + except Exception: + pass if compat_source in ('qwen', 'both'): try: @@ -371,6 +414,291 @@ def create_item(): return jsonify({'error': str(e)}), 500 +@inventory_bp.route('/items/bulk-import', methods=['POST']) +@require_auth('inventory.edit') +def bulk_import_items(): + """ + Bulk import inventory items with optional vehicle compatibility. + Expects multipart/form-data with a 'file' (CSV/Excel) or JSON body. + Headers: + X-Import-Mode: 'strict' (default) aborts on first error; 'lenient' skips bad rows. + X-Import-Strategy: 'qwen' (default) auto-generates missing compat via QWEN; + 'skip' ignores missing compat; 'reject' requires all compat. + Expected CSV columns (case-insensitive): + sku/part_number, name, brand, price, stock, cost, + location, description, category, make, model, year, engine, engine_code + Optional compat columns: make, model, year, engine, engine_code + """ + from services.qwen_fitment import get_vehicle_fitment + from services.inventory_vehicle_compat import save_qwen_fitment + import services.inventory_vehicle_compat as ivc_service + + mode = request.headers.get('X-Import-Mode', 'strict').lower() + strategy = request.headers.get('X-Import-Strategy', 'qwen').lower() + errors = [] + warnings = [] + created_ids = [] + skipped = 0 + created = 0 + + # ---------- 1. Parse input ---------- + rows = [] + if request.content_type and 'multipart/form-data' in request.content_type: + file = request.files.get('file') + if not file: + return jsonify({'error': 'No file uploaded'}), 400 + try: + ext = os.path.splitext(file.filename)[1].lower() + if ext == '.csv': + stream = io.TextIOWrapper(file.stream, encoding='utf-8-sig') + reader = csv.DictReader(stream) + rows = list(reader) + elif ext in ('.xls', '.xlsx', '.xlsm'): + try: + import openpyxl + except ImportError: + return jsonify({'error': 'Excel support requires openpyxl. Please convert to CSV or install openpyxl.'}), 400 + wb = openpyxl.load_workbook(file.stream, data_only=True) + ws = wb.active + headers = [str(c).strip().lower().replace(' ', '_') if c else '' for c in next(ws.iter_rows(values_only=True))] + for raw in ws.iter_rows(min_row=2, values_only=True): + rows.append({headers[i]: (str(v) if v is not None else '') for i, v in enumerate(raw) if i < len(headers)}) + else: + return jsonify({'error': 'Unsupported file type. Use CSV or Excel.'}), 400 + except Exception as e: + return jsonify({'error': f'Failed to parse file: {e}'}), 400 + else: + body = request.get_json() or {} + rows = body.get('items') + if not rows or not isinstance(rows, list): + return jsonify({'error': 'Expected JSON body with an "items" array'}), 400 + + if not rows: + return jsonify({'error': 'No data rows found'}), 400 + + # Normalise column names on first row + if rows: + first = rows[0] + normalised_keys = {} + for k in list(first.keys()): + nk = str(k).strip().lower().replace(' ', '_') + normalised_keys[k] = nk + for r in rows: + for old_k, new_k in normalised_keys.items(): + if old_k in r: + r[new_k] = r.pop(old_k) + + # Map common synonyms + col_map = { + 'sku': 'part_number', 'numero_de_parte': 'part_number', 'parte': 'part_number', + 'nombre': 'name', 'producto': 'name', 'descripcion': 'name', + 'marca': 'brand', 'precio': 'price', 'costo': 'cost', + 'cantidad': 'stock', 'existencia': 'stock', 'inventario': 'stock', + 'ubicacion': 'location', 'categoria': 'category', + 'fabricante': 'make', 'vehiculo': 'make', 'auto': 'make', + 'modelo': 'model', 'anio': 'year', 'ano': 'year', + 'motor': 'engine', 'codigo_motor': 'engine_code', + } + for r in rows: + for old_k, new_k in col_map.items(): + if old_k in r and new_k not in r: + r[new_k] = r.pop(old_k) + + required = ['part_number', 'name'] + first_keys = set(rows[0].keys()) if rows else set() + missing_required = [c for c in required if c not in first_keys] + if missing_required: + return jsonify({'error': f'Missing required columns: {missing_required}'}), 400 + + conn = get_tenant_conn(g.tenant_id) + try: + cur = conn.cursor() + from services.barcode_generator import generate_barcode + from tenant_db import get_master_conn + from services.inventory_engine import record_initial + + # Pre-fetch tenant db_name for barcode generation + mconn = get_master_conn() + mcur = mconn.cursor() + mcur.execute("SELECT db_name FROM tenants WHERE id = %s", (g.tenant_id,)) + db_name_row = mcur.fetchone() + db_name = db_name_row[0] if db_name_row else None + mcur.close(); mconn.close() + + for row_num, row in enumerate(rows, start=1): + part_number = str(row.get('part_number', '')).strip() + name = str(row.get('name', '')).strip() + if not part_number or not name: + msg = f'Row {row_num}: part_number and name are required' + if mode == 'strict': + conn.rollback(); cur.close(); conn.close() + return jsonify({'error': msg}), 400 + warnings.append(msg) + skipped += 1 + continue + + branch_id = _to_int(row.get('branch_id'), g.branch_id) + if not branch_id: + msg = f'Row {row_num}: branch_id required (not set in row or session)' + if mode == 'strict': + conn.rollback(); cur.close(); conn.close() + return jsonify({'error': msg}), 400 + warnings.append(msg) + skipped += 1 + continue + + brand = str(row.get('brand', '')).strip() + price_1 = _to_decimal(row.get('price'), 0) + stock = _to_int(row.get('stock'), 0) + cost = _to_decimal(row.get('cost'), 0) + location = str(row.get('location', '')).strip() + description = str(row.get('description', '')).strip() + category = str(row.get('category', '')).strip() + + # Check if item already exists for this branch + cur.execute("SELECT id FROM inventory WHERE branch_id = %s AND part_number = %s", (branch_id, part_number)) + existing = cur.fetchone() + + if existing: + item_id = existing[0] + # Update existing item — add stock if provided + cur.execute( + """ + UPDATE inventory SET + name = %s, + brand = COALESCE(NULLIF(%s,''), brand), + cost = CASE WHEN %s > 0 THEN %s ELSE cost END, + price_1 = CASE WHEN %s > 0 THEN %s ELSE price_1 END, + stock = stock + %s, + location = COALESCE(NULLIF(%s,''), location), + description = COALESCE(NULLIF(%s,''), description), + category = COALESCE(NULLIF(%s,''), category) + WHERE id = %s + """, + (name, brand, cost, cost, price_1, price_1, stock, location, description, category, item_id) + ) + was_inserted = False + # Record stock adjustment for existing item if stock > 0 + if stock > 0: + record_initial(conn, item_id, branch_id, stock, cost if cost > 0 else None) + else: + # Generate barcode for new item + barcode = generate_barcode(conn, db_name) + + cur.execute( + """ + INSERT INTO inventory + (branch_id, part_number, barcode, name, brand, cost, price_1, location, description, category, unit) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, + (branch_id, part_number, barcode, name, brand, cost, price_1, location, description, category, 'PZA') + ) + item_id = cur.fetchone()[0] + was_inserted = True + + # Record initial stock if provided and new item + if was_inserted and stock > 0: + record_initial(conn, item_id, branch_id, stock, cost if cost > 0 else None) + + conn.commit() + created_ids.append(item_id) + created += 1 + + # ---------- 2. Vehicle compatibility ---------- + make = str(row.get('make', '')).strip() + model = str(row.get('model', '')).strip() + year_str = str(row.get('year', '')).strip() + engine = str(row.get('engine', '')).strip() + engine_code = str(row.get('engine_code', '')).strip() + + has_compat = any([make, model, year_str, engine, engine_code]) + + if has_compat: + # Validate / resolve against vehicle tables + year = _to_int(year_str, None) + mye_id = None + if make and model and year: + # Try exact match against model_year_engine + cur.execute( + """ + SELECT mye.id FROM model_year_engine mye + JOIN models m ON m.id = mye.model_id + JOIN brands b ON b.id = m.brand_id + JOIN years y ON y.id = mye.year_id + WHERE LOWER(b.name) = LOWER(%s) + AND LOWER(m.name) = LOWER(%s) + AND y.year = %s + LIMIT 1 + """, + (make, model, year) + ) + r = cur.fetchone() + if r: + mye_id = r[0] + else: + warnings.append( + f'Row {row_num}: vehicle "{make} {model} {year}" not found in catalog; ' + 'saving as text-only compatibility.' + ) + + if mye_id: + cur.execute( + """ + INSERT INTO inventory_vehicle_compat + (inventory_id, model_year_engine_id, make, model, year, engine, engine_code) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ON CONFLICT DO NOTHING + """, + (item_id, mye_id, make, model, year_str, engine, engine_code) + ) + else: + cur.execute( + """ + INSERT INTO inventory_vehicle_compat + (inventory_id, make, model, year, engine, engine_code) + VALUES (%s, %s, %s, %s, %s, %s) + ON CONFLICT DO NOTHING + """, + (item_id, make, model, year_str, engine, engine_code) + ) + conn.commit() + + else: + # No compatibility provided + if strategy == 'reject': + msg = f'Row {row_num}: missing vehicle compatibility (strategy=reject)' + if mode == 'strict': + conn.rollback(); cur.close(); conn.close() + return jsonify({'error': msg}), 400 + warnings.append(msg) + elif strategy == 'qwen': + try: + fitment = get_vehicle_fitment(part_number, name, brand) + save_qwen_fitment(conn, item_id, fitment) + conn.commit() + except Exception as qe: + warnings.append(f'Row {row_num}: QWEN fitment failed: {qe}') + # strategy == 'skip' → do nothing + + cur.close() + conn.close() + return jsonify({ + 'created': created, + 'skipped': skipped, + 'item_ids': created_ids, + 'warnings': warnings, + 'errors': errors, + }), 200 + + except Exception as e: + conn.rollback() + try: cur.close() + except Exception: pass + conn.close() + return jsonify({'error': str(e)}), 500 + + @inventory_bp.route('/items/', methods=['PUT']) @require_auth('inventory.edit') def update_item(item_id): @@ -1371,22 +1699,6 @@ def report_branch_comparison(): # ─── Categories and Brands ───────────────────── -@inventory_bp.route('/categories', methods=['GET']) -@require_auth('inventory.view') -def list_categories(): - """Get distinct categories from inventory.""" - conn = get_tenant_conn(g.tenant_id) - cur = conn.cursor() - cur.execute(""" - SELECT DISTINCT category_id FROM inventory - WHERE is_active = true AND category_id IS NOT NULL - ORDER BY category_id - """) - categories = [r[0] for r in cur.fetchall()] - cur.close(); conn.close() - return jsonify({'data': categories}) - - @inventory_bp.route('/brands', methods=['GET']) @require_auth('inventory.view') def list_brands(): @@ -1718,6 +2030,89 @@ def auto_match_item_vehicles(item_id): return jsonify({'error': 'No compatibility source configured'}), 400 +# ─── SKU Aliases (multiple part numbers per item) ─────────────────────── + +@inventory_bp.route('/items//skus', methods=['GET']) +@require_auth('inventory.view') +def get_item_sku_aliases(item_id): + """Return active SKU aliases for an inventory item.""" + conn = get_tenant_conn(g.tenant_id) + try: + cur = conn.cursor() + cur.execute( + """ + SELECT id, sku, label, created_at + FROM inventory_sku_aliases + WHERE inventory_id = %s AND is_active = true + ORDER BY created_at + """, + (item_id,), + ) + rows = cur.fetchall() + cur.close() + return jsonify({ + 'aliases': [ + {'id': r[0], 'sku': r[1], 'label': r[2], 'created_at': r[3]} + for r in rows + ] + }) + finally: + conn.close() + + +@inventory_bp.route('/items//skus', methods=['POST']) +@require_auth('inventory.edit') +def add_item_sku_alias(item_id): + """Add an SKU alias to an inventory item.""" + data = request.get_json() or {} + sku = (data.get('sku') or '').strip() + label = (data.get('label') or '').strip() + if not sku: + return jsonify({'error': 'sku is required'}), 400 + conn = get_tenant_conn(g.tenant_id) + try: + cur = conn.cursor() + cur.execute( + """ + INSERT INTO inventory_sku_aliases (inventory_id, sku, label) + VALUES (%s, %s, %s) + ON CONFLICT (inventory_id, sku) DO UPDATE SET + is_active = true, label = EXCLUDED.label + RETURNING id + """, + (item_id, sku, label or None), + ) + row = cur.fetchone() + conn.commit() + cur.close() + return jsonify({'id': row[0], 'message': 'SKU alias added'}), 201 + except Exception as e: + conn.rollback() + return jsonify({'error': str(e)}), 500 + finally: + conn.close() + + +@inventory_bp.route('/items//skus/', methods=['DELETE']) +@require_auth('inventory.edit') +def delete_item_sku_alias(item_id, alias_id): + """Soft-delete an SKU alias.""" + conn = get_tenant_conn(g.tenant_id) + try: + cur = conn.cursor() + cur.execute( + """ + UPDATE inventory_sku_aliases SET is_active = false WHERE id = %s AND inventory_id = %s + """, + (alias_id, item_id), + ) + conn.commit() + cur.close() + return jsonify({'message': 'SKU alias removed'}) + finally: + conn.close() + + @inventory_bp.route('/mye/search', methods=['GET']) @require_auth() def search_mye_endpoint(): @@ -1735,6 +2130,178 @@ def search_mye_endpoint(): master.close() +# ─── Manual Vehicle Compatibility (text-based) ──────────────────────────── + +@inventory_bp.route('/items//vehicles/manual', methods=['POST']) +@require_auth('inventory.edit') +def add_manual_vehicle_compat(item_id): + """Add a manual vehicle compatibility using free-text fields.""" + data = request.get_json() or {} + make = (data.get('make') or '').strip() + model = (data.get('model') or '').strip() + year = data.get('year') + engine = (data.get('engine') or '').strip() + engine_code = (data.get('engine_code') or '').strip() + + if not make or not model or not year: + return jsonify({'error': 'make, model and year are required'}), 400 + + conn = get_tenant_conn(g.tenant_id) + try: + cur = conn.cursor() + cur.execute( + """ + INSERT INTO inventory_vehicle_compat + (inventory_id, make, model, year, engine, engine_code, source, model_year_engine_id) + VALUES (%s, %s, %s, %s, %s, %s, 'manual', NULL) + ON CONFLICT DO NOTHING + RETURNING id + """, + (item_id, make, model, year, engine or None, engine_code or None), + ) + row = cur.fetchone() + conn.commit() + cur.close() + if not row: + return jsonify({'error': 'Compatibility already exists or item not found'}), 409 + return jsonify({'id': row[0], 'message': 'Compatibility added'}), 201 + finally: + conn.close() + + +@inventory_bp.route('/vehicles/makes', methods=['GET']) +@require_auth() +def get_vehicle_makes(): + """Return distinct vehicle makes from master DB.""" + master = get_master_conn() + try: + cur = master.cursor() + cur.execute("SELECT id_brand, name_brand FROM brands ORDER BY name_brand") + rows = cur.fetchall() + cur.close() + return jsonify({'makes': [{'id': r[0], 'name': r[1]} for r in rows]}) + finally: + master.close() + + +@inventory_bp.route('/vehicles/models', methods=['GET']) +@require_auth() +def get_vehicle_models(): + """Return models for a given brand.""" + brand_id = request.args.get('brand_id', type=int) + if not brand_id: + return jsonify({'error': 'brand_id required'}), 400 + master = get_master_conn() + try: + cur = master.cursor() + cur.execute( + "SELECT id_model, name_model FROM models WHERE brand_id = %s ORDER BY name_model", + (brand_id,), + ) + rows = cur.fetchall() + cur.close() + return jsonify({'models': [{'id': r[0], 'name': r[1]} for r in rows]}) + finally: + master.close() + + +@inventory_bp.route('/vehicles/years', methods=['GET']) +@require_auth() +def get_vehicle_years(): + """Return distinct years available for a model.""" + model_id = request.args.get('model_id', type=int) + if not model_id: + return jsonify({'error': 'model_id required'}), 400 + master = get_master_conn() + try: + cur = master.cursor() + cur.execute( + """ + SELECT DISTINCT y.id_year, y.year_car + FROM model_year_engine mye + JOIN years y ON y.id_year = mye.year_id + WHERE mye.model_id = %s + ORDER BY y.year_car DESC + """, + (model_id,), + ) + rows = cur.fetchall() + cur.close() + return jsonify({'years': [{'id': r[0], 'year': r[1]} for r in rows]}) + finally: + master.close() + + +@inventory_bp.route('/vehicles/engines', methods=['GET']) +@require_auth() +def get_vehicle_engines(): + """Return engines available for a model+year.""" + model_id = request.args.get('model_id', type=int) + year_id = request.args.get('year_id', type=int) + if not model_id or not year_id: + return jsonify({'error': 'model_id and year_id required'}), 400 + master = get_master_conn() + try: + cur = master.cursor() + cur.execute( + """ + SELECT DISTINCT e.id_engine, e.name_engine, e.engine_code + FROM model_year_engine mye + JOIN engines e ON e.id_engine = mye.engine_id + WHERE mye.model_id = %s AND mye.year_id = %s + ORDER BY e.name_engine + """, + (model_id, year_id), + ) + rows = cur.fetchall() + cur.close() + return jsonify({'engines': [{'id': r[0], 'name': r[1], 'code': r[2]} for r in rows]}) + finally: + master.close() + + +# ─── Categories ────────────────────────────────── + +@inventory_bp.route('/categories', methods=['GET']) +@require_auth() +def list_inventory_categories(): + """Return active categories (root only). Optional ?parent_id= for subcategories.""" + parent_id = request.args.get('parent_id') + conn = get_tenant_conn(g.tenant_id) + try: + cur = conn.cursor() + if parent_id: + cur.execute( + "SELECT id, name FROM categories WHERE parent_id = %s AND is_active = true ORDER BY name", + (parent_id,) + ) + else: + cur.execute( + "SELECT id, name FROM categories WHERE parent_id IS NULL AND is_active = true ORDER BY name" + ) + rows = cur.fetchall() + return jsonify({'categories': [{'id': r[0], 'name': r[1]} for r in rows]}) + finally: + conn.close() + + +@inventory_bp.route('/categories//subcategories', methods=['GET']) +@require_auth() +def list_inventory_subcategories(category_id): + """Return subcategories for a given category.""" + conn = get_tenant_conn(g.tenant_id) + try: + cur = conn.cursor() + cur.execute( + "SELECT id, name FROM categories WHERE parent_id = %s AND is_active = true ORDER BY name", + (category_id,) + ) + rows = cur.fetchall() + return jsonify({'subcategories': [{'id': r[0], 'name': r[1]} for r in rows]}) + finally: + conn.close() + + # ─── Global Tier Discounts ─────────────────────── @inventory_bp.route('/tier-discounts', methods=['GET']) diff --git a/pos/blueprints/marketplace_external_bp.py b/pos/blueprints/marketplace_external_bp.py index c9aafdf..dd9fe05 100644 --- a/pos/blueprints/marketplace_external_bp.py +++ b/pos/blueprints/marketplace_external_bp.py @@ -28,6 +28,23 @@ from flask import Blueprint, request, jsonify, g from middleware import require_auth, has_permission from tenant_db import get_tenant_conn, get_master_conn from services import marketplace_external_service as meli_svc + + +def _get_public_base_url() -> str: + """Build the tenant's public base URL from request headers (handles reverse proxy).""" + proto = request.headers.get("X-Forwarded-Proto", request.scheme) + host = request.headers.get("X-Forwarded-Host", request.host) + + # Cloudflare specific header + cf_visitor = request.headers.get("CF-Visitor") + if cf_visitor and '"scheme":"https"' in cf_visitor: + proto = "https" + + # Force https for production domain if we detect http behind a TLS terminator + if proto == "http" and ("nexusautoparts.com.mx" in host or request.headers.get("X-Forwarded-Ssl") == "on"): + proto = "https" + + return f"{proto}://{host}/" from services.meli_service import MeliService, MeliAuthError marketplace_ext_bp = Blueprint( @@ -148,6 +165,10 @@ def search_categories(): return jsonify({"error": "MercadoLibre not connected"}), 400 result = svc.search_categories(site_id, q) return jsonify({"categories": result}) + except MeliAuthError: + return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401 + except Exception as e: + return jsonify({"error": str(e)}), 500 finally: conn.close() @@ -167,6 +188,10 @@ def list_listings(): try: result = meli_svc.get_listings(conn, page=page, per_page=per_page, status=status) return jsonify(result) + except MeliAuthError: + return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401 + except Exception as e: + return jsonify({"error": str(e)}), 500 finally: conn.close() @@ -199,6 +224,7 @@ def create_listings(): listing_type_id=listing_type, shipping_mode=shipping_mode, custom_data=custom_data, + base_url=_get_public_base_url(), ) return jsonify(result), 201 except ValueError as e: @@ -220,7 +246,7 @@ def inventory_check(): conn = get_tenant_conn(g.tenant_id) try: - result = meli_svc.check_inventory_ml_status(conn, inventory_ids) + result = meli_svc.check_inventory_ml_status(conn, inventory_ids, base_url=_get_public_base_url()) return jsonify(result) except Exception as e: return jsonify({"error": str(e)}), 500 @@ -242,6 +268,8 @@ def category_attributes(category_id): # Filter to required attributes only for the UI required = [a for a in attrs if a.get("tags", {}).get("required")] return jsonify({"attributes": required, "all": attrs}) + except MeliAuthError: + return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401 except Exception as e: return jsonify({"error": str(e)}), 500 finally: @@ -277,6 +305,7 @@ def validate_listings(): listing_type_id=listing_type, shipping_mode=shipping_mode, custom_data=custom_data, + base_url=_get_public_base_url(), ) return jsonify(result) except ValueError as e: @@ -316,6 +345,7 @@ def create_listings_async(): listing_type=listing_type, shipping_mode=shipping_mode, custom_data=custom_data, + base_url=_get_public_base_url(), ) return jsonify({"task_id": task.id, "status": "queued"}), 202 except Exception as e: @@ -413,6 +443,85 @@ def delete_listing(listing_id): conn.close() +@marketplace_ext_bp.route("/listings//permanent", methods=["DELETE"]) +@require_auth() +def delete_listing_permanent(listing_id): + """Hard-delete a closed listing from the local DB.""" + err = _require_meli_manage() + if err: + return err + + conn = get_tenant_conn(g.tenant_id) + try: + result = meli_svc.delete_listing_permanently(conn, listing_id) + return jsonify(result) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + except Exception as e: + return jsonify({"error": str(e)}), 500 + finally: + conn.close() + + +# ═══════════════════════════════════════════════════════════════════════════ +# QUESTIONS & ANSWERS +# ═══════════════════════════════════════════════════════════════════════════ + +@marketplace_ext_bp.route("/questions", methods=["GET"]) +@require_auth() +def list_questions(): + """List questions from local DB. Query param: ?status=unanswered""" + status = request.args.get("status") + conn = get_tenant_conn(g.tenant_id) + try: + items = meli_svc.list_local_questions(conn, status=status) + return jsonify({"items": items}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + finally: + conn.close() + + +@marketplace_ext_bp.route("/questions/sync", methods=["POST"]) +@require_auth() +def sync_questions(): + """Force sync questions from ML for all active listings.""" + err = _require_meli_manage() + if err: + return err + conn = get_tenant_conn(g.tenant_id) + try: + result = meli_svc.sync_questions(conn) + return jsonify(result) + except Exception as e: + return jsonify({"error": str(e)}), 500 + finally: + conn.close() + + +@marketplace_ext_bp.route("/questions//answer", methods=["POST"]) +@require_auth() +def answer_question(question_id): + """Answer a buyer question via ML API.""" + err = _require_meli_manage() + if err: + return err + data = request.get_json() or {} + text = data.get("text", "").strip() + if not text: + return jsonify({"error": "Answer text is required"}), 400 + conn = get_tenant_conn(g.tenant_id) + try: + result = meli_svc.answer_question(conn, question_id, text) + return jsonify(result) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + except Exception as e: + return jsonify({"error": str(e)}), 500 + finally: + conn.close() + + # ═══════════════════════════════════════════════════════════════════════════ # ORDERS # ═══════════════════════════════════════════════════════════════════════════ diff --git a/pos/blueprints/supplier_catalog_bp.py b/pos/blueprints/supplier_catalog_bp.py new file mode 100644 index 0000000..53eb13c --- /dev/null +++ b/pos/blueprints/supplier_catalog_bp.py @@ -0,0 +1,278 @@ +"""Supplier Catalog Blueprint — parts from suppliers with vehicle compatibility. + +Independent from inventory. Supports: +- Browse by supplier/category +- Search by text or vehicle (MYE or make/model/year) +- Part detail with compatibilities and interchanges +- Bulk import via Excel +""" + +from flask import Blueprint, request, jsonify, g, render_template +from psycopg2.extras import RealDictCursor + +from tenant_db import get_master_conn +from middleware import require_auth + +supplier_catalog_bp = Blueprint('supplier_catalog', __name__, url_prefix='/pos/api/supplier-catalog') + + +# ─── Helpers ─────────────────────────────────────────────────────────────── + +def _get_master_conn(): + return get_master_conn() + + +def _json_response(data, status=200): + return jsonify(data), status + + +# ─── Brands ──────────────────────────────────────────────────────────────── + +@supplier_catalog_bp.route('/brands', methods=['GET']) +@require_auth('catalog.view') +def list_brands(): + """Return distinct makes (vehicle brands) present in the supplier catalog.""" + conn = _get_master_conn() + cur = conn.cursor() + cur.execute(""" + SELECT DISTINCT make, COUNT(*) as cnt + FROM supplier_catalog_compat + WHERE make IS NOT NULL AND make != '' + GROUP BY make + ORDER BY make ASC + """) + rows = cur.fetchall() + cur.close(); conn.close() + return jsonify({'brands': [{'name': r[0], 'count': r[1]} for r in rows]}) + + +# ─── Search ──────────────────────────────────────────────────────────────── + +@supplier_catalog_bp.route('/search', methods=['GET']) +@require_auth('catalog.view') +def search_items(): + """Search supplier catalog by text and/or vehicle.""" + q = (request.args.get('q') or '').strip() + mye_id = request.args.get('mye_id', type=int) + make = (request.args.get('make') or '').strip() + model = (request.args.get('model') or '').strip() + year = request.args.get('year', type=int) + supplier = (request.args.get('supplier') or '').strip() + category = (request.args.get('category') or '').strip() + page = max(1, request.args.get('page', 1, type=int)) + per_page = min(100, request.args.get('per_page', 30, type=int)) + offset = (page - 1) * per_page + + conn = _get_master_conn() + cur = conn.cursor() + + # Build query dynamically + where_parts = ["sc.is_active = true"] + params = [] + + if supplier: + where_parts.append("sc.supplier_name = %s") + params.append(supplier) + if category: + where_parts.append("sc.category = %s") + params.append(category) + + # Text search on SKU, name, or interchange part_number + if q: + where_parts.append(""" + (sc.sku ILIKE %s OR sc.name ILIKE %s + OR EXISTS ( + SELECT 1 FROM supplier_catalog_interchange sci2 + WHERE sci2.catalog_id = sc.id AND sci2.part_number ILIKE %s + )) + """) + like_q = f'%{q}%' + params.extend([like_q, like_q, like_q]) + + # Vehicle filter + vehicle_join = "" + if mye_id: + vehicle_join = "JOIN supplier_catalog_compat scc ON scc.catalog_id = sc.id" + where_parts.append("scc.model_year_engine_id = %s") + params.append(mye_id) + elif make or model or year: + vehicle_join = "JOIN supplier_catalog_compat scc ON scc.catalog_id = sc.id" + if make: + where_parts.append("scc.make ILIKE %s") + params.append(f'%{make}%') + if model: + where_parts.append("scc.model ILIKE %s") + params.append(f'%{model}%') + if year: + where_parts.append("scc.year = %s") + params.append(year) + + where_sql = " AND ".join(where_parts) + + # Count total + count_sql = f""" + SELECT COUNT(DISTINCT sc.id) + FROM supplier_catalog sc + {vehicle_join} + WHERE {where_sql} + """ + cur.execute(count_sql, params) + total = cur.fetchone()[0] + + # Fetch page + fetch_sql = f""" + SELECT DISTINCT + sc.id, sc.supplier_name, sc.sku, sc.name, + sc.category, sc.description, sc.image_url + FROM supplier_catalog sc + {vehicle_join} + WHERE {where_sql} + ORDER BY sc.name ASC + LIMIT %s OFFSET %s + """ + cur.execute(fetch_sql, params + [per_page, offset]) + rows = cur.fetchall() + + items = [] + for r in rows: + items.append({ + 'id': r[0], + 'supplier_name': r[1], + 'sku': r[2], + 'name': r[3], + 'category': r[4], + 'description': r[5], + 'image_url': r[6], + }) + + cur.close(); conn.close() + return jsonify({ + 'data': items, + 'pagination': { + 'page': page, + 'per_page': per_page, + 'total': total, + 'total_pages': (total + per_page - 1) // per_page, + } + }) + + +# ─── Item Detail ─────────────────────────────────────────────────────────── + +@supplier_catalog_bp.route('/items/', methods=['GET']) +@require_auth('catalog.view') +def get_item_detail(item_id): + """Return full detail for a supplier catalog item including compat + interchanges.""" + conn = _get_master_conn() + cur = conn.cursor() + + cur.execute(""" + SELECT id, supplier_name, sku, name, category, description, image_url, created_at + FROM supplier_catalog WHERE id = %s AND is_active = true + """, (item_id,)) + row = cur.fetchone() + if not row: + cur.close(); conn.close() + return jsonify({'error': 'Item not found'}), 404 + + item = { + 'id': row[0], + 'supplier_name': row[1], + 'sku': row[2], + 'name': row[3], + 'category': row[4], + 'description': row[5], + 'image_url': row[6], + 'created_at': str(row[7]) if row[7] else None, + } + + # Compatibilities — deduplicate by (make, model, year, engine) because + # the same vehicle may map to multiple MYE ids (especially when engine + # text is empty from the supplier catalog). + cur.execute(""" + SELECT make, model, year, engine, model_year_engine_id, source + FROM supplier_catalog_compat + WHERE catalog_id = %s + ORDER BY make, model, year, engine + """, (item_id,)) + seen_compat = set() + compatibilities = [] + for r in cur.fetchall(): + key = (r[0], r[1], r[2], r[3]) + if key in seen_compat: + continue + seen_compat.add(key) + compatibilities.append({ + 'make': r[0], 'model': r[1], 'year': r[2], 'engine': r[3], + 'model_year_engine_id': r[4], 'source': r[5] + }) + item['compatibilities'] = compatibilities + + # Interchanges + cur.execute(""" + SELECT brand, part_number + FROM supplier_catalog_interchange + WHERE catalog_id = %s + ORDER BY brand, part_number + """, (item_id,)) + item['interchanges'] = [ + {'brand': r[0], 'part_number': r[1]} + for r in cur.fetchall() + ] + + cur.close(); conn.close() + return jsonify(item) + + +# ─── Categories ──────────────────────────────────────────────────────────── + +@supplier_catalog_bp.route('/categories', methods=['GET']) +@require_auth('catalog.view') +def list_categories(): + """Return distinct categories with counts.""" + conn = _get_master_conn() + cur = conn.cursor() + cur.execute(""" + SELECT category, COUNT(*) as cnt + FROM supplier_catalog + WHERE is_active = true + GROUP BY category + ORDER BY cnt DESC + """) + rows = cur.fetchall() + cur.close(); conn.close() + return jsonify({'categories': [{'name': r[0], 'count': r[1]} for r in rows]}) + + +# ─── Suppliers ───────────────────────────────────────────────────────────── + +@supplier_catalog_bp.route('/suppliers', methods=['GET']) +@require_auth('catalog.view') +def list_suppliers(): + """Return distinct suppliers with counts.""" + conn = _get_master_conn() + cur = conn.cursor() + cur.execute(""" + SELECT supplier_name, COUNT(*) as cnt + FROM supplier_catalog + WHERE is_active = true + GROUP BY supplier_name + ORDER BY supplier_name ASC + """) + rows = cur.fetchall() + cur.close(); conn.close() + return jsonify({'suppliers': [{'name': r[0], 'count': r[1]} for r in rows]}) + + +# ─── Delete ──────────────────────────────────────────────────────────────── + +@supplier_catalog_bp.route('/items/', methods=['DELETE']) +@require_auth('inventory.edit') +def delete_item(item_id): + """Soft-delete a supplier catalog item.""" + conn = _get_master_conn() + cur = conn.cursor() + cur.execute("UPDATE supplier_catalog SET is_active = false WHERE id = %s", (item_id,)) + conn.commit() + cur.close(); conn.close() + return jsonify({'success': True}) diff --git a/pos/blueprints/supplier_portal_bp.py b/pos/blueprints/supplier_portal_bp.py index 33a5125..16034fe 100644 --- a/pos/blueprints/supplier_portal_bp.py +++ b/pos/blueprints/supplier_portal_bp.py @@ -12,6 +12,7 @@ supplier_portal_bp = Blueprint('supplier_portal', __name__, url_prefix='/pos/api from middleware import require_auth +from tenant_db import get_tenant_conn class DecimalEncoder(json.JSONEncoder): @@ -26,48 +27,47 @@ class DecimalEncoder(json.JSONEncoder): def get_demand(): """Aggregated demand by zone, part group, and time range.""" days = request.args.get('days', 30, type=int) - group_id = request.args.get('group_id', type=int) branch_id = request.args.get('branch_id', type=int) - from tenant_db import get_tenant_db - db = get_tenant_db() + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() since = datetime.utcnow() - timedelta(days=days) - params = [since] - filters = "s.created_at >= %s" - if group_id: - filters += " AND p.group_id = %s" - params.append(group_id) - if branch_id: - filters += " AND s.branch_id = %s" - params.append(branch_id) + try: + params = [since] + filters = "s.created_at >= %s" + if branch_id: + filters += " AND s.branch_id = %s" + params.append(branch_id) - rows = db.execute( - f"""SELECT g.name as group_name, b.name as branch_name, - COUNT(DISTINCT s.id_sale) as orders, - SUM(si.quantity) as qty_requested, - COALESCE(SUM(si.total), 0) as revenue - FROM sale_items si - JOIN sales s ON si.sale_id = s.id_sale - JOIN parts p ON si.part_id = p.id_part - JOIN part_groups g ON p.group_id = g.id_group - LEFT JOIN branches b ON s.branch_id = b.id_branch - WHERE {filters} - GROUP BY g.name, b.name - ORDER BY revenue DESC - LIMIT 100""", tuple(params) - ).fetchall() + cur.execute( + f"""SELECT b.name as branch_name, + COUNT(DISTINCT s.id) as orders, + SUM(si.quantity) as qty_requested, + COALESCE(SUM(si.subtotal), 0) as revenue + FROM sale_items si + JOIN sales s ON si.sale_id = s.id + LEFT JOIN branches b ON s.branch_id = b.id + WHERE {filters} + GROUP BY b.name + ORDER BY revenue DESC + LIMIT 100""", tuple(params) + ) + rows = cur.fetchall() - return jsonify({ - 'since': since.isoformat(), - 'days': days, - 'demand': [ - {'group': row['group_name'], 'branch': row['branch_name'], - 'orders': row['orders'], 'quantity': row['qty_requested'], - 'revenue': row['revenue']} - for row in rows - ] - }, cls=DecimalEncoder) + return jsonify({ + 'since': since.isoformat(), + 'days': days, + 'demand': [ + {'branch': row[0] or 'Sin sucursal', + 'orders': row[1], 'quantity': row[2], + 'revenue': float(row[3]) if row[3] is not None else 0} + for row in rows + ] + }) + finally: + cur.close() + conn.close() @supplier_portal_bp.route('/top-parts', methods=['GET']) @@ -75,31 +75,31 @@ def get_demand(): def get_top_parts(): """Top moving parts for suppliers to restock.""" days = request.args.get('days', 30, type=int) - from tenant_db import get_tenant_db - db = get_tenant_db() + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() since = datetime.utcnow() - timedelta(days=days) - rows = db.execute( - """SELECT p.oem_part_number, p.name, g.name as group_name, - SUM(si.quantity) as sold, COALESCE(SUM(si.total), 0) as revenue, - COALESCE(SUM(wi.stock_quantity), 0) as current_stock - FROM sale_items si - JOIN sales s ON si.sale_id = s.id_sale - JOIN parts p ON si.part_id = p.id_part - JOIN part_groups g ON p.group_id = g.id_group - LEFT JOIN warehouse_inventory wi ON p.id_part = wi.part_id - WHERE s.created_at >= %s - GROUP BY p.oem_part_number, p.name, g.name - ORDER BY sold DESC - LIMIT 50""", (since,) - ).fetchall() + try: + cur.execute( + """SELECT si.part_number, si.name, + SUM(si.quantity) as sold, COALESCE(SUM(si.subtotal), 0) as revenue + FROM sale_items si + JOIN sales s ON si.sale_id = s.id + WHERE s.created_at >= %s + GROUP BY si.part_number, si.name + ORDER BY sold DESC + LIMIT 50""", (since,) + ) + rows = cur.fetchall() - return jsonify({ - 'since': since.isoformat(), - 'parts': [ - {'oem': row['oem_part_number'], 'name': row['name'], - 'group': row['group_name'], 'sold': row['sold'], - 'revenue': row['revenue'], 'stock': row['current_stock']} - for row in rows - ] - }, cls=DecimalEncoder) + return jsonify({ + 'since': since.isoformat(), + 'parts': [ + {'part_number': row[0], 'name': row[1], + 'sold': row[2], 'revenue': float(row[3]) if row[3] is not None else 0} + for row in rows + ] + }) + finally: + cur.close() + conn.close() diff --git a/pos/migrations/runner.py b/pos/migrations/runner.py index c2cefaf..8ce56d8 100755 --- a/pos/migrations/runner.py +++ b/pos/migrations/runner.py @@ -33,6 +33,7 @@ MIGRATIONS = { 'v3.0': 'v3.0_public_api.sql', 'v3.1': 'v3.1_inventory_vehicle_compat.sql', 'v3.2': 'v3.2_db_performance.sql', + 'v3.8': 'v3.8_supplier_catalog.sql', } diff --git a/pos/migrations/v1.0_initial.sql b/pos/migrations/v1.0_initial.sql index b6f8719..2cdcf5e 100644 --- a/pos/migrations/v1.0_initial.sql +++ b/pos/migrations/v1.0_initial.sql @@ -386,3 +386,4 @@ CREATE TABLE IF NOT EXISTS tenant_config ( -- Barcode sequence CREATE SEQUENCE IF NOT EXISTS barcode_seq START 1; + diff --git a/pos/migrations/v3.5_meli_questions.sql b/pos/migrations/v3.5_meli_questions.sql new file mode 100644 index 0000000..e1b7683 --- /dev/null +++ b/pos/migrations/v3.5_meli_questions.sql @@ -0,0 +1,30 @@ +-- ============================================================ +-- v3.5 MercadoLibre Questions & Answers +-- ============================================================ +-- Adds table for tracking buyer questions on ML listings. +-- All tables live in the tenant DB. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS marketplace_questions ( + id SERIAL PRIMARY KEY, + listing_id INTEGER REFERENCES marketplace_listings(id) ON DELETE SET NULL, + external_question_id VARCHAR(50) NOT NULL UNIQUE, + external_item_id VARCHAR(50) NOT NULL, + question_text TEXT NOT NULL, + answer_text TEXT, + status VARCHAR(20) DEFAULT 'unanswered', -- unanswered, answered, closed + buyer_id VARCHAR(50), + buyer_nickname VARCHAR(100), + question_date TIMESTAMPTZ, + answer_date TIMESTAMPTZ, + raw_json JSONB, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_marketplace_questions_status + ON marketplace_questions(status); +CREATE INDEX IF NOT EXISTS idx_marketplace_questions_listing + ON marketplace_questions(listing_id); +CREATE INDEX IF NOT EXISTS idx_marketplace_questions_external + ON marketplace_questions(external_question_id); diff --git a/pos/migrations/v3.6_dropshipping.sql b/pos/migrations/v3.6_dropshipping.sql new file mode 100644 index 0000000..87f04d7 --- /dev/null +++ b/pos/migrations/v3.6_dropshipping.sql @@ -0,0 +1,18 @@ +-- ============================================================ +-- v3.6 Dropshipping API Integration +-- ============================================================ +-- Adds config keys and webhook targets for external +-- dropshipping platforms. +-- ============================================================ + +-- Webhook targets for dropshipping notifications per tenant +CREATE TABLE IF NOT EXISTS dropshipping_webhooks ( + id SERIAL PRIMARY KEY, + event_type VARCHAR(30) NOT NULL, -- stock_updated, price_updated, sale_made + target_url TEXT NOT NULL, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_dropshipping_webhooks_event + ON dropshipping_webhooks(event_type) WHERE is_active = true; diff --git a/pos/migrations/v3.7_sku_aliases.sql b/pos/migrations/v3.7_sku_aliases.sql new file mode 100644 index 0000000..6bd355f --- /dev/null +++ b/pos/migrations/v3.7_sku_aliases.sql @@ -0,0 +1,22 @@ +-- ============================================================ +-- v3.7 SKU Aliases (multiple SKUs per inventory item) +-- ============================================================ +-- Allows registering 2-3 alternative part numbers/SKUs for the +-- same product (e.g. different supplier SKUs). +-- ============================================================ + +CREATE TABLE IF NOT EXISTS inventory_sku_aliases ( + id SERIAL PRIMARY KEY, + inventory_id INTEGER NOT NULL REFERENCES inventory(id) ON DELETE CASCADE, + sku VARCHAR(100) NOT NULL, + label VARCHAR(50), -- e.g. "Bodega A", "Proveedor X" + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + CONSTRAINT inventory_sku_aliases_unique_sku + UNIQUE (inventory_id, sku) +); + +CREATE INDEX IF NOT EXISTS idx_inventory_sku_aliases_inventory + ON inventory_sku_aliases(inventory_id) WHERE is_active = true; +CREATE INDEX IF NOT EXISTS idx_inventory_sku_aliases_sku + ON inventory_sku_aliases(sku) WHERE is_active = true; diff --git a/pos/migrations/v3.8_supplier_catalog.sql b/pos/migrations/v3.8_supplier_catalog.sql new file mode 100644 index 0000000..1563873 --- /dev/null +++ b/pos/migrations/v3.8_supplier_catalog.sql @@ -0,0 +1,63 @@ +-- v3.8 — Supplier Catalog tables +-- Adds supplier_catalog, supplier_catalog_compat, and supplier_catalog_interchange +-- to support multi-supplier parts injection into the vehicle catalog. + +CREATE TABLE IF NOT EXISTS supplier_catalog ( + id SERIAL PRIMARY KEY, + tenant_id INTEGER NOT NULL, + supplier_name VARCHAR(255) NOT NULL, + sku VARCHAR(255) NOT NULL, + name VARCHAR(500) NOT NULL, + category VARCHAR(255), + description TEXT, + image_url TEXT, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS supplier_catalog_tenant_id_supplier_name_sku_category_key + ON supplier_catalog (tenant_id, supplier_name, sku, category); + +CREATE INDEX IF NOT EXISTS idx_sc_supplier + ON supplier_catalog (tenant_id, supplier_name, is_active); + +CREATE INDEX IF NOT EXISTS idx_sc_sku + ON supplier_catalog (tenant_id, sku, category); + + +CREATE TABLE IF NOT EXISTS supplier_catalog_compat ( + id SERIAL PRIMARY KEY, + catalog_id INTEGER NOT NULL REFERENCES supplier_catalog(id) ON DELETE CASCADE, + make VARCHAR(255), + model VARCHAR(255), + year INTEGER, + engine VARCHAR(255), + engine_code VARCHAR(255), + model_year_engine_id INTEGER, + source VARCHAR(50) DEFAULT 'import', + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS supplier_catalog_compat_catalog_id_make_model_year_engine_key + ON supplier_catalog_compat (catalog_id, make, model, year, engine); + +CREATE INDEX IF NOT EXISTS idx_scc_catalog + ON supplier_catalog_compat (catalog_id); + +CREATE INDEX IF NOT EXISTS idx_scc_vehicle + ON supplier_catalog_compat (make, model, year); + +CREATE INDEX IF NOT EXISTS idx_scc_mye + ON supplier_catalog_compat (model_year_engine_id); + + +CREATE TABLE IF NOT EXISTS supplier_catalog_interchange ( + id SERIAL PRIMARY KEY, + catalog_id INTEGER NOT NULL REFERENCES supplier_catalog(id) ON DELETE CASCADE, + brand VARCHAR(255), + part_number VARCHAR(255), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_sci_catalog + ON supplier_catalog_interchange (catalog_id); diff --git a/pos/services/catalog_import_service.py b/pos/services/catalog_import_service.py new file mode 100644 index 0000000..a755be8 --- /dev/null +++ b/pos/services/catalog_import_service.py @@ -0,0 +1,157 @@ +"""Bulk catalog import service. + +Imports products into inventory with optional vehicle compatibilities +and SKU aliases. Can auto-generate vehicle fitment via QWEN AI if +compatibilities are not provided. +""" + +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + + +def import_products( + tenant_conn, + products: list[dict], + branch_id: int, + auto_generate_compat: bool = False, + employee_id: Optional[int] = None, +): + """Import a list of products into inventory. + + Each product dict may contain: + - sku (str) *required + - name (str) *required + - brand (str) + - description (str) + - cost (float) + - price (float) + - stock (int) + - location (str) + - sku_aliases (list[dict]) [{"sku": str, "label": str}] + - vehicles (list[dict]) [{"make", "model", "year", "engine", "engine_code"}] + + Returns {"imported": N, "failed": [{"sku": ..., "error": ...}], "compat_generated": M} + """ + cur = tenant_conn.cursor() + imported = 0 + failed = [] + compat_generated = 0 + + for idx, p in enumerate(products): + sku = (p.get("sku") or "").strip() + name = (p.get("name") or "").strip() + if not sku or not name: + failed.append({"index": idx, "sku": sku, "error": "sku and name are required"}) + continue + + brand = (p.get("brand") or "").strip() or None + description = (p.get("description") or "").strip() or None + cost = float(p.get("cost") or 0) + price = float(p.get("price") or 0) + stock = int(p.get("stock") or 0) + location = (p.get("location") or "").strip() or None + barcode = (p.get("barcode") or "").strip() or None + + try: + # Check for duplicate SKU in same branch + cur.execute( + "SELECT id FROM inventory WHERE part_number = %s AND branch_id = %s AND is_active = true", + (sku, branch_id), + ) + if cur.fetchone(): + # Update existing item instead of creating new + cur.execute( + """ + UPDATE inventory + SET name = %s, brand = %s, description = %s, cost = %s, price_1 = %s, + location = %s, barcode = COALESCE(%s, barcode), updated_at = NOW() + WHERE part_number = %s AND branch_id = %s AND is_active = true + RETURNING id + """, + (name, brand, description, cost, price, location, barcode, sku, branch_id), + ) + row = cur.fetchone() + item_id = row[0] + else: + # Insert new item + cur.execute( + """ + INSERT INTO inventory + (branch_id, part_number, barcode, name, description, brand, + unit, cost, price_1, price_2, price_3, tax_rate, + min_stock, max_stock, location, is_active) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, true) + RETURNING id + """, + ( + branch_id, sku, barcode, name, description, brand, + "PZA", cost, price, price, price, 0.16, + 0, 0, location, + ), + ) + row = cur.fetchone() + item_id = row[0] + + # Record initial stock if provided + if stock > 0: + from services.inventory_engine import record_initial + record_initial(tenant_conn, item_id, branch_id, stock, cost) + + # Insert SKU aliases + aliases = p.get("sku_aliases") or [] + for alias in aliases: + alias_sku = (alias.get("sku") or "").strip() + label = (alias.get("label") or "").strip() or None + if alias_sku: + cur.execute( + """ + INSERT INTO inventory_sku_aliases (inventory_id, sku, label) + VALUES (%s, %s, %s) + ON CONFLICT (inventory_id, sku) DO UPDATE SET + is_active = true, label = EXCLUDED.label + """, + (item_id, alias_sku, label), + ) + + # Insert manual vehicle compatibilities + vehicles = p.get("vehicles") or [] + for v in vehicles: + make = (v.get("make") or "").strip() + model = (v.get("model") or "").strip() + year = v.get("year") + engine = (v.get("engine") or "").strip() or None + engine_code = (v.get("engine_code") or "").strip() or None + if make and model and year: + cur.execute( + """ + INSERT INTO inventory_vehicle_compat + (inventory_id, make, model, year, engine, engine_code, source, model_year_engine_id) + VALUES (%s, %s, %s, %s, %s, %s, 'manual', NULL) + ON CONFLICT DO NOTHING + """, + (item_id, make, model, year, engine, engine_code), + ) + + tenant_conn.commit() + imported += 1 + + # Auto-generate compat via QWEN if requested and no vehicles provided + if auto_generate_compat and not vehicles: + try: + from services.qwen_fitment import get_vehicle_fitment + from services.inventory_vehicle_compat import save_qwen_fitment + fitment = get_vehicle_fitment(sku, name, brand or "") + inserted = save_qwen_fitment(tenant_conn, item_id, fitment) + compat_generated += inserted + except Exception as qe: + logger.warning("QWEN auto-match failed for %s: %s", sku, qe) + + except Exception as e: + tenant_conn.rollback() + logger.warning("Import failed for sku=%s: %s", sku, e) + failed.append({"index": idx, "sku": sku, "error": str(e)}) + + cur.close() + return {"imported": imported, "failed": failed, "compat_generated": compat_generated} diff --git a/pos/services/catalog_service.py b/pos/services/catalog_service.py index c3b54dd..b16574c 100644 --- a/pos/services/catalog_service.py +++ b/pos/services/catalog_service.py @@ -97,6 +97,11 @@ def _clean_model_name(name): s = re.sub(r'\s+(?:VIII|VII|VI|IV|IX|III|II|V|X|I)(?:\s|$)', ' ', s) # Remove body type suffixes s = re.sub(r'\s+(?:Estate|Saloon|Hatchback|Van|Coupe|Coupé|Convertible|Wagon|Pickup|Cab|Sedan|SUV|MPV|Kombi|Kasten|Bus|Box|Platform|Chassis)\b', '', s, flags=re.IGNORECASE) + # Remove truck cab/bed suffixes: CREW, EXTENDED, STANDARD, HD, etc. + s = re.sub(r'\s+(?:CREW|EXTENDED|STANDARD|CUTAWAY|PASSENGER|CARGO)\b', '', s, flags=re.IGNORECASE) + # Remove "HD", "&" suffixes that create fake variants + s = re.sub(r'\s+HD\b', '', s, flags=re.IGNORECASE) + s = re.sub(r'\s+&\s*$', '', s) # Remove "Hatchback Van", "Box Body" compound types s = re.sub(r'\s+(?:Hatchback|Box)\s+(?:Van|Body)\b', '', s, flags=re.IGNORECASE) # Clean up extra spaces @@ -115,7 +120,67 @@ from services.catalog_modes import get_brands_for_mode NORTH_AMERICA_BRANDS = get_brands_for_mode('oem') -def get_brands(master_conn, year_id=None, mode='oem'): +def _get_mye_ids_with_parts(tenant_conn, min_parts=1, tenant_id=None, master_conn=None): + """Return MYE ids that have at least min_parts total (local inventory + supplier catalog). + + Results are cached in Redis per-tenant for 60s to avoid repeated + expensive UNION ALL + GROUP BY queries during navigation. + """ + if tenant_id: + r = _get_redis() + if r: + cache_key = f'nexus:mye_ids:{tenant_id}:{min_parts}' + try: + cached = r.get(cache_key) + if cached: + return json.loads(cached) + except Exception: + pass + + # Inventory from tenant DB + cur = tenant_conn.cursor() + cur.execute(""" + SELECT model_year_engine_id, COUNT(*) as cnt + FROM inventory_vehicle_compat + WHERE model_year_engine_id IS NOT NULL + GROUP BY model_year_engine_id + """) + inventory_counts = {r[0]: r[1] for r in cur.fetchall()} + cur.close() + + # Supplier catalog from master DB + supplier_counts = {} + if master_conn: + cur = master_conn.cursor() + cur.execute(""" + SELECT model_year_engine_id, COUNT(*) as cnt + FROM supplier_catalog_compat + WHERE model_year_engine_id IS NOT NULL + GROUP BY model_year_engine_id + """) + supplier_counts = {r[0]: r[1] for r in cur.fetchall()} + cur.close() + + # Combine and filter + all_myes = set(inventory_counts.keys()) | set(supplier_counts.keys()) + result = [] + for mye_id in all_myes: + total = inventory_counts.get(mye_id, 0) + supplier_counts.get(mye_id, 0) + if total >= min_parts: + result.append(mye_id) + + if tenant_id: + r = _get_redis() + if r: + try: + r.setex(cache_key, 60, json.dumps(result)) + except Exception: + pass + + return result + + +def get_brands(master_conn, year_id=None, mode='oem', mye_ids=None): """Get vehicle brands that have MYE entries, filtered by catalog mode. Args: @@ -125,30 +190,50 @@ def get_brands(master_conn, year_id=None, mode='oem'): """ allowed = list(get_brands_for_mode(mode)) cur = master_conn.cursor() - if year_id: - cur.execute(""" - SELECT DISTINCT b.id_brand, b.name_brand - FROM brands b - JOIN models m ON m.brand_id = b.id_brand - JOIN model_year_engine mye ON mye.model_id = m.id_model - WHERE b.name_brand = ANY(%s) AND mye.year_id = %s - ORDER BY b.name_brand - """, (allowed, year_id)) + if mye_ids: + if year_id: + cur.execute(""" + SELECT DISTINCT b.id_brand, b.name_brand + FROM brands b + JOIN models m ON m.brand_id = b.id_brand + JOIN model_year_engine mye ON mye.model_id = m.id_model + WHERE b.name_brand = ANY(%s) AND mye.year_id = %s AND mye.id_mye = ANY(%s) + ORDER BY b.name_brand + """, (allowed, year_id, mye_ids)) + else: + cur.execute(""" + SELECT DISTINCT b.id_brand, b.name_brand + FROM brands b + JOIN models m ON m.brand_id = b.id_brand + JOIN model_year_engine mye ON mye.model_id = m.id_model + WHERE b.name_brand = ANY(%s) AND mye.id_mye = ANY(%s) + ORDER BY b.name_brand + """, (allowed, mye_ids)) else: - cur.execute(""" - SELECT DISTINCT b.id_brand, b.name_brand - FROM brands b - JOIN models m ON m.brand_id = b.id_brand - JOIN model_year_engine mye ON mye.model_id = m.id_model - WHERE b.name_brand = ANY(%s) - ORDER BY b.name_brand - """, (allowed,)) + if year_id: + cur.execute(""" + SELECT DISTINCT b.id_brand, b.name_brand + FROM brands b + JOIN models m ON m.brand_id = b.id_brand + JOIN model_year_engine mye ON mye.model_id = m.id_model + WHERE b.name_brand = ANY(%s) AND mye.year_id = %s + ORDER BY b.name_brand + """, (allowed, year_id)) + else: + cur.execute(""" + SELECT DISTINCT b.id_brand, b.name_brand + FROM brands b + JOIN models m ON m.brand_id = b.id_brand + JOIN model_year_engine mye ON mye.model_id = m.id_model + WHERE b.name_brand = ANY(%s) + ORDER BY b.name_brand + """, (allowed,)) rows = cur.fetchall() cur.close() return [{'id_brand': r[0], 'name_brand': r[1]} for r in rows] -def get_models(master_conn, brand_id, year_id=None, brand_name=None): +def get_models(master_conn, brand_id, year_id=None, brand_name=None, mye_ids=None): """Get models for a brand that have MYE entries, filtered to North America. If year_id is provided, only models available for that year. brand_name is used for NA filtering; looked up from DB if not provided.""" @@ -160,22 +245,40 @@ def get_models(master_conn, brand_id, year_id=None, brand_name=None): row = cur.fetchone() brand_name = row[0] if row else '' - if year_id: - cur.execute(""" - SELECT DISTINCT m.id_model, m.name_model - FROM models m - JOIN model_year_engine mye ON mye.model_id = m.id_model - WHERE m.brand_id = %s AND mye.year_id = %s - ORDER BY m.name_model - """, (brand_id, year_id)) + if mye_ids: + if year_id: + cur.execute(""" + SELECT DISTINCT m.id_model, m.name_model + FROM models m + JOIN model_year_engine mye ON mye.model_id = m.id_model + WHERE m.brand_id = %s AND mye.year_id = %s AND mye.id_mye = ANY(%s) + ORDER BY m.name_model + """, (brand_id, year_id, mye_ids)) + else: + cur.execute(""" + SELECT DISTINCT m.id_model, m.name_model + FROM models m + JOIN model_year_engine mye ON mye.model_id = m.id_model + WHERE m.brand_id = %s AND mye.id_mye = ANY(%s) + ORDER BY m.name_model + """, (brand_id, mye_ids)) else: - cur.execute(""" - SELECT DISTINCT m.id_model, m.name_model - FROM models m - JOIN model_year_engine mye ON mye.model_id = m.id_model - WHERE m.brand_id = %s - ORDER BY m.name_model - """, (brand_id,)) + if year_id: + cur.execute(""" + SELECT DISTINCT m.id_model, m.name_model + FROM models m + JOIN model_year_engine mye ON mye.model_id = m.id_model + WHERE m.brand_id = %s AND mye.year_id = %s + ORDER BY m.name_model + """, (brand_id, year_id)) + else: + cur.execute(""" + SELECT DISTINCT m.id_model, m.name_model + FROM models m + JOIN model_year_engine mye ON mye.model_id = m.id_model + WHERE m.brand_id = %s + ORDER BY m.name_model + """, (brand_id,)) rows = cur.fetchall() cur.close() @@ -200,31 +303,45 @@ def get_models(master_conn, brand_id, year_id=None, brand_name=None): return results -def get_years(master_conn, model_id): +def get_years(master_conn, model_id, mye_ids=None): """Get distinct years for a model via MYE (fast, no vehicle_parts scan). Ordered DESC.""" cur = master_conn.cursor() - cur.execute(""" - SELECT DISTINCT y.id_year, y.year_car - FROM years y - JOIN model_year_engine mye ON mye.year_id = y.id_year - WHERE mye.model_id = %s - ORDER BY y.year_car DESC - """, (model_id,)) + if mye_ids: + cur.execute(""" + SELECT DISTINCT y.id_year, y.year_car + FROM years y + JOIN model_year_engine mye ON mye.year_id = y.id_year + WHERE mye.model_id = %s AND mye.id_mye = ANY(%s) + ORDER BY y.year_car DESC + """, (model_id, mye_ids)) + else: + cur.execute(""" + SELECT DISTINCT y.id_year, y.year_car + FROM years y + JOIN model_year_engine mye ON mye.year_id = y.id_year + WHERE mye.model_id = %s + ORDER BY y.year_car DESC + """, (model_id,)) rows = cur.fetchall() cur.close() return [{'id_year': r[0], 'year_car': r[1]} for r in rows] -def get_engines(master_conn, model_id, year_id): +def get_engines(master_conn, model_id, year_id, mye_ids=None): """Get MYE entries (engine + trim) for a model+year combo.""" cur = master_conn.cursor() - cur.execute(""" + mye_filter = "" + params = [model_id, year_id] + if mye_ids: + mye_filter = " AND mye.id_mye = ANY(%s)" + params.append(mye_ids) + cur.execute(f""" SELECT mye.id_mye, e.name_engine, mye.trim_level FROM model_year_engine mye JOIN engines e ON e.id_engine = mye.engine_id - WHERE mye.model_id = %s AND mye.year_id = %s + WHERE mye.model_id = %s AND mye.year_id = %s{mye_filter} ORDER BY e.name_engine, mye.trim_level - """, (model_id, year_id)) + """, tuple(params)) rows = cur.fetchall() cur.close() return [{'id_mye': r[0], 'name_engine': r[1], 'trim_level': r[2] or ''} for r in rows] @@ -307,15 +424,23 @@ def _classify_vehicle_parts(master_conn, mye_id): from services.nexpart_taxonomy import tecdoc_to_nexpart - cur = master_conn.cursor() - cur.execute(""" - SELECT p.id_part, p.name_part - FROM vehicle_parts vp - JOIN parts p ON p.id_part = vp.part_id - WHERE vp.model_year_engine_id = %s - """, (mye_id,)) - rows = cur.fetchall() - cur.close() + rows = [] + try: + cur = master_conn.cursor() + cur.execute(""" + SELECT p.id_part, p.name_part + FROM vehicle_parts vp + JOIN parts p ON p.id_part = vp.part_id + WHERE vp.model_year_engine_id = %s + """, (mye_id,)) + rows = cur.fetchall() + cur.close() + except Exception: + # vehicle_parts or parts table may not exist (TecDoc removed) + try: + master_conn.rollback() + except Exception: + pass classified = {} for part_id, name_part in rows: @@ -332,7 +457,247 @@ def _classify_vehicle_parts(master_conn, mye_id): return classified -def get_nexpart_groups_for_vehicle(master_conn, mye_id): +def _classify_inventory_parts(tenant_conn, mye_id): + """Classify local inventory parts for a vehicle into Nexpart triples. + + Uses inventory_vehicle_compat to find local items linked to the MYE, + then classifies each item's name via tecdoc_to_nexpart. + + Returns the same nested dict shape as _classify_vehicle_parts but + uses inventory id values as the leaf list items. + """ + from services.nexpart_taxonomy import tecdoc_to_nexpart + + cur = tenant_conn.cursor() + cur.execute(""" + SELECT i.id, i.name + FROM inventory i + JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id + WHERE ivc.model_year_engine_id = %s + AND i.is_active = true + """, (mye_id,)) + rows = cur.fetchall() + cur.close() + + classified = {} + for inv_id, name in rows: + triple = tecdoc_to_nexpart(name) + if not triple: + continue + group, subgroup, part_type = triple + classified.setdefault(group, {}) \ + .setdefault(subgroup, {}) \ + .setdefault(part_type, []) \ + .append(f"inv:{inv_id}") + return classified + + +# Keyword-based Spanish → Nexpart mapping for supplier catalog items. +# Each entry is ((group, subgroup, part_type), [spanish_keywords...]) +# Keywords are checked in order; first match wins. +_SPANISH_KEYWORDS = [ + # ── Steering & Suspension ── + (("Steering & Suspension Parts", "Shock Absorbers & Struts", "Suspension Shock Absorber Mount"), + ["base amortiguador", "base de amortiguador"]), + (("Steering & Suspension Parts", "Shock Absorbers & Struts", "Suspension Shock Absorber"), + ["amortiguador"]), + (("Steering & Suspension Parts", "Shock Absorbers & Struts", "Suspension Strut"), + ["strut", "torre"]), + (("Steering & Suspension Parts", "Ball Joints & Control Arms", "Suspension Control Arm and Ball Joint Assembly"), + ["horquilla", "mango de suspension", "mangueta"]), + (("Steering & Suspension Parts", "Sway Bars, Stabilizer Bars, Strut Rods & Parts", "Suspension Stabilizer Bar Link"), + ["bieleta", "estabilizador"]), + (("Steering & Suspension Parts", "Steering Linkages, Rods & Arms", "Steering Tie Rod End"), + ["terminal", "rotula direccion", "rotula de direccion"]), + (("Steering & Suspension Parts", "Ball Joints & Control Arms", "Suspension Ball Joint"), + ["rotula"]), + (("Steering & Suspension Parts", "Ball Joints & Control Arms", "Suspension Control Arm Bushing"), + ["buje", "bujes"]), + (("Steering & Suspension Parts", "Rack & Pinion, Gear Box, Power Cylinder", "Rack and Pinion Assembly"), + ["caja de direccion", "cremallera"]), + + # ── Brake System ── + (("Brake System, Wheel Bearings, Studs, Nuts & Hardware", "Front Wheel Bearings & Seals", "Front Wheel Bearing"), + ["balero", "rodamiento", "maza", "cubo"]), + (("Brake System, Wheel Bearings, Studs, Nuts & Hardware", "Front Friction, Drums & Rotors", "Front Disc Brake Pad Set"), + ["balata", "pastilla de freno"]), + (("Brake System, Wheel Bearings, Studs, Nuts & Hardware", "Front Friction, Drums & Rotors", "Front Disc Brake Rotor"), + ["disco de freno", "freno de disco", "rotor"]), + (("Brake System, Wheel Bearings, Studs, Nuts & Hardware", "Front Calipers, Wheel Cylinders, Hoses", "Front Disc Brake Caliper"), + ["caliper", "calipers"]), + (("Brake System, Wheel Bearings, Studs, Nuts & Hardware", "Master Cylinders, Boosters & Switches", "Brake Master Cylinder"), + ["cilindro maestro"]), + + # ── Ignition & Filters ── + (("Ignition & Filters", "Filters & PCV", "Engine Oil Filter"), + ["filtro de aceite", "filtro aceite"]), + (("Ignition & Filters", "Filters & PCV", "Engine Air Filter"), + ["filtro de aire", "filtro aire"]), + (("Ignition & Filters", "Filters & PCV", "Cabin Air Filter"), + ["filtro de cabina", "filtro cabina"]), + (("Ignition & Filters", "Filters & PCV", "Fuel Filter"), + ["filtro de gasolina", "filtro gasolina"]), + + # ── Cooling System ── + (("Belts, Hoses, Water Pumps & Cooling System Parts", "Radiators & Electric Fan Motors", "Engine Coolant Reservoir"), + ["radiador"]), + (("Belts, Hoses, Water Pumps & Cooling System Parts", "Water Pumps, Fan Blades & Clutches", "Engine Water Pump"), + ["bomba de agua"]), + (("Belts, Hoses, Water Pumps & Cooling System Parts", "Thermostats, Housings & Radiator Caps", "Engine Coolant Thermostat"), + ["termostato"]), + (("Belts, Hoses, Water Pumps & Cooling System Parts", "Belts, Tensioners & Pulleys", "Accessory Drive Belt Tensioner Assembly"), + ["tensor", "polea", "bandas"]), + + # ── Engine ── + (("Engine Parts", "Engine Parts", "Engine Oil Pump"), + ["bomba de aceite"]), + + # ── Drivetrain ── + (("Drivetrain Parts", "Driveshafts, U-Joints & CV (Constant Velocity) Parts", "CV Axle Assembly"), + ["cople flecha", "junta homocinetica", "juntas homocineticas"]), + (("Drivetrain Parts", "Axle & Differential Parts", "Manual Transmission Differential Bearing"), + ["collarin", "collar"]), + + # ── Clutch ── + (("Exhaust, Clutch & Flywheel Parts", "Clutches & Clutch Kits", "Transmission Clutch Kit"), + ["clutch", "kit de clutch"]), + + # ── Engine Mounts ── + (("Engine Parts", "Engine Mounts & Other Miscellaneous Engine Parts", "Engine Mount"), + ["soporte motor", "soporte de motor"]), + (("Engine Parts", "Engine Mounts & Other Miscellaneous Engine Parts", "Automatic Transmission Mount"), + ["soporte transmision", "soporte de transmision"]), + + # ── Dust Boots ── + (("Steering & Suspension Parts", "Shock Absorbers & Struts", "Suspension Strut Bellows"), + ["cubre polvo"]), + + # ── Suspension Arms / Links ── + (("Steering & Suspension Parts", "Ball Joints & Control Arms", "Suspension Control Arm and Ball Joint Assembly"), + ["tirante"]), + (("Steering & Suspension Parts", "Sway Bars, Stabilizer Bars, Strut Rods & Parts", "Suspension Stabilizer Bar Link"), + ["tornillo estabilizador"]), +] + +# Fallback: supplier category → Nexpart group +_SUPPLIER_CATEGORY_TO_GROUP = { + "SUSPENSION": "Steering & Suspension Parts", + "DIRECCION": "Steering & Suspension Parts", + "RODAMIENTOS": "Brake System, Wheel Bearings, Studs, Nuts & Hardware", + "CAT05_Filtros_Juntas_Bomb": "Ignition & Filters", + "CAT06_Radiadores": "Belts, Hoses, Water Pumps & Cooling System Parts", + "CAT07_Balatas": "Brake System, Wheel Bearings, Studs, Nuts & Hardware", + "CAT08_Collarines": "Drivetrain Parts", + # Raybestos + "FRENO_DE_DISCO": "Brake System, Wheel Bearings, Studs, Nuts & Hardware", + # LUK + "KIT_CLUTCH": "Exhaust, Clutch & Flywheel Parts", + # VAZLO + "AMORTIGUADOR": "Steering & Suspension Parts", + "BASE_AMORTIGUADOR": "Steering & Suspension Parts", + "BUJES": "Steering & Suspension Parts", + "COPLE_FLECHA": "Drivetrain Parts", + "CUBRE_POLVO": "Steering & Suspension Parts", + "GOMAS_BARRA_ESTABILIZADORA": "Steering & Suspension Parts", + "HORQUILLAS": "Steering & Suspension Parts", + "JUNTAS_HOMOCINETICAS": "Drivetrain Parts", + "SOPORTE_MOTOR": "Engine Parts", + "SOPORTE_TRANSMISION": "Engine Parts", + "TERMINAL_DIRECCION": "Steering & Suspension Parts", + "TIRANTE": "Steering & Suspension Parts", + "TORNILLO_ESTABILIZADOR": "Steering & Suspension Parts", +} + + +def _spanish_name_to_nexpart(name, category=None): + """Map a Spanish part name to a Nexpart (group, subgroup, part_type). + + Uses keyword matching against known Spanish auto-part terms. + Returns None if no match and no category fallback available. + """ + if not name: + return None + name_lower = name.lower().replace('_', ' ') + + # 1. Keyword match (most specific first) + for triple, keywords in _SPANISH_KEYWORDS: + for kw in keywords: + if kw in name_lower: + return triple + + # 2. Category fallback → group + first available subgroup/part_type + if category: + group = _SUPPLIER_CATEGORY_TO_GROUP.get(category.upper()) + if group: + from services.nexpart_taxonomy import NEXPART_TAXONOMY + subgroups = NEXPART_TAXONOMY.get(group, {}) + if subgroups: + # Pick the first subgroup and its first part_type + subgroup = next(iter(subgroups.keys())) + part_types = subgroups[subgroup] + if part_types: + return (group, subgroup, part_types[0]) + return (group, subgroup, subgroup) + return (group, group, group) + + return None + + +def _classify_supplier_catalog_parts(master_conn, mye_id): + """Classify supplier catalog parts for a vehicle into Nexpart triples. + + Uses supplier_catalog_compat (in master DB) to find supplier items linked + to the MYE, then classifies each item's name via _spanish_name_to_nexpart. + + Returns the same nested dict shape but uses supplier_catalog id values + prefixed with 'sc:' as the leaf list items. + """ + rows = [] + try: + cur = master_conn.cursor() + cur.execute(""" + SELECT sc.id, sc.name, sc.category + FROM supplier_catalog sc + JOIN supplier_catalog_compat scc ON scc.catalog_id = sc.id + WHERE scc.model_year_engine_id = %s AND sc.is_active = true + """, (mye_id,)) + rows = cur.fetchall() + cur.close() + except Exception: + # supplier_catalog table may not exist + try: + master_conn.rollback() + except Exception: + pass + + classified = {} + for sc_id, name, category in rows: + triple = _spanish_name_to_nexpart(name, category) + if not triple: + continue + group, subgroup, part_type = triple + classified.setdefault(group, {}) \ + .setdefault(subgroup, {}) \ + .setdefault(part_type, []) \ + .append(f"sc:{sc_id}") + return classified + + +def _merge_classified(base, extra): + """Merge two classified dicts (group -> subgroup -> part_type -> ids). + Modifies base in place and returns it.""" + if not extra: + return base + for group, subgroups in extra.items(): + sg_base = base.setdefault(group, {}) + for subgroup, part_types in subgroups.items(): + pt_base = sg_base.setdefault(subgroup, {}) + for part_type, ids in part_types.items(): + pt_base.setdefault(part_type, []).extend(ids) + return base + + +def get_nexpart_groups_for_vehicle(master_conn, mye_id, tenant_conn=None): """Local mode: return Nexpart top-level groups that have parts for this vehicle. Output shape mirrors get_categories() but uses `slug` (string) instead of @@ -345,6 +710,10 @@ def get_nexpart_groups_for_vehicle(master_conn, mye_id): ) classified = _classify_vehicle_parts(master_conn, mye_id) + # Merge local inventory and supplier catalog classifications + if tenant_conn: + _merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id)) + _merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id)) result = [] # Iterate in canonical Nexpart order so the UI is stable @@ -366,7 +735,7 @@ def get_nexpart_groups_for_vehicle(master_conn, mye_id): return result -def get_nexpart_subgroups_for_vehicle(master_conn, mye_id, group_slug): +def get_nexpart_subgroups_for_vehicle(master_conn, mye_id, group_slug, tenant_conn=None): """Local mode: return Nexpart subgroups within a group that have vehicle parts.""" from services.nexpart_taxonomy import ( NEXPART_TAXONOMY, @@ -374,6 +743,9 @@ def get_nexpart_subgroups_for_vehicle(master_conn, mye_id, group_slug): ) classified = _classify_vehicle_parts(master_conn, mye_id) + if tenant_conn: + _merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id)) + _merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id)) group_data = classified.get(group_slug, {}) if not group_data: return [] @@ -773,28 +1145,72 @@ def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug, Returns the same shape as get_parts_local(). """ classified = _classify_vehicle_parts(master_conn, mye_id) - part_ids = ( + # Also merge inventory and supplier catalog so the leaf IDs may be a mix + if tenant_conn: + _merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id)) + _merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id)) + + all_ids = ( classified .get(group_slug, {}) .get(subgroup_slug, {}) .get(part_type_slug, []) ) - 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, - ) + # Separate OEM (TecDoc) IDs, inventory prefixed IDs, and supplier-catalog prefixed IDs + oem_ids = [pid for pid in all_ids if isinstance(pid, int)] + inv_ids = [pid for pid in all_ids if isinstance(pid, str) and pid.startswith('inv:')] + sc_ids = [pid for pid in all_ids if isinstance(pid, str) and pid.startswith('sc:')] + + # If no OEM IDs, skip get_parts_local (TecDoc tables may not exist) + if oem_ids: + try: + result = get_parts_local( + master_conn, mye_id=None, group_id=None, + tenant_conn=tenant_conn, branch_id=branch_id, + page=1, per_page=999999, + oem_part_ids=oem_ids, + ) + except Exception: + # TecDoc tables (parts, aftermarket_parts, vehicle_parts) may not exist + result = { + 'data': [], + 'pagination': { + 'page': 1, + 'per_page': 999999, + 'total': 0, + 'total_pages': 0, + }, + 'mode': 'local', + } + else: + result = { + 'data': [], + 'pagination': { + 'page': 1, + 'per_page': 999999, + 'total': 0, + 'total_pages': 0, + }, + 'mode': 'local', + } + # Inject local inventory items linked to this vehicle - # (get_parts_local with oem_part_ids skips mye_id, so we call it separately) - local_injected = 0 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) + # If specific inventory IDs were classified for this triple, filter to those only + if inv_ids: + allowed_inv_ids = {int(pid.replace('inv:', '')) for pid in inv_ids} + else: + allowed_inv_ids = None 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 not _local_name_matches_part_type(name, part_type_slug): + if allowed_inv_ids is not None and inv_id not in allowed_inv_ids: + continue + # Only apply name-based filtering when we don't have explicit classified IDs. + # If _classify_inventory_parts already matched these IDs to the triple, + # trust that classification and skip the expensive name check. + if allowed_inv_ids is None and part_type_slug and not _local_name_matches_part_type(name, part_type_slug): continue result['data'].append({ 'id_part': f'inv:{inv_id}', @@ -815,17 +1231,56 @@ def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug, 'price_usd': None, 'source': 'local_inventory', }) - local_injected += 1 - # Update pagination total to include injected local items - if local_injected: - result['pagination']['total'] = result['pagination'].get('total', 0) + local_injected - result['pagination']['total_pages'] = ( - (result['pagination']['total'] + per_page - 1) // per_page - ) + + # Inject supplier catalog items linked to this vehicle (from master DB) + if master_conn and sc_ids: + cur = master_conn.cursor() + sc_id_values = [int(pid.replace('sc:', '')) for pid in sc_ids] + cur.execute(""" + SELECT id, supplier_name, sku, name, category, description, image_url + FROM supplier_catalog + WHERE id = ANY(%s) + ORDER BY name + """, (sc_id_values,)) + for row in cur.fetchall(): + sc_id, supplier, sku, name, category, desc, img = row + result['data'].append({ + 'id_part': f'sc:{sc_id}', + 'id_aftermarket': None, + 'oem_part_number': sku, + 'part_number': sku, + 'name': name.replace('\\n', ' ') if name else '', + 'description': desc or category, + 'image_url': img, + 'manufacturer': supplier, + 'priority_tier': 3, + 'local_stock': None, + 'local_price': None, + 'bodega_count': 0, + 'warehouse_stock': 0, + 'warehouse_price': None, + 'in_stock_network': False, + 'price_usd': None, + 'source': 'supplier_catalog', + }) + cur.close() + + # Sort combined list and paginate in Python + all_items = result['data'] + all_items.sort(key=lambda x: ( + x.get('priority_tier', 3), + 0 if (x.get('in_stock_network') or (x.get('local_stock') or 0) > 0) else 1, + (x.get('manufacturer') or '').lower(), + (x.get('name') or '').lower() + )) + total = len(all_items) + offset = (page - 1) * per_page + result['data'] = all_items[offset:offset + per_page] + result['pagination'] = _pagination(page, per_page, total) return result -def get_nexpart_part_types_for_vehicle(master_conn, mye_id, group_slug, subgroup_slug): +def get_nexpart_part_types_for_vehicle(master_conn, mye_id, group_slug, subgroup_slug, tenant_conn=None): """Local mode: return Nexpart part types within a subgroup that have vehicle parts. Output shape matches get_part_types() so the frontend can render with @@ -837,6 +1292,9 @@ def get_nexpart_part_types_for_vehicle(master_conn, mye_id, group_slug, subgroup ) classified = _classify_vehicle_parts(master_conn, mye_id) + if tenant_conn: + _merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id)) + _merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id)) subgroup_data = classified.get(group_slug, {}).get(subgroup_slug, {}) if not subgroup_data: return [] @@ -849,15 +1307,19 @@ def get_nexpart_part_types_for_vehicle(master_conn, mye_id, group_slug, subgroup ] image_map = {} if all_part_ids: - cur = master_conn.cursor() - cur.execute(""" - SELECT id_part, image_url - FROM parts - WHERE id_part = ANY(%s) AND image_url IS NOT NULL - """, (all_part_ids,)) - for pid, url in cur.fetchall(): - image_map[pid] = url - cur.close() + try: + cur = master_conn.cursor() + cur.execute(""" + SELECT id_part, image_url + FROM parts + WHERE id_part = ANY(%s) AND image_url IS NOT NULL + """, (all_part_ids,)) + for pid, url in cur.fetchall(): + image_map[pid] = url + cur.close() + except Exception: + # parts table may not exist (TecDoc removed) + pass canonical_order = NEXPART_TAXONOMY.get(group_slug, {}).get(subgroup_slug, []) @@ -1093,125 +1555,121 @@ def get_parts_local(master_conn, mye_id, group_id, tenant_conn, branch_id, where_clause += " AND p.name_part = %s" where_params_count = (mye_id, group_id, part_type) - # Count total aftermarket parts - cur.execute( - "SELECT COUNT(*) " + from_join_count + " WHERE " + where_clause, - where_params_count, - ) - total = cur.fetchone()[0] + # Priority-sorted fetch — same WHERE clause, plus tiers. No SQL paging here; + # we combine with local inventory and supplier catalog, then paginate in Python. + fetch_params = list(where_params_count) + [tier1, tier2] - # Priority-sorted fetch — same WHERE clause as the COUNT, plus tiers + paging. - fetch_params = list(where_params_count) + [tier1, tier2, per_page, offset] + rows = [] + try: + cur.execute(""" + WITH aftermarket_for_vehicle AS ( + SELECT DISTINCT + ap.id_aftermarket_parts, + ap.oem_part_id, + ap.part_number, + COALESCE(ap.name_es, ap.name_aftermarket_parts) AS am_name, + ap.price_usd, + m.name_manufacture, + p.oem_part_number, + COALESCE(p.name_es, p.name_part) AS oem_name, + COALESCE(p.description_es, p.description) AS oem_desc, + p.image_url AS oem_image + """ + from_join_count + """ + WHERE """ + where_clause + """ + ), + stock_per_oem AS ( + SELECT part_id, COUNT(*) AS bodega_count, MIN(price) AS min_price, SUM(stock_quantity) AS total_stock + FROM warehouse_inventory + WHERE stock_quantity > 0 + GROUP BY part_id + ) + SELECT afv.id_aftermarket_parts, + afv.oem_part_id, + afv.part_number, + afv.am_name, + afv.price_usd, + afv.name_manufacture, + afv.oem_part_number, + afv.oem_name, + afv.oem_desc, + afv.oem_image, + COALESCE(s.bodega_count, 0) AS bodega_count, + s.min_price AS warehouse_price, + COALESCE(s.total_stock, 0) AS warehouse_stock, + CASE + WHEN UPPER(afv.name_manufacture) = ANY(%s) THEN 1 + WHEN UPPER(afv.name_manufacture) = ANY(%s) THEN 2 + ELSE 3 + END AS tier + FROM aftermarket_for_vehicle afv + LEFT JOIN stock_per_oem s ON s.part_id = afv.oem_part_id + ORDER BY tier ASC, + (COALESCE(s.bodega_count, 0) > 0) DESC, + afv.name_manufacture ASC, + afv.am_name ASC + """, fetch_params) - cur.execute(""" - WITH aftermarket_for_vehicle AS ( - SELECT DISTINCT - ap.id_aftermarket_parts, - ap.oem_part_id, - ap.part_number, - COALESCE(ap.name_es, ap.name_aftermarket_parts) AS am_name, - ap.price_usd, - m.name_manufacture, - p.oem_part_number, - COALESCE(p.name_es, p.name_part) AS oem_name, - COALESCE(p.description_es, p.description) AS oem_desc, - p.image_url AS oem_image - """ + from_join_count + """ - WHERE """ + where_clause + """ - ), - stock_per_oem AS ( - SELECT part_id, COUNT(*) AS bodega_count, MIN(price) AS min_price, SUM(stock_quantity) AS total_stock - FROM warehouse_inventory - WHERE stock_quantity > 0 - GROUP BY part_id - ) - SELECT afv.id_aftermarket_parts, - afv.oem_part_id, - afv.part_number, - afv.am_name, - afv.price_usd, - afv.name_manufacture, - afv.oem_part_number, - afv.oem_name, - afv.oem_desc, - afv.oem_image, - COALESCE(s.bodega_count, 0) AS bodega_count, - s.min_price AS warehouse_price, - COALESCE(s.total_stock, 0) AS warehouse_stock, - CASE - WHEN UPPER(afv.name_manufacture) = ANY(%s) THEN 1 - WHEN UPPER(afv.name_manufacture) = ANY(%s) THEN 2 - ELSE 3 - END AS tier - FROM aftermarket_for_vehicle afv - LEFT JOIN stock_per_oem s ON s.part_id = afv.oem_part_id - ORDER BY tier ASC, - (COALESCE(s.bodega_count, 0) > 0) DESC, - afv.name_manufacture ASC, - afv.am_name ASC - LIMIT %s OFFSET %s - """, fetch_params) - - rows = cur.fetchall() - cur.close() - - if not rows: - return {'data': [], 'pagination': _pagination(page, per_page, total), 'mode': 'local'} - - # Enrich with tenant local stock (look up by OEM part number). - # Use a different name to avoid shadowing the `oem_part_ids` parameter. - oem_numbers = list({r[6] for r in rows if r[6]}) - result_oem_ids = list({r[1] for r in rows if r[1]}) - local_map = _get_local_stock_bulk(tenant_conn, branch_id, oem_numbers, result_oem_ids) + rows = cur.fetchall() + except Exception: + # TecDoc tables (parts, aftermarket_parts, vehicle_parts) may not exist + try: + master_conn.rollback() + except Exception: + pass + finally: + cur.close() items = [] seen_part_numbers = set() - for r in rows: - aft_id = r[0] - oem_part_id = r[1] - aft_number = r[2] - aft_name = r[3] - price_usd = r[4] - manufacturer = r[5] - oem_number = r[6] - oem_name = r[7] - oem_desc = r[8] - oem_image = r[9] - bodega_count = r[10] - warehouse_price = r[11] - warehouse_stock = r[12] - tier = r[13] + if rows: + # Enrich with tenant local stock (look up by OEM part number). + oem_numbers = list({r[6] for r in rows if r[6]}) + result_oem_ids = list({r[1] for r in rows if r[1]}) + local_map = _get_local_stock_bulk(tenant_conn, branch_id, oem_numbers, result_oem_ids) - # Tenant local stock (refaccionaria's own inventory) - 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 + for r in rows: + aft_id = r[0] + oem_part_id = r[1] + aft_number = r[2] + aft_name = r[3] + price_usd = r[4] + manufacturer = r[5] + oem_number = r[6] + oem_name = r[7] + oem_desc = r[8] + oem_image = r[9] + bodega_count = r[10] + warehouse_price = r[11] + warehouse_stock = r[12] + tier = r[13] - part_number = aft_number or oem_number - if part_number: - seen_part_numbers.add(part_number.upper()) + 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 - items.append({ - # Keep fields compatible with OEM mode output so the frontend - # can render with minimal branching. - 'id_part': oem_part_id, # OEM id used for detail drill-down - 'id_aftermarket': aft_id, # aftermarket row id (for future use) - 'oem_part_number': oem_number, - 'part_number': aft_number, # aftermarket SKU - 'name': translate_part_name(aft_name or oem_name), - 'description': oem_desc, - 'image_url': image_url, - 'manufacturer': manufacturer, - 'priority_tier': tier, # 1, 2, or 3 - 'local_stock': local['stock'] if local else 0, - 'local_price': local['price_1'] if local else None, - 'bodega_count': bodega_count, - 'warehouse_stock': warehouse_stock, - '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', - }) + part_number = aft_number or oem_number + if part_number: + seen_part_numbers.add(part_number.upper()) + + items.append({ + 'id_part': oem_part_id, + 'id_aftermarket': aft_id, + 'oem_part_number': oem_number, + 'part_number': aft_number, + 'name': translate_part_name(aft_name or oem_name), + 'description': oem_desc, + 'image_url': image_url, + 'manufacturer': manufacturer, + 'priority_tier': tier, + 'local_stock': local['stock'] if local else 0, + 'local_price': local['price_1'] if local else None, + 'bodega_count': bodega_count, + 'warehouse_stock': warehouse_stock, + '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: @@ -1220,7 +1678,7 @@ def get_parts_local(master_conn, mye_id, group_id, tenant_conn, 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 + continue seen_part_numbers.add(pn.upper() if pn else '') items.append({ 'id_part': f'inv:{inv_id}', @@ -1231,7 +1689,7 @@ def get_parts_local(master_conn, mye_id, group_id, tenant_conn, branch_id, 'description': desc, 'image_url': img, 'manufacturer': brand, - 'priority_tier': 1, # treat as tier 1 since it's local stock + 'priority_tier': 1, 'local_stock': int(stock) if stock else 0, 'local_price': float(p1) if p1 else None, 'bodega_count': 0, @@ -1241,9 +1699,18 @@ def get_parts_local(master_conn, mye_id, group_id, tenant_conn, branch_id, 'price_usd': None, 'source': 'local_inventory', }) - total += 1 - return {'data': items, 'pagination': _pagination(page, per_page, total), 'mode': 'local'} + # Sort combined list (aftermarket + local) by tier, in-stock, manufacturer/name + items.sort(key=lambda x: ( + x.get('priority_tier', 3), + 0 if (x.get('in_stock_network') or (x.get('local_stock') or 0) > 0) else 1, + (x.get('manufacturer') or '').lower(), + (x.get('name') or '').lower() + )) + total = len(items) + offset = (page - 1) * per_page + paginated = items[offset:offset + per_page] + return {'data': paginated, 'pagination': _pagination(page, per_page, total), 'mode': 'local'} def get_part_detail(master_conn, part_id, tenant_conn, branch_id): @@ -1449,97 +1916,13 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None): return [] limit = min(limit, 100) - cur = master_conn.cursor() # ── Attempt Meilisearch first ─────────────────────────────────────────── - meili_rows = _search_meili_fallback(master_conn, q, limit) - if meili_rows is not None: - rows = meili_rows - else: - # PostgreSQL fallback - is_part_number = bool(re.search(r'\d.*[-/]|[-/].*\d|\d{3,}', q)) - - if is_part_number: - clean_q = q.replace(' ', '').upper() - cur.execute(""" - SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es, - p.image_url, p.group_id - FROM parts p - WHERE REPLACE(UPPER(p.oem_part_number), ' ', '') LIKE %s - ORDER BY p.oem_part_number - LIMIT %s - """, (f'%{clean_q}%', limit)) - else: - tsquery = ' & '.join(q.split()) - cur.execute(""" - SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es, - p.image_url, p.group_id - FROM parts p - WHERE p.search_vector @@ to_tsquery('spanish', %s) - OR p.name_part ILIKE %s - OR p.name_es ILIKE %s - ORDER BY - CASE WHEN p.search_vector @@ to_tsquery('spanish', %s) - THEN 0 ELSE 1 END, - p.name_part - LIMIT %s - """, (tsquery, f'%{q}%', f'%{q}%', tsquery, limit)) - rows = cur.fetchall() - - part_ids = [r[0] for r in rows] - oem_numbers = [r[1] for r in rows] - - # Get vehicle info for each part (Redis cache first, then DB fallback) - vehicle_info_map = {} - missing_ids = [] - r = _get_redis() - if r: - for pid in part_ids: - cached = r.get(f'nexus:vehicle:{pid}') - if cached is not None: - vehicle_info_map[pid] = cached - else: - missing_ids.append(pid) - else: - missing_ids = part_ids - - if missing_ids: - cur.execute(""" - SELECT part_id, name_brand, name_model, year_car - FROM part_vehicle_preview - WHERE part_id = ANY(%s) - """, (missing_ids,)) - for row in cur.fetchall(): - info = f"{row[1]} {row[2]} {row[3]}" - vehicle_info_map[row[0]] = info - if r: - try: - r.setex(f'nexus:vehicle:{row[0]}', _VEHICLE_TTL_SECONDS, info) - except Exception: - pass - cur.close() - - # Local stock enrichment - local_map = _get_local_stock_bulk(tenant_conn, branch_id, oem_numbers, part_ids) - - results = [] + # NOTE: TecDoc parts table was removed. Central catalog search is disabled. + # Only local inventory search remains active. + rows = [] seen_local_ids = set() - for r in rows: - part_id = r[0] - oem = r[1] - local = local_map.get(oem) or local_map.get(f'cat:{part_id}') - results.append({ - 'id_part': part_id, - 'oem_part_number': oem, - 'name': translate_part_name(r[3] or r[2]), - 'image_url': r[4], - 'local_stock': local['stock'] if local else 0, - 'local_price': local['price_1'] if local else None, - 'vehicle_info': vehicle_info_map.get(part_id, ''), - }) - # Track which local inventory items are already shown via OEM link - if local: - seen_local_ids.add(local.get('inventory_id')) + results = [] # ── Inject local inventory items that match the query directly ────────── if tenant_conn: @@ -1557,12 +1940,88 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None): 'vehicle_info': '', 'source': 'local_inventory', }) + seen_local_ids.add(li['inventory_id']) + if len(results) >= limit: + break + + # ── Inject supplier catalog items ─────────────────────────────────────── + if tenant_conn: + supplier_items = _search_supplier_catalog(tenant_conn, q, mye_id, limit) + for si in supplier_items: + if f"sc:{si['id']}" in seen_local_ids: + continue + results.append({ + 'id_part': f"sc:{si['id']}", + 'oem_part_number': si['sku'], + 'name': si['name'], + 'image_url': si['image_url'], + 'local_stock': None, + 'local_price': None, + 'vehicle_info': si['category'] or '', + 'source': 'supplier_catalog', + }) + seen_local_ids.add(f"sc:{si['id']}") if len(results) >= limit: break return results +def _search_supplier_catalog(tenant_conn, q, mye_id, limit): + """Search supplier catalog items by SKU or name. + + If mye_id is provided, only returns items compatible with that vehicle. + """ + if tenant_conn is None: + return [] + cur = tenant_conn.cursor() + clean_q = q.replace(' ', '').upper() + + _SQL_UNACCENT = """ + REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( + REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( + UPPER(sc.name) + , 'Á', 'A'), 'É', 'E'), 'Í', 'I'), 'Ó', 'O'), 'Ú', 'U') + , 'À', 'A'), 'È', 'E'), 'Ì', 'I'), 'Ò', 'O'), 'Ù', 'U') + """ + _q_unaccent = q.upper() + for a, b in [('Á', 'A'), ('É', 'E'), ('Í', 'I'), ('Ó', 'O'), ('Ú', 'U'), + ('À', 'A'), ('È', 'E'), ('Ì', 'I'), ('Ò', 'O'), ('Ù', 'U'), + ('Ä', 'A'), ('Ë', 'E'), ('Ï', 'I'), ('Ö', 'O'), ('Ü', 'U'), + ('Ñ', 'N')]: + _q_unaccent = _q_unaccent.replace(a, b) + + if mye_id: + cur.execute(f""" + SELECT DISTINCT sc.id, sc.sku, sc.name, sc.image_url, sc.category + FROM supplier_catalog sc + JOIN supplier_catalog_compat scc ON scc.catalog_id = sc.id + WHERE sc.is_active = true + AND scc.model_year_engine_id = %s + AND (REPLACE(UPPER(sc.sku), ' ', '') LIKE %s + OR {_SQL_UNACCENT} LIKE %s) + ORDER BY sc.name + LIMIT %s + """, (mye_id, f'%{clean_q}%', f'%{_q_unaccent}%', limit)) + else: + cur.execute(f""" + SELECT DISTINCT sc.id, sc.sku, sc.name, sc.image_url, sc.category + FROM supplier_catalog sc + WHERE sc.is_active = true + AND (REPLACE(UPPER(sc.sku), ' ', '') LIKE %s + OR {_SQL_UNACCENT} LIKE %s) + ORDER BY sc.name + LIMIT %s + """, (f'%{clean_q}%', f'%{_q_unaccent}%', limit)) + + rows = cur.fetchall() + cur.close() + return [ + {'id': r[0], 'sku': r[1], 'name': r[2], 'image_url': r[3], 'category': r[4]} + for r in rows + ] + + def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit): """Search tenant inventory items by part_number or name. @@ -1817,7 +2276,7 @@ def get_alternatives(master_conn, part_id): def _pagination(page, per_page, total): """Build standard pagination dict.""" - total_pages = max(1, (total + per_page - 1) // per_page) + total_pages = max(0, (total + per_page - 1) // per_page) return { 'page': page, 'per_page': per_page, diff --git a/pos/services/dropshipping_service.py b/pos/services/dropshipping_service.py new file mode 100644 index 0000000..11e823a --- /dev/null +++ b/pos/services/dropshipping_service.py @@ -0,0 +1,168 @@ +"""Dropshipping integration service. + +Provides read-only inventory access for external dropshipping platforms +and webhook dispatching on stock/price/sale events. +""" + +import logging +from services.inventory_engine import get_stock_bulk + +logger = logging.getLogger(__name__) + + +def resolve_tenant_by_api_key(master_conn, api_key: str): + """Find tenant_id and db_name for a given dropshipping API key. + + Returns (tenant_id, db_name) or (None, None) if invalid. + """ + if not api_key: + return None, None + cur = master_conn.cursor() + # tenant_config lives in each tenant DB, so we need to scan tenants + cur.execute("SELECT id, db_name FROM tenants WHERE is_active = true") + tenants = cur.fetchall() + for tid, db_name in tenants: + try: + tcur = master_conn.cursor() + # Use dblink or connect to tenant DB? Simpler: the blueprint + # will pass tenant_conn directly after resolution. + # Instead, we store a mapping in master DB for speed. + # For now, return all candidates and let caller validate. + pass + except Exception: + continue + cur.close() + return None, None + + +def _get_dropshipping_key(tenant_conn): + cur = tenant_conn.cursor() + cur.execute("SELECT value FROM tenant_config WHERE key = 'dropshipping_api_key'") + row = cur.fetchone() + cur.close() + return row[0] if row else None + + +def validate_api_key(tenant_conn, api_key: str) -> bool: + """Check if the provided API key matches the tenant's configured key.""" + if not api_key: + return False + expected = _get_dropshipping_key(tenant_conn) + return expected is not None and expected == api_key + + +def get_inventory_list(tenant_conn, search: str = None, page: int = 1, per_page: int = 50): + """Return inventory items with stock and price for dropshipping.""" + offset = (max(page, 1) - 1) * per_page + stock_map = get_stock_bulk(tenant_conn, branch_id=None) + + cur = tenant_conn.cursor() + params = [] + where = "WHERE is_active = true" + if search: + where += " AND (name ILIKE %s OR part_number ILIKE %s)" + params.extend([f"%{search}%", f"%{search}%"]) + + cur.execute( + f""" + SELECT id, part_number, name, brand, price_1, price_2, price_3, + image_url, unit, description + FROM inventory + {where} + ORDER BY id DESC + LIMIT %s OFFSET %s + """, + params + [per_page, offset], + ) + rows = cur.fetchall() + + # Count total + cur.execute(f"SELECT COUNT(*) FROM inventory {where}", params) + total = cur.fetchone()[0] + cur.close() + + items = [] + for r in rows: + inv_id = r[0] + items.append({ + "id": inv_id, + "sku": r[1], + "name": r[2], + "brand": r[3], + "price_1": float(r[4]) if r[4] else None, + "price_2": float(r[5]) if r[5] else None, + "price_3": float(r[6]) if r[6] else None, + "stock": stock_map.get(inv_id, 0), + "image_url": r[7], + "unit": r[8], + "description": r[9], + }) + return {"items": items, "page": page, "per_page": per_page, "total": total} + + +def get_inventory_by_sku(tenant_conn, sku: str): + """Return a single inventory item by SKU/part_number.""" + stock_map = get_stock_bulk(tenant_conn, branch_id=None) + cur = tenant_conn.cursor() + cur.execute( + """ + SELECT id, part_number, name, brand, price_1, price_2, price_3, + image_url, unit, description + FROM inventory + WHERE part_number = %s AND is_active = true + LIMIT 1 + """, + (sku,), + ) + row = cur.fetchone() + cur.close() + if not row: + return None + inv_id = row[0] + return { + "id": inv_id, + "sku": row[1], + "name": row[2], + "brand": row[3], + "price_1": float(row[4]) if row[4] else None, + "price_2": float(row[5]) if row[5] else None, + "price_3": float(row[6]) if row[6] else None, + "stock": stock_map.get(inv_id, 0), + "image_url": row[7], + "unit": row[8], + "description": row[9], + } + + +def get_stock_by_skus(tenant_conn, skus: list[str]) -> dict: + """Return stock levels for a list of SKUs.""" + stock_map = get_stock_bulk(tenant_conn, branch_id=None) + cur = tenant_conn.cursor() + cur.execute( + """ + SELECT id, part_number FROM inventory + WHERE part_number = ANY(%s) AND is_active = true + """, + (skus,), + ) + rows = cur.fetchall() + cur.close() + result = {} + for inv_id, sku in rows: + result[sku] = stock_map.get(inv_id, 0) + return result + + +def get_webhook_targets(tenant_conn, event_type: str) -> list[str]: + """Return active webhook URLs for a given event type.""" + cur = tenant_conn.cursor() + cur.execute( + """ + SELECT target_url FROM dropshipping_webhooks + WHERE event_type = %s AND is_active = true + """, + (event_type,), + ) + urls = [r[0] for r in cur.fetchall()] + cur.close() + return urls diff --git a/pos/services/marketplace_external_service.py b/pos/services/marketplace_external_service.py index 019d548..a218c80 100644 --- a/pos/services/marketplace_external_service.py +++ b/pos/services/marketplace_external_service.py @@ -9,6 +9,7 @@ Depends on: import json import logging +import urllib.parse from typing import Optional from decimal import Decimal @@ -19,6 +20,19 @@ from services.inventory_engine import get_stock, get_stock_bulk logger = logging.getLogger(__name__) +def _resolve_image_urls(images: list[str], base_url: str | None = None) -> list[str]: + """Convert relative image paths to absolute URLs.""" + resolved = [] + for url in images: + if not url: + continue + if base_url and url.startswith("/"): + resolved.append(urllib.parse.urljoin(base_url.rstrip("/") + "/", url.lstrip("/"))) + else: + resolved.append(url) + return resolved + + # ═══════════════════════════════════════════════════════════════════════════ # CONFIG HELPERS # ═══════════════════════════════════════════════════════════════════════════ @@ -145,6 +159,7 @@ def build_item_payload( custom_title: str = None, extra_attributes: list = None, shipping_cost: float = None, + base_url: str = None, ) -> dict: """Convert a Nexus inventory row into a MercadoLibre item payload.""" title = custom_title or f"{inventory_row['name']} {inventory_row['brand'] or ''} {inventory_row['part_number'] or ''}".strip() @@ -167,17 +182,20 @@ def build_item_payload( "buying_mode": "buy_it_now", "listing_type_id": listing_type_id, "condition": "new", - "pictures": [{"source": url} for url in images if url], + "pictures": [{"source": url} for url in _resolve_image_urls(images, base_url) if url], "shipping": shipping_payload, "attributes": [], } - if inventory_row.get("brand"): + # Collect extra attribute IDs to avoid duplicates + extra_attr_ids = {a.get("id") for a in (extra_attributes or []) if a.get("id")} + + if inventory_row.get("brand") and "BRAND" not in extra_attr_ids: payload["attributes"].append( {"id": "BRAND", "value_name": inventory_row["brand"]} ) - if inventory_row.get("part_number"): + if inventory_row.get("part_number") and "PART_NUMBER" not in extra_attr_ids: payload["attributes"].append( {"id": "PART_NUMBER", "value_name": inventory_row["part_number"]} ) @@ -193,11 +211,11 @@ def build_item_payload( if isinstance(vehicle_compat, list) and vehicle_compat: first = vehicle_compat[0] if isinstance(first, dict): - if first.get("brand"): + if first.get("brand") and "VEHICLE_MODEL" not in extra_attr_ids: payload["attributes"].append( {"id": "VEHICLE_MODEL", "value_name": first["brand"]} ) - if first.get("model"): + if first.get("model") and "VEHICLE_MODEL_NAME" not in extra_attr_ids: payload["attributes"].append( {"id": "VEHICLE_MODEL_NAME", "value_name": first["model"]} ) @@ -243,7 +261,7 @@ def check_meli_shipping_config(svc: MeliService, cfg: dict) -> dict: # LISTINGS CRUD # ═══════════════════════════════════════════════════════════════════════════ -def check_inventory_ml_status(tenant_conn, inventory_ids: list[int]) -> dict: +def check_inventory_ml_status(tenant_conn, inventory_ids: list[int], base_url: str = None) -> dict: """Check local pre-flight status for ML publishing. Returns per-item dict with checks: has_image, has_stock, has_price, @@ -298,7 +316,7 @@ def check_inventory_ml_status(tenant_conn, inventory_ids: list[int]) -> dict: "has_price": price > 0, "price": price, "stock": stock, - "image_url": image_url, + "image_url": _resolve_image_urls([image_url], base_url)[0] if image_url else None, "already_published": existing is not None, "existing_listing": existing, }) @@ -312,6 +330,7 @@ def validate_items( listing_type_id: str = "gold_special", shipping_mode: str = "me2", custom_data: dict = None, + base_url: str = None, ) -> dict: """Validate items against ML /items/validate without creating them. @@ -370,6 +389,7 @@ def validate_items( images = [] if inv.get("image_url"): images.append(inv["image_url"]) + images = _resolve_image_urls(images, base_url) if not images: results["invalid"].append({"inventory_id": inv_id, "error": "El producto no tiene imagen"}) continue @@ -385,6 +405,7 @@ def validate_items( shipping_mode=shipping_mode, listing_type_id=listing_type_id, custom_title=title, extra_attributes=extra_attrs, shipping_cost=shipping_cost, + base_url=base_url, ) try: @@ -412,6 +433,7 @@ def publish_items( listing_type_id: str = "gold_special", shipping_mode: str = "me2", custom_data: dict = None, + base_url: str = None, ) -> dict: """Publish one or more inventory items to MercadoLibre. @@ -476,6 +498,7 @@ def publish_items( images = [] if inv.get("image_url"): images.append(inv["image_url"]) + images = _resolve_image_urls(images, base_url) if not images: results["failed"].append({"inventory_id": inv_id, "error": "El producto no tiene imagen. ML requiere imagen para publicar."}) @@ -492,6 +515,7 @@ def publish_items( shipping_mode=shipping_mode, listing_type_id=listing_type_id, custom_title=title, extra_attributes=extra_attrs, shipping_cost=shipping_cost, + base_url=base_url, ) try: @@ -740,6 +764,239 @@ def close_listing(tenant_conn, listing_id: int) -> dict: return {"ok": True, "status": "closed"} +def delete_listing_permanently(tenant_conn, listing_id: int) -> dict: + """Hard-delete a closed listing from the local DB. + + Sets listing_id = NULL on marketplace_order_items to avoid FK errors, + then deletes the marketplace_listings row. + """ + cur = tenant_conn.cursor() + cur.execute( + "SELECT id, external_status FROM marketplace_listings WHERE id = %s", + (listing_id,), + ) + row = cur.fetchone() + if not row: + cur.close() + raise ValueError("Listing not found") + + # Clear FK references so we can delete safely + cur.execute( + "UPDATE marketplace_order_items SET listing_id = NULL WHERE listing_id = %s", + (listing_id,), + ) + cur.execute( + "DELETE FROM marketplace_listings WHERE id = %s", + (listing_id,), + ) + tenant_conn.commit() + cur.close() + return {"ok": True, "deleted": True} + + +# ═══════════════════════════════════════════════════════════════════════════ +# QUESTIONS & ANSWERS +# ═══════════════════════════════════════════════════════════════════════════ + +def _upsert_question(cur, q_data: dict, listing_id_map: dict): + """Upsert a single question from ML API into marketplace_questions.""" + external_qid = str(q_data.get("id")) + external_item_id = str(q_data.get("item_id")) + text = q_data.get("text", "") + status = q_data.get("status", "unanswered") + answer = q_data.get("answer", {}) + answer_text = answer.get("text") if answer else None + answer_date = None + if answer and answer.get("date_created"): + answer_date = answer["date_created"] + from_user = q_data.get("from", {}) + buyer_id = str(from_user.get("id")) if from_user else None + buyer_nickname = from_user.get("nickname") if from_user else None + question_date = q_data.get("date_created") + listing_id = listing_id_map.get(external_item_id) + + cur.execute( + """ + INSERT INTO marketplace_questions + (listing_id, external_question_id, external_item_id, question_text, + answer_text, status, buyer_id, buyer_nickname, question_date, + answer_date, raw_json, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()) + ON CONFLICT (external_question_id) + DO UPDATE SET + question_text = EXCLUDED.question_text, + answer_text = EXCLUDED.answer_text, + status = EXCLUDED.status, + buyer_id = EXCLUDED.buyer_id, + buyer_nickname = EXCLUDED.buyer_nickname, + question_date = EXCLUDED.question_date, + answer_date = EXCLUDED.answer_date, + raw_json = EXCLUDED.raw_json, + updated_at = NOW() + """, + ( + listing_id, + external_qid, + external_item_id, + text, + answer_text, + status, + buyer_id, + buyer_nickname, + question_date, + answer_date, + json.dumps(q_data), + ), + ) + + +def sync_questions(tenant_conn) -> dict: + """Fetch questions from ML for all active listings and upsert locally. + + Returns {"synced": N, "items": [...]}. + """ + cfg = get_meli_config(tenant_conn) + svc = _get_meli_service(cfg) + if not svc: + raise ValueError("MercadoLibre not configured") + + cur = tenant_conn.cursor() + cur.execute( + """ + SELECT id, external_item_id + FROM marketplace_listings + WHERE channel = 'mercadolibre' AND is_active = true + """ + ) + listings = {r[1]: r[0] for r in cur.fetchall()} + cur.close() + + if not listings: + return {"synced": 0, "items": []} + + total_synced = 0 + for item_id in listings: + try: + resp = svc.get_questions(item_id, limit=50) + questions = resp.get("questions", []) + if not questions: + continue + cur = tenant_conn.cursor() + for q in questions: + _upsert_question(cur, q, listings) + tenant_conn.commit() + cur.close() + total_synced += len(questions) + except Exception as e: + logger.warning("Failed to sync questions for item %s: %s", item_id, e) + + return {"synced": total_synced} + + +def fetch_question_from_ml(tenant_conn, external_question_id: str) -> dict: + """Fetch a single question from ML API and upsert locally.""" + cfg = get_meli_config(tenant_conn) + svc = _get_meli_service(cfg) + if not svc: + raise ValueError("MercadoLibre not configured") + + q_data = svc.get_question(external_question_id) + cur = tenant_conn.cursor() + cur.execute( + "SELECT id, external_item_id FROM marketplace_listings WHERE external_item_id = %s", + (str(q_data.get("item_id")),), + ) + row = cur.fetchone() + listing_id_map = {row[1]: row[0]} if row else {} + _upsert_question(cur, q_data, listing_id_map) + tenant_conn.commit() + cur.close() + return q_data + + +def answer_question(tenant_conn, local_question_id: int, text: str) -> dict: + """Answer a question via ML API and update local status.""" + cfg = get_meli_config(tenant_conn) + svc = _get_meli_service(cfg) + if not svc: + raise ValueError("MercadoLibre not configured") + + cur = tenant_conn.cursor() + cur.execute( + "SELECT external_question_id FROM marketplace_questions WHERE id = %s", + (local_question_id,), + ) + row = cur.fetchone() + if not row: + cur.close() + raise ValueError("Question not found") + external_qid = row[0] + cur.close() + + resp = svc.answer_question(external_qid, text) + + cur = tenant_conn.cursor() + cur.execute( + """ + UPDATE marketplace_questions + SET answer_text = %s, status = 'answered', answer_date = NOW(), updated_at = NOW() + WHERE id = %s + """, + (text, local_question_id), + ) + tenant_conn.commit() + cur.close() + return {"ok": True, "ml_response": resp} + + +def list_local_questions(tenant_conn, status: str = None) -> list: + """Return questions from local DB, optionally filtered by status.""" + cur = tenant_conn.cursor() + if status: + cur.execute( + """ + SELECT q.id, q.external_question_id, q.external_item_id, q.question_text, + q.answer_text, q.status, q.buyer_nickname, q.question_date, + q.answer_date, q.created_at, l.title, l.external_permalink + FROM marketplace_questions q + LEFT JOIN marketplace_listings l ON l.id = q.listing_id + WHERE q.status = %s + ORDER BY q.question_date DESC + """, + (status,), + ) + else: + cur.execute( + """ + SELECT q.id, q.external_question_id, q.external_item_id, q.question_text, + q.answer_text, q.status, q.buyer_nickname, q.question_date, + q.answer_date, q.created_at, l.title, l.external_permalink + FROM marketplace_questions q + LEFT JOIN marketplace_listings l ON l.id = q.listing_id + ORDER BY q.question_date DESC + """ + ) + rows = cur.fetchall() + cur.close() + results = [] + for r in rows: + results.append({ + "id": r[0], + "external_question_id": r[1], + "external_item_id": r[2], + "question_text": r[3], + "answer_text": r[4], + "status": r[5], + "buyer_nickname": r[6], + "question_date": r[7], + "answer_date": r[8], + "created_at": r[9], + "listing_title": r[10], + "listing_permalink": r[11], + }) + return results + + # ═══════════════════════════════════════════════════════════════════════════ # ORDERS # ═══════════════════════════════════════════════════════════════════════════ diff --git a/pos/services/meli_service.py b/pos/services/meli_service.py index 9773402..06afce3 100644 --- a/pos/services/meli_service.py +++ b/pos/services/meli_service.py @@ -167,6 +167,23 @@ class MeliService: def close_item(self, item_id: str) -> dict: return self.update_item(item_id, {"status": "closed"}) + # ─── Questions & Answers ───────────────────────────────────────────── + + def get_questions(self, item_id: str, status: str = None, offset: int = 0, limit: int = 50) -> dict: + params = {"item_id": item_id, "offset": offset, "limit": limit} + if status: + params["status"] = status + return self._request("GET", "/questions/search", params=params) + + def get_question(self, question_id: str) -> dict: + return self._request("GET", f"/questions/{question_id}") + + def answer_question(self, question_id: str, text: str) -> dict: + return self._request("POST", "/answers", json_payload={"question_id": question_id, "text": text}) + + def delete_question(self, question_id: str) -> dict: + return self._request("DELETE", f"/questions/{question_id}") + # ─── Categories ────────────────────────────────────────────────────── def get_category(self, category_id: str) -> dict: diff --git a/pos/services/pos_engine.py b/pos/services/pos_engine.py index 21bccfb..9609791 100644 --- a/pos/services/pos_engine.py +++ b/pos/services/pos_engine.py @@ -447,6 +447,35 @@ def process_sale(conn, sale_data): except Exception: pass # Learning errors never block sales + # Dropshipping webhook hook (non-blocking) + try: + from services import dropshipping_service as ds_svc + from services.webhook_service import dispatch_webhooks_bulk + webhook_urls = ds_svc.get_webhook_targets(conn, 'sale_made') + if webhook_urls: + payload_items = [] + for item in enriched_items: + remaining = item['stock_before'] - item['quantity'] + payload_items.append({ + 'sku': item['part_number'], + 'name': item['name'], + 'quantity_sold': item['quantity'], + 'stock_remaining': remaining, + 'unit_price': item['unit_price'], + }) + threading.Thread( + target=dispatch_webhooks_bulk, + args=(webhook_urls, 'sale_made', { + 'sale_id': sale_id, + 'items': payload_items, + 'total': totals['total'], + 'created_at': str(created_at), + }), + daemon=True + ).start() + except Exception: + pass # Webhook errors never block sales + return { 'id': sale_id, 'branch_id': branch_id, diff --git a/pos/services/webhook_service.py b/pos/services/webhook_service.py new file mode 100644 index 0000000..0dba55a --- /dev/null +++ b/pos/services/webhook_service.py @@ -0,0 +1,65 @@ +"""Webhook dispatch service for dropshipping and external integrations. + +Sends POST requests to configured target URLs with retry logic. +Can be called synchronously or enqueued via Celery. +""" + +import json +import logging +import requests +import threading +from typing import Optional + +logger = logging.getLogger(__name__) + + +def _send_post(url: str, payload: dict, headers: Optional[dict] = None, timeout: int = 10): + """Send a POST request and return (success, status_code, response_text).""" + default_headers = {"Content-Type": "application/json"} + if headers: + default_headers.update(headers) + try: + resp = requests.post(url, json=payload, headers=default_headers, timeout=timeout) + success = 200 <= resp.status_code < 300 + if not success: + logger.warning("Webhook %s returned %s: %s", url, resp.status_code, resp.text[:200]) + return success, resp.status_code, resp.text + except requests.exceptions.Timeout: + logger.warning("Webhook %s timed out after %ss", url, timeout) + return False, 0, "timeout" + except Exception as e: + logger.warning("Webhook %s failed: %s", url, e) + return False, 0, str(e) + + +def dispatch_webhook_sync(target_url: str, event_type: str, payload: dict, secret: Optional[str] = None): + """Send webhook synchronously (use inside Celery tasks for async).""" + full_payload = { + "event": event_type, + "data": payload, + } + headers = {} + if secret: + headers["X-Webhook-Secret"] = secret + success, status, body = _send_post(target_url, full_payload, headers=headers) + return {"success": success, "status": status, "body": body[:500]} + + +def dispatch_webhooks_bulk(target_urls: list[str], event_type: str, payload: dict, secret: Optional[str] = None): + """Dispatch to multiple URLs concurrently using threads.""" + results = [] + threads = [] + + def _send(url): + result = dispatch_webhook_sync(url, event_type, payload, secret=secret) + results.append({"url": url, **result}) + + for url in target_urls: + t = threading.Thread(target=_send, args=(url,)) + t.start() + threads.append(t) + + for t in threads: + t.join(timeout=15) + + return results diff --git a/pos/static/css/dashboard.css b/pos/static/css/dashboard.css index e10d14d..1476411 100644 --- a/pos/static/css/dashboard.css +++ b/pos/static/css/dashboard.css @@ -812,6 +812,18 @@ box-shadow: var(--shadow-md); } + .chart-canvas-wrap { + position: relative; + height: 220px; + width: 100%; + } + + .chart-canvas-wrap canvas { + display: block; + width: 100% !important; + height: 100% !important; + } + .rank-list { display: flex; flex-direction: column; diff --git a/pos/static/js/app-init.js b/pos/static/js/app-init.js index 82a2363..bdae956 100644 --- a/pos/static/js/app-init.js +++ b/pos/static/js/app-init.js @@ -197,4 +197,14 @@ }).catch(function() {}); } catch(e) {} + // ─── Service Worker update handler ─── + if ('serviceWorker' in navigator) { + navigator.serviceWorker.addEventListener('message', function (event) { + if (event.data && event.data.type === 'SW_UPDATED') { + console.log('[AppInit] SW updated to', event.data.cacheName, '— reloading...'); + window.location.reload(); + } + }); + } + })(); diff --git a/pos/static/js/catalog.js b/pos/static/js/catalog.js index f8667ae..05179b0 100644 --- a/pos/static/js/catalog.js +++ b/pos/static/js/catalog.js @@ -195,7 +195,19 @@ currentAbort = null; } var opts = { headers: headers }; - if (url.indexOf('/pos/api/') === 0 && url.indexOf('mode=') !== -1 || url.indexOf('/years') !== -1 || url.indexOf('/brands') !== -1 || url.indexOf('/models') !== -1 || url.indexOf('/engines') !== -1 || url.indexOf('/categories') !== -1 || url.indexOf('/groups') !== -1 || url.indexOf('/part-types') !== -1 || url.indexOf('/parts') !== -1 || url.indexOf('/search') !== -1) { + var isCatalogNav = url.indexOf('/pos/api/') === 0 && ( + url.indexOf('mode=') !== -1 || + url.indexOf('/years') !== -1 || + url.indexOf('/brands') !== -1 || + url.indexOf('/models') !== -1 || + url.indexOf('/engines') !== -1 || + url.indexOf('/categories') !== -1 || + url.indexOf('/groups') !== -1 || + url.indexOf('/part-types') !== -1 || + url.indexOf('/parts') !== -1 || + url.indexOf('/search') !== -1 + ); + if (isCatalogNav) { currentAbort = new AbortController(); opts.signal = currentAbort.signal; } @@ -233,7 +245,7 @@ if (!s) return ''; var d = document.createElement('div'); d.textContent = s; - return d.innerHTML; + return d.innerHTML.replace(/"/g, '"'); } // ─── Breadcrumb ─── @@ -290,9 +302,9 @@ function resetNav() { nav.level = 'brands'; - pushNavState(); nav.brand = nav.model = nav.year = nav.engine = nav.category = nav.group = nav.partType = null; nav.nxGroup = nav.nxSubgroup = nav.nxPartType = null; + pushNavState(); } function resetNavFrom(level) { @@ -927,9 +939,14 @@ partsGrid.querySelectorAll('.part-card').forEach(function (card) { card.addEventListener('click', function () { var pid = this.dataset.partId; + var src = this.dataset.source || ''; if (typeof pid === 'string' && pid.indexOf('inv:') === 0) { return; } + if (src === 'supplier_catalog' || (typeof pid === 'string' && pid.indexOf('sc:') === 0)) { + openSupplierDetail(pid.replace('sc:', '')); + return; + } openPartDetail(parseInt(pid)); }); }); @@ -988,17 +1005,23 @@ partsGrid.innerHTML = data.data.map(function (p) { // Stock badge — prefer tenant stock, then warehouse network, else fallback var stockBadge; - if (p.local_stock > 0) { + var isSupplier = p.source === 'supplier_catalog' || (typeof p.id_part === 'string' && p.id_part.indexOf('sc:') === 0); + if (isSupplier) { + stockBadge = 'Cat. Proveedor'; + } else if (p.local_stock > 0) { stockBadge = 'En stock: ' + p.local_stock + ''; } else if (p.in_stock_network || p.bodega_count > 0) { stockBadge = '' + p.bodega_count + ' bodega' + (p.bodega_count > 1 ? 's' : '') + ''; } else { stockBadge = 'Sin stock'; } - // Local inventory native badge - var sourceBadge = p.source === 'local_inventory' - ? 'Stock Local' - : ''; + // Source badge for local inventory or supplier catalog + var sourceBadge = ''; + if (p.source === 'local_inventory') { + sourceBadge = 'Stock Local'; + } else if (isSupplier) { + sourceBadge = 'Cat. Proveedor'; + } var imgHtml = p.image_url ? '' + esc(p.name) + '' @@ -1039,10 +1062,15 @@ partsGrid.querySelectorAll('.part-card').forEach(function (card) { card.addEventListener('click', function () { var pid = this.dataset.partId; + var src = this.dataset.source || ''; if (typeof pid === 'string' && pid.indexOf('inv:') === 0) { // local-inventory item: info already visible on card return; } + if (src === 'supplier_catalog' || (typeof pid === 'string' && pid.indexOf('sc:') === 0)) { + openSupplierDetail(pid.replace('sc:', '')); + return; + } openPartDetail(parseInt(pid)); }); }); @@ -1185,6 +1213,73 @@ }); } + function openSupplierDetail(supplierId) { + detailBody.innerHTML = '
'; + detailFooter.style.display = 'none'; + detailPanel.classList.add('is-open'); + detailOverlay.classList.add('is-visible'); + + apiFetch('/pos/api/supplier-catalog/items/' + supplierId).then(function (data) { + if (!data || data.error) { + detailBody.innerHTML = '

Error al cargar detalle.

'; + return; + } + var p = data; + var html = ''; + + html += '
'; + html += '
' + esc(p.supplier_name) + ' > ' + esc(p.category || '') + '
'; + html += '
' + esc(p.sku) + '
'; + html += '
' + esc((p.name || '').replace(/\\n/g, ' ')) + '
'; + if (p.description) html += '
' + esc(p.description) + '
'; + if (p.image_url) html += '
'; + html += '
'; + + // Interchanges + if (p.interchanges && p.interchanges.length) { + html += '
'; + html += '
Intercambios OEM
'; + var seen = {}; + p.interchanges.forEach(function(ix) { + var key = (ix.brand || '') + '|' + (ix.interchange_number || ''); + if (seen[key]) return; + seen[key] = true; + html += '
' + + '' + esc(ix.brand || '') + '' + + '' + esc(ix.interchange_number || '') + '' + + '
'; + }); + html += '
'; + } + + // Compatibilities — deduplicate by (make, model, year, engine) + if (p.compatibilities && p.compatibilities.length) { + html += '
'; + html += '
Vehiculos compatibles
'; + var seenCompat = {}; + var uniqCompat = []; + p.compatibilities.forEach(function(c) { + var key = (c.make || '') + '|' + (c.model || '') + '|' + (c.year || '') + '|' + (c.engine || ''); + if (seenCompat[key]) return; + seenCompat[key] = true; + uniqCompat.push(c); + }); + var currentMake = ''; + uniqCompat.forEach(function(c) { + if (c.make !== currentMake) { + currentMake = c.make; + html += '
' + esc(c.make) + '
'; + } + html += '
' + + esc(c.model) + ' ' + c.year + ' ' + esc(c.engine || '') + '
'; + }); + html += '
'; + } + + detailBody.innerHTML = html; + }); + } + function closeDetail() { detailPanel.classList.remove('is-open'); detailOverlay.classList.remove('is-visible'); @@ -1398,17 +1493,22 @@ } searchDropdown.innerHTML = data.data.map(function (r) { var isLocal = r.source === 'local_inventory' || (typeof r.id_part === 'string' && r.id_part.indexOf('inv:') === 0); + var isSupplier = r.source === 'supplier_catalog' || (typeof r.id_part === 'string' && r.id_part.indexOf('sc:') === 0); var stockLabel = r.local_stock > 0 ? 'Stock: ' + r.local_stock + '' : ''; - var localBadge = isLocal - ? 'Stock Local' - : ''; + var sourceBadge = ''; + if (isLocal) { + sourceBadge = 'Stock Local'; + } else if (isSupplier) { + sourceBadge = 'Cat. Proveedor'; + } var oemNum = isLocal ? (r.oem_part_number || r.part_number || '') : (r.oem_part_number || ''); - return '
' + + var cleanName = (r.name || '').replace(/\\n/g, ' '); + return '
' + '
' + - '
' + localBadge + esc(oemNum) + '
' + - '
' + esc(r.name) + '
' + + '
' + sourceBadge + esc(oemNum) + '
' + + '
' + esc(cleanName) + '
' + (r.vehicle_info ? '
' + esc(r.vehicle_info) + '
' : '') + '
' + stockLabel + @@ -1420,6 +1520,7 @@ el.addEventListener('click', function () { searchDropdown.classList.remove('is-visible'); var pid = this.dataset.partId; + var src = this.dataset.source || ''; if (typeof pid === 'string' && pid.indexOf('inv:') === 0) { var info = '💠 Stock Local\n\n' + 'Parte: ' + (this.dataset.pn || 'N/A') + '\n' + @@ -1429,6 +1530,10 @@ alert(info); return; } + if (src === 'supplier_catalog' || (typeof pid === 'string' && pid.indexOf('sc:') === 0)) { + openSupplierDetail(pid.replace('sc:', '')); + return; + } openPartDetail(parseInt(pid)); }); }); diff --git a/pos/static/js/dashboard-stats.js b/pos/static/js/dashboard-stats.js index f4092b1..a6a9778 100644 --- a/pos/static/js/dashboard-stats.js +++ b/pos/static/js/dashboard-stats.js @@ -8,6 +8,9 @@ const token = localStorage.getItem('pos_token') || ''; if (!token) return; + let hourlyChart = null; + let topProductsChart = null; + function headers() { return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }; } @@ -31,10 +34,11 @@ function renderHourlyChart(hourly) { const ctx = document.getElementById('hourlySalesChart'); if (!ctx) return; + if (hourlyChart) { hourlyChart.destroy(); hourlyChart = null; } const labels = hourly.map(function (h) { return h.hour + ':00'; }); const totals = hourly.map(function (h) { return h.total; }); - new Chart(ctx, { + hourlyChart = new Chart(ctx, { type: 'bar', data: { labels: labels, @@ -62,10 +66,38 @@ function renderTopProductsChart(topProducts) { const ctx = document.getElementById('topProductsChart'); if (!ctx) return; + if (topProductsChart) { topProductsChart.destroy(); topProductsChart = null; } + if (!topProducts || topProducts.length === 0) { + // No sales today — render a friendly empty-state mini chart so the canvas + // doesn't collapse or leave a blank hole. + topProductsChart = new Chart(ctx, { + type: 'doughnut', + data: { + labels: ['Sin ventas hoy'], + datasets: [{ + data: [1], + backgroundColor: ['rgba(136, 136, 136, 0.25)'], + borderWidth: 0, + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'right', + labels: { color: '#888', font: { size: 10 }, boxWidth: 10 } + } + } + } + }); + return; + } + const labels = topProducts.map(function (p) { return p.name.substring(0, 20); }); const revenues = topProducts.map(function (p) { return p.revenue; }); - new Chart(ctx, { + topProductsChart = new Chart(ctx, { type: 'doughnut', data: { labels: labels, diff --git a/pos/static/js/inventory.js b/pos/static/js/inventory.js index acaa3e0..9167154 100644 --- a/pos/static/js/inventory.js +++ b/pos/static/js/inventory.js @@ -287,8 +287,41 @@ // CREATE ITEM (createModal) // ===================================================================== + function loadCategories() { + var sel = document.getElementById('newCategory'); + if (!sel) return; + apiFetch(API + '/categories').then(function(data) { + if (!data || !data.categories) return; + sel.innerHTML = ''; + data.categories.forEach(function(c) { + sel.innerHTML += ''; + }); + }); + } + window.loadCategories = loadCategories; + + function onCategoryChange(categoryId) { + var subSel = document.getElementById('newSubcategory'); + if (!subSel) return; + if (!categoryId) { + subSel.innerHTML = ''; + subSel.disabled = true; + return; + } + apiFetch(API + '/categories/' + categoryId + '/subcategories').then(function(data) { + if (!data || !data.subcategories) return; + subSel.innerHTML = ''; + data.subcategories.forEach(function(s) { + subSel.innerHTML += ''; + }); + subSel.disabled = false; + }); + } + window.onCategoryChange = onCategoryChange; + function showCreateModal() { document.getElementById('createModal').classList.add('is-open'); + loadCategories(); // Attach AI classification on part number blur var pnInput = document.getElementById('newPartNumber'); if (pnInput && !pnInput._classifyBound) { @@ -334,6 +367,10 @@ function closeCreateModal() { document.getElementById('createModal').classList.remove('is-open'); document.getElementById('createResult').innerHTML = ''; + var catSel = document.getElementById('newCategory'); + var subSel = document.getElementById('newSubcategory'); + if (catSel) catSel.innerHTML = ''; + if (subSel) { subSel.innerHTML = ''; subSel.disabled = true; } } function createItem() { @@ -350,8 +387,20 @@ price_3: elPrice3 ? (parseFloat(elPrice3.value) || 0) : 0, min_stock: parseInt(document.getElementById('newMinStock').value) || 0, initial_stock: parseInt(document.getElementById('newInitialStock').value) || 0, - location: document.getElementById('newLocation').value.trim() + location: document.getElementById('newLocation').value.trim(), + sku_aliases: [] }; + var sku2 = document.getElementById('newSku2').value.trim(); + var sku3 = document.getElementById('newSku3').value.trim(); + var categoryId = document.getElementById('newCategory').value; + var subcategoryId = document.getElementById('newSubcategory').value; + if (sku2) data.sku_aliases.push({sku: sku2, label: 'Alternativo 1'}); + if (sku3) data.sku_aliases.push({sku: sku3, label: 'Alternativo 2'}); + if (subcategoryId) { + data.category_id = parseInt(subcategoryId); + } else if (categoryId) { + data.category_id = parseInt(categoryId); + } if (!data.part_number || !data.name) { document.getElementById('createResult').innerHTML = 'Numero de parte y nombre son obligatorios'; return; @@ -366,7 +415,7 @@ loadItems(currentPage); // Close modal, clear form, refresh badges closeCreateModal(); - ['newPartNumber','newName','newBrand','newBarcode','newCost','newPrice1','newMinStock','newInitialStock','newLocation'].forEach(function(id) { + ['newPartNumber','newName','newBrand','newBarcode','newSku2','newSku3','newCost','newPrice1','newMinStock','newInitialStock','newLocation'].forEach(function(id) { var el = document.getElementById(id); if (el) el.value = ''; }); @@ -377,6 +426,54 @@ }); } + function submitBulkImport() { + var fileInput = document.getElementById('bulkImportFile'); + var resultEl = document.getElementById('bulkImportResult'); + var mode = document.getElementById('bulkImportMode').value; + var strategy = document.getElementById('bulkImportStrategy').value; + if (!fileInput.files || !fileInput.files[0]) { + resultEl.style.display = 'block'; + resultEl.innerHTML = 'Selecciona un archivo CSV o Excel.'; + return; + } + var file = fileInput.files[0]; + var formData = new FormData(); + formData.append('file', file); + resultEl.style.display = 'block'; + resultEl.innerHTML = 'Importando...'; + fetch(API + '/items/bulk-import', { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + token, + 'X-Import-Mode': mode, + 'X-Import-Strategy': strategy + }, + body: formData + }).then(function(resp) { return resp.json(); }).then(function(data) { + if (data.error) { + resultEl.innerHTML = '' + esc(data.error) + ''; + return; + } + var html = '
Importacion completada: ' + data.created + ' producto(s) creado(s)'; + if (data.skipped > 0) html += ', ' + data.skipped + ' saltado(s)'; + html += '
'; + if (data.warnings && data.warnings.length) { + html += '
'; + html += 'Advertencias (' + data.warnings.length + '):
    '; + data.warnings.forEach(function(w) { + html += '
  • ' + esc(w) + '
  • '; + }); + html += '
'; + } + resultEl.innerHTML = html; + loadItems(currentPage); + if (window.loadInventoryStats) window.loadInventoryStats(); + }).catch(function(err) { + resultEl.innerHTML = 'Error de red: ' + esc(err.message) + ''; + }); + } + window.submitBulkImport = submitBulkImport; + // ===================================================================== // PURCHASE / ENTRADA (purchaseModal) // ===================================================================== @@ -1006,17 +1103,36 @@ var attrName = esc(attr.name || attr.id); var inputHtml = ''; if (attr.values && attr.values.length) { - inputHtml = '' + '' + attr.values.map(function(v) { return ''; }).join('') + - ''; + '' + + '' + + ''; } - html += '
' + inputHtml + '
'; + html += '
' + inputHtml + '
'; }); grid.innerHTML = html; }).catch(function() { grid.innerHTML = '

Error cargando atributos

'; }); }; + window.onMeliAttrSelectChange = function(attrId) { + var sel = document.getElementById('meliAttrSel-' + attrId); + var other = document.getElementById('meliAttrOther-' + attrId); + if (!sel || !other) return; + if (sel.value === '__other__') { + other.style.display = 'block'; + other.focus(); + } else { + other.style.display = 'none'; + other.value = ''; + } + }; + window.handleMeliCatKeydown = function(e) { if (!meliCatItems.length) return; if (e.key === 'ArrowDown') { @@ -1071,9 +1187,21 @@ if (stockEl) customData.stocks[id] = parseInt(stockEl.value); var attrs = []; meliCategoryAttrs.forEach(function(attr) { - var el = document.getElementById('meliAttr-' + attr.id); - if (el && el.value) { - attrs.push({ id: attr.id, value_name: el.value }); + var val = ''; + var sel = document.getElementById('meliAttrSel-' + attr.id); + if (sel) { + if (sel.value === '__other__') { + var otherEl = document.getElementById('meliAttrOther-' + attr.id); + val = otherEl ? otherEl.value : ''; + } else { + val = sel.value; + } + } else { + var el = document.getElementById('meliAttr-' + attr.id); + if (el) val = el.value; + } + if (val) { + attrs.push({ id: attr.id, value_name: val }); } }); if (attrs.length) customData.attributes[id] = attrs; @@ -1298,6 +1426,26 @@ var history = data.history || []; var html = ''; + // Tab styles + html += ''; + + // Tabs + html += '
'; + html += ''; + html += ''; + html += '
'; + + // Detail panel + html += '
'; + // Product image section html += '
'; if (data.image_url) { @@ -1330,12 +1478,19 @@ html += '
ID Inventario' + data.id + '
'; html += '
No. Parte' + esc(data.part_number) + '
'; html += '
Nombre' + esc(data.name) + '
'; - html += '
Marca' + esc(data.brand) + '
'; + html += '
Marca' + esc(data.brand || '-') + '
'; + html += '
Categoría' + esc(data.category_name || '-') + '
'; html += '
Codigo de Barras' + esc(data.barcode) + '
'; html += '
Ubicacion' + esc(data.location || '-') + '
'; html += '
Stock' + (data.stock || 0) + '
'; html += '
'; + // SKU Aliases section + html += '
SKU Alternativos
'; + html += '
'; + html += '

Cargando SKU alternativos...

'; + html += '
'; + // Prices html += '
'; html += '
Costo$' + fmt(data.cost) + '
'; @@ -1415,14 +1570,67 @@ el.innerHTML = html2; } - // Vehicle compatibility section - html += '
Vehiculos Compatibles
'; + // Close detail panel + html += '
'; + + // Compatibility panel + html += '
'; + + // Existing compatibilities html += '
'; html += '

Cargando compatibilidades...

'; html += '
'; - // Load vehicle compatibilities - (function loadCompat() { + // Manual add form + html += '
Agregar Manualmente
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += ''; + + // Auto-match button + var btnLabel = compatSource === 'qwen' ? 'Auto-Match con IA (QWEN)' : (compatSource === 'both' ? 'Auto-Match (TecDoc + IA)' : 'Auto-Match por TecDoc'); + html += '
'; + + html += '
'; + + // Load SKU aliases + (function loadSkuAliases() { + fetch('/pos/api/inventory/items/' + itemId + '/skus', { headers: { 'Authorization': 'Bearer ' + token } }) + .then(function(r) { return r.json(); }) + .then(function(d) { + var el = document.getElementById('skuAliasContent'); + if (!el) return; + var list = d.aliases || []; + var html2 = ''; + if (list.length > 0) { + html2 += ''; + list.forEach(function(a) { + html2 += ''; + html2 += ''; + }); + html2 += '
SKUEtiqueta
' + esc(a.sku) + '' + esc(a.label || '-') + '
'; + } else { + html2 += '

Sin SKU alternativos.

'; + } + html2 += '
'; + html2 += ''; + html2 += ''; + html2 += ''; + html2 += '
'; + el.innerHTML = html2; + }) + .catch(function() { + var el = document.getElementById('skuAliasContent'); + if (el) el.innerHTML = '

Error al cargar SKU alternativos.

'; + }); + })(); + + // Load vehicle compatibilities and makes + (function loadCompatPanel() { fetch('/pos/api/inventory/items/' + itemId + '/vehicles', { headers: { 'Authorization': 'Bearer ' + token } }) .then(function(r) { return r.json(); }) .then(function(d) { @@ -1441,15 +1649,28 @@ } else { html2 += '

Sin vehiculos vinculados.

'; } - var btnLabel = compatSource === 'qwen' ? 'Auto-Match con IA (QWEN)' : (compatSource === 'both' ? 'Auto-Match (TecDoc + IA)' : 'Auto-Match por TecDoc'); - var btnDesc = compatSource === 'qwen' ? 'Busca compatibilidad usando inteligencia artificial' : (compatSource === 'both' ? 'Busca en catalogo central y con IA' : 'Busca en catalogo central y vincula automaticamente'); - html2 += '
' + btnDesc + '
'; el.innerHTML = html2; }) .catch(function() { var el = document.getElementById('compatContent'); if (el) el.innerHTML = '

Error al cargar compatibilidades.

'; }); + + // Load makes + fetch('/pos/api/inventory/vehicles/makes', { headers: { 'Authorization': 'Bearer ' + token } }) + .then(function(r) { return r.json(); }) + .then(function(d) { + var sel = document.getElementById('manualMake'); + if (!sel) return; + var opts = ''; + (d.makes || []).forEach(function(m) { opts += ''; }); + sel.innerHTML = opts; + sel.disabled = false; + }) + .catch(function() { + var sel = document.getElementById('manualMake'); + if (sel) { sel.innerHTML = ''; } + }); })(); // Movement history @@ -1510,6 +1731,150 @@ }).catch(function() { alert('Error al quitar compatibilidad'); }); } + // Manual compatibility tab functions + window.switchCompatTab = function(tab, btn) { + document.querySelectorAll('.compat-tab-btn').forEach(function(b) { b.classList.remove('is-active'); }); + document.querySelectorAll('.compat-tab-panel').forEach(function(p) { p.classList.remove('is-active'); }); + btn.classList.add('is-active'); + document.getElementById('compatTab-' + tab).classList.add('is-active'); + }; + + window.onManualMakeChange = function(itemId) { + var sel = document.getElementById('manualMake'); + var modelSel = document.getElementById('manualModel'); + var yearSel = document.getElementById('manualYear'); + var engineSel = document.getElementById('manualEngine'); + if (!sel || !modelSel) return; + var opt = sel.options[sel.selectedIndex]; + var brandId = opt ? opt.getAttribute('data-id') : null; + modelSel.innerHTML = ''; + modelSel.disabled = true; + yearSel.innerHTML = ''; + yearSel.disabled = true; + engineSel.innerHTML = ''; + engineSel.disabled = true; + if (!brandId) { + modelSel.innerHTML = ''; + return; + } + fetch('/pos/api/inventory/vehicles/models?brand_id=' + brandId, { headers: { 'Authorization': 'Bearer ' + token } }) + .then(function(r) { return r.json(); }) + .then(function(d) { + var opts = ''; + (d.models || []).forEach(function(m) { opts += ''; }); + modelSel.innerHTML = opts; + modelSel.disabled = false; + }) + .catch(function() { modelSel.innerHTML = ''; }); + }; + + window.onManualModelChange = function(itemId) { + var modelSel = document.getElementById('manualModel'); + var yearSel = document.getElementById('manualYear'); + var engineSel = document.getElementById('manualEngine'); + if (!modelSel || !yearSel) return; + var opt = modelSel.options[modelSel.selectedIndex]; + var modelId = opt ? opt.getAttribute('data-id') : null; + yearSel.innerHTML = ''; + yearSel.disabled = true; + engineSel.innerHTML = ''; + engineSel.disabled = true; + if (!modelId) { + yearSel.innerHTML = ''; + return; + } + fetch('/pos/api/inventory/vehicles/years?model_id=' + modelId, { headers: { 'Authorization': 'Bearer ' + token } }) + .then(function(r) { return r.json(); }) + .then(function(d) { + var opts = ''; + (d.years || []).forEach(function(y) { opts += ''; }); + yearSel.innerHTML = opts; + yearSel.disabled = false; + }) + .catch(function() { yearSel.innerHTML = ''; }); + }; + + window.onManualYearChange = function(itemId) { + var modelSel = document.getElementById('manualModel'); + var yearSel = document.getElementById('manualYear'); + var engineSel = document.getElementById('manualEngine'); + if (!modelSel || !yearSel || !engineSel) return; + var mOpt = modelSel.options[modelSel.selectedIndex]; + var yOpt = yearSel.options[yearSel.selectedIndex]; + var modelId = mOpt ? mOpt.getAttribute('data-id') : null; + var yearId = yOpt ? yOpt.getAttribute('data-id') : null; + engineSel.innerHTML = ''; + engineSel.disabled = true; + if (!modelId || !yearId) { + engineSel.innerHTML = ''; + return; + } + fetch('/pos/api/inventory/vehicles/engines?model_id=' + modelId + '&year_id=' + yearId, { headers: { 'Authorization': 'Bearer ' + token } }) + .then(function(r) { return r.json(); }) + .then(function(d) { + var opts = ''; + (d.engines || []).forEach(function(e) { opts += ''; }); + engineSel.innerHTML = opts; + engineSel.disabled = false; + }) + .catch(function() { engineSel.innerHTML = ''; }); + }; + + window.submitManualCompat = function(itemId) { + var makeSel = document.getElementById('manualMake'); + var modelSel = document.getElementById('manualModel'); + var yearSel = document.getElementById('manualYear'); + var engineSel = document.getElementById('manualEngine'); + if (!makeSel || !modelSel || !yearSel) return; + var make = makeSel.value; + var model = modelSel.value; + var year = yearSel.value; + var engine = engineSel ? engineSel.value : ''; + var engineCode = engineSel && engineSel.selectedIndex > 0 ? (engineSel.options[engineSel.selectedIndex].getAttribute('data-code') || '') : ''; + if (!make || !model || !year) { + alert('Selecciona al menos marca, modelo y ano'); + return; + } + fetch('/pos/api/inventory/items/' + itemId + '/vehicles/manual', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, + body: JSON.stringify({ make: make, model: model, year: parseInt(year), engine: engine, engine_code: engineCode }) + }).then(function(r) { return r.json(); }) + .then(function(d) { + if (d.error) { alert(d.error); return; } + viewProductDetail(itemId); + }).catch(function() { alert('Error al agregar compatibilidad'); }); + }; + + // SKU alias actions + window.addSkuAlias = function(itemId) { + var skuEl = document.getElementById('newAliasSku-' + itemId); + var labelEl = document.getElementById('newAliasLabel-' + itemId); + var sku = skuEl ? skuEl.value.trim() : ''; + var label = labelEl ? labelEl.value.trim() : ''; + if (!sku) { alert('Ingresa un SKU'); return; } + fetch('/pos/api/inventory/items/' + itemId + '/skus', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, + body: JSON.stringify({ sku: sku, label: label }) + }).then(function(r) { return r.json(); }) + .then(function(d) { + if (d.error) { alert(d.error); return; } + viewProductDetail(itemId); + }).catch(function() { alert('Error al agregar SKU'); }); + }; + + window.removeSkuAlias = function(itemId, aliasId) { + if (!confirm('Eliminar este SKU alternativo?')) return; + fetch('/pos/api/inventory/items/' + itemId + '/skus/' + aliasId, { + method: 'DELETE', + headers: { 'Authorization': 'Bearer ' + token } + }).then(function(r) { return r.json(); }) + .then(function() { + viewProductDetail(itemId); + }).catch(function() { alert('Error al eliminar SKU'); }); + }; + // ===================================================================== // EXPOSE GLOBALS (for onclick handlers in HTML) // ===================================================================== diff --git a/pos/static/js/marketplace_external.js b/pos/static/js/marketplace_external.js index 4f09022..7263e53 100644 --- a/pos/static/js/marketplace_external.js +++ b/pos/static/js/marketplace_external.js @@ -27,6 +27,7 @@ }); if (tab === 'listings') loadListings(); if (tab === 'orders') loadOrders(); + if (tab === 'questions') loadQuestions(); }; function closeModal(id) { @@ -81,7 +82,7 @@ localStorage.setItem('meli_shipping', shipping); var redirectUri = window.location.origin + '/pos/marketplace-external/callback'; - var authUrl = 'https://auth.mercadolibre.com.mx/authorization?response_type=code&client_id=' + encodeURIComponent(clientId) + '&redirect_uri=' + encodeURIComponent(redirectUri); + var authUrl = 'https://auth.mercadolibre.com.mx/authorization?response_type=code&client_id=' + encodeURIComponent(clientId) + '&redirect_uri=' + encodeURIComponent(redirectUri) + '&scope=read+write+offline_access'; window.location.href = authUrl; }; @@ -148,16 +149,18 @@ var statusClass = 'meli-status--' + (l.external_status || 'pending'); return '
' + '
' - + '
' + escapeHtml(l.title || l.inventory_name || 'Sin título') + '
' + + '' + escapeHtml(l.title || l.inventory_name || 'Sin título') + ' ↗' + '' + (l.external_status || '—') + '' + '
' + '
' - + 'SKU: ' + escapeHtml(l.part_number || '—') + ' · ID ML: ' + escapeHtml(l.external_item_id || '—') + + 'SKU: ' + escapeHtml(l.part_number || '—') + ' · ID ML: ' + escapeHtml(l.external_item_id || '—') + '' + '
' + '
' + '' + (l.external_status === 'active' ? '' : '') - + '' + + (l.external_status === 'closed' || !l.is_active + ? '' + : '') + '
' + '
'; }).join(''); @@ -202,6 +205,14 @@ } catch (e) { alert('Error: ' + e.message); } }; + window.deleteListingPermanently = async function(id) { + if (!confirm('¿Eliminar permanentemente esta publicación del listado local? Esta acción no se puede deshacer.')) return; + try { + var res = await fetch(API + '/listings/' + id + '/permanent', { method: 'DELETE', headers: headers() }); + if (res.ok) { loadListings(); } else { alert('Error al eliminar'); } + } catch (e) { alert('Error: ' + e.message); } + }; + // ─── Orders ──────────────────────────────────────────────────────────── var ordersData = []; @@ -451,11 +462,144 @@ container.innerHTML = html; } + // ─── Questions ───────────────────────────────────────────────────────── + + var questionsData = []; + + window.loadQuestions = async function() { + var container = document.getElementById('questionsContainer'); + container.innerHTML = '
' + Array(6).fill('
').join('') + '
'; + try { + var res = await fetch(API + '/questions', { headers: headers() }); + if (!res.ok) throw new Error('Failed to load questions'); + var data = await res.json(); + questionsData = data.items || []; + renderQuestions(); + } catch (e) { + container.innerHTML = renderEmptyState({ + icon: '', + title: 'Sin preguntas', + subtitle: 'No hay preguntas de compradores pendientes. Sincroniza con MercadoLibre para obtenerlas.', + action: '' + }); + } + }; + + function renderQuestions() { + var container = document.getElementById('questionsContainer'); + var statusFilter = document.getElementById('questionStatusFilter').value; + var search = document.getElementById('questionSearch').value.toLowerCase(); + + var filtered = questionsData.filter(function(q) { + if (statusFilter && q.status !== statusFilter) return false; + if (search && !((q.question_text || '').toLowerCase().includes(search)) && !((q.listing_title || '').toLowerCase().includes(search))) return false; + return true; + }); + + // Stats bar + var unanswered = questionsData.filter(function(q) { return q.status === 'unanswered'; }).length; + var answered = questionsData.filter(function(q) { return q.status === 'answered'; }).length; + var total = questionsData.length; + var statsHtml = '
' + + '
' + total + '
Total preguntas
' + + '
' + unanswered + '
Sin responder
' + + '
' + answered + '
Respondidas
' + + '
'; + document.getElementById('questionsStatsBar').innerHTML = statsHtml; + + if (!filtered.length) { + container.innerHTML = renderEmptyState({ + icon: '', + title: 'Sin preguntas', + subtitle: statusFilter ? 'No hay preguntas con el filtro seleccionado.' : 'No hay preguntas sincronizadas.', + action: '' + }); + return; + } + + container.innerHTML = filtered.map(function(q) { + var statusClass = 'meli-status--' + (q.status || 'pending'); + var statusLabel = q.status === 'unanswered' ? 'Sin responder' : (q.status === 'answered' ? 'Respondida' : (q.status || '—')); + var answerHtml = ''; + if (q.status === 'unanswered') { + answerHtml = '
' + + '' + + '' + + '
'; + } else if (q.answer_text) { + answerHtml = '
' + + 'Respuesta: ' + escapeHtml(q.answer_text) + + '
'; + } + return '
' + + '
' + + '
' + escapeHtml(q.listing_title || 'Artículo sin título') + '
' + + '' + statusLabel + '' + + '
' + + '
' + + 'Comprador: ' + escapeHtml(q.buyer_nickname || '—') + ' · ' + (q.question_date ? new Date(q.question_date).toLocaleString('es-MX') : '—') + + '
' + + '
' + + 'Pregunta: ' + escapeHtml(q.question_text) + + '
' + + answerHtml + + '
'; + }).join(''); + } + + window.filterQuestions = renderQuestions; + + window.syncQuestions = async function() { + var btn = document.querySelector('#panel-questions .btn--primary'); + if (btn) { btn.disabled = true; btn.textContent = 'Sincronizando...'; } + try { + var res = await fetch(API + '/questions/sync', { method: 'POST', headers: headers() }); + var data = await res.json(); + if (res.ok) { + showToast('Sincronizadas ' + (data.synced || 0) + ' preguntas', 'ok', { title: 'Sincronización' }); + loadQuestions(); + } else { + showToast(data.error || 'Error al sincronizar', 'error', { title: 'Error' }); + } + } catch (e) { + showToast(e.message, 'error', { title: 'Error de red' }); + } finally { + if (btn) { btn.disabled = false; btn.textContent = '🔄 Actualizar'; } + } + }; + + window.submitAnswer = async function(questionId) { + var textarea = document.getElementById('qAnswer-' + questionId); + if (!textarea) return; + var text = textarea.value.trim(); + if (!text) { + showToast('Escribe una respuesta antes de enviar', 'error', { title: 'Respuesta vacía' }); + return; + } + try { + var res = await fetch(API + '/questions/' + questionId + '/answer', { + method: 'POST', + headers: headers(), + body: JSON.stringify({ text: text }) + }); + var data = await res.json(); + if (res.ok) { + showToast('Respuesta enviada correctamente', 'ok', { title: 'Pregunta respondida' }); + loadQuestions(); + } else { + showToast(data.error || 'Error al enviar respuesta', 'error', { title: 'Error' }); + } + } catch (e) { + showToast(e.message, 'error', { title: 'Error de red' }); + } + }; + // Register Cmd+K items if (typeof registerCmdKItem === 'function') { registerCmdKItem({ group: 'MercadoLibre', label: 'Configuración ML', href: '/pos/marketplace-external', icon: '⚙️' }); registerCmdKItem({ group: 'MercadoLibre', label: 'Publicaciones ML', href: '/pos/marketplace-external#listings', icon: '📦' }); registerCmdKItem({ group: 'MercadoLibre', label: 'Órdenes ML', href: '/pos/marketplace-external#orders', icon: '🛒' }); + registerCmdKItem({ group: 'MercadoLibre', label: 'Preguntas ML', href: '/pos/marketplace-external#questions', icon: '❓' }); } document.addEventListener('DOMContentLoaded', function() { diff --git a/pos/static/js/supplier_catalog.js b/pos/static/js/supplier_catalog.js new file mode 100644 index 0000000..bfbc690 --- /dev/null +++ b/pos/static/js/supplier_catalog.js @@ -0,0 +1,299 @@ +(function() { + 'use strict'; + + const API = '/pos/api/supplier-catalog'; + const VEHICLE_API = '/pos/api/inventory/vehicles'; + const token = localStorage.getItem('pos_token') || ''; + + let state = { + q: '', + category: '', + make: '', + model: '', + year: '', + engine: '', + myeId: null, + page: 1, + perPage: 30, + totalPages: 1, + categories: [], + items: [] + }; + + function headers() { + return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }; + } + + let scAbort = null; + let scSeq = 0; + + async function apiFetch(url) { + if (scAbort) { + scAbort.abort(); + scAbort = null; + } + const ctrl = new AbortController(); + scAbort = ctrl; + try { + const resp = await fetch(url, { headers: headers(), signal: ctrl.signal }); + if (resp.status === 401) { window.location.href = '/pos/login'; return null; } + if (!resp.ok) { console.error('API error', url, resp.status); return null; } + return resp.json(); + } catch (e) { + if (e.name === 'AbortError') return null; + console.error('API error', url, e); + return null; + } + } + + async function apiFetchSeq(url) { + const mySeq = ++scSeq; + const data = await apiFetch(url); + if (!data || scSeq !== mySeq) return null; + return data; + } + + // ─── Categories ───────────────────────────────────────────── + async function loadCategories() { + const data = await apiFetch(API + '/categories'); + if (!data) return; + state.categories = data.categories || []; + renderCategories(); + } + + function renderCategories() { + const el = document.getElementById('categoriesGrid'); + if (!el) return; + let html = '
' + + '
Todas
' + state.categories.reduce((a,c)=>a+c.count,0) + ' items
'; + state.categories.forEach(function(c) { + html += '
' + + '
' + escapeHtml(c.name) + '
' + c.count + ' items
'; + }); + el.innerHTML = html; + } + + window.selectCategory = function(name) { + state.category = name; + state.page = 1; + renderCategories(); + doSearch(); + }; + + // ─── Vehicle filters ──────────────────────────────────────── + async function loadMakes() { + const data = await apiFetch(VEHICLE_API + '/makes'); + if (!data) return; + const sel = document.getElementById('filterMake'); + sel.innerHTML = ''; + (data.data || []).forEach(function(m) { + sel.innerHTML += ''; + }); + } + + window.onMakeChange = async function() { + const sel = document.getElementById('filterMake'); + state.make = sel.value; + state.model = ''; state.year = ''; state.engine = ''; state.myeId = null; + document.getElementById('filterModel').disabled = true; + document.getElementById('filterYear').disabled = true; + document.getElementById('filterEngine').disabled = true; + if (!state.make) { doSearch(); return; } + + const makes = await apiFetchSeq(VEHICLE_API + '/makes'); + if (!makes) return; + const brand = (makes.data || []).find(function(m) { return m.name_brand === state.make; }); + if (!brand) { doSearch(); return; } + + const models = await apiFetchSeq(VEHICLE_API + '/models?brand_id=' + brand.id_brand); + if (!models) return; + const msel = document.getElementById('filterModel'); + msel.innerHTML = ''; + (models.data || []).forEach(function(m) { + msel.innerHTML += ''; + }); + msel.disabled = false; + doSearch(); + }; + + window.onModelChange = async function() { + const sel = document.getElementById('filterModel'); + const modelId = sel.value; + state.model = modelId ? sel.options[sel.selectedIndex].text : ''; + state.year = ''; state.engine = ''; state.myeId = null; + document.getElementById('filterYear').disabled = true; + document.getElementById('filterEngine').disabled = true; + if (!modelId) { doSearch(); return; } + + const years = await apiFetchSeq(VEHICLE_API + '/years?model_id=' + modelId); + if (!years) return; + const ysel = document.getElementById('filterYear'); + ysel.innerHTML = ''; + (years.data || []).forEach(function(y) { + ysel.innerHTML += ''; + }); + ysel.disabled = false; + doSearch(); + }; + + window.onYearChange = async function() { + const sel = document.getElementById('filterYear'); + const yearId = sel.value; + const modelId = document.getElementById('filterModel').value; + state.year = yearId ? sel.options[sel.selectedIndex].text : ''; + state.engine = ''; state.myeId = null; + document.getElementById('filterEngine').disabled = true; + if (!yearId || !modelId) { doSearch(); return; } + + const engines = await apiFetchSeq(VEHICLE_API + '/engines?model_id=' + modelId + '&year_id=' + yearId); + if (!engines) return; + const esel = document.getElementById('filterEngine'); + esel.innerHTML = ''; + (engines.data || []).forEach(function(e) { + const label = escapeHtml(e.name_engine) + (e.trim_level ? ' (' + escapeHtml(e.trim_level) + ')' : ''); + esel.innerHTML += ''; + }); + esel.disabled = false; + doSearch(); + }; + + // ─── Search ───────────────────────────────────────────────── + window.doSearch = async function() { + state.q = document.getElementById('searchInput').value.trim(); + const engineSel = document.getElementById('filterEngine'); + state.myeId = engineSel.value || null; + + let url = API + '/search?page=' + state.page + '&per_page=' + state.perPage; + if (state.q) url += '&q=' + encodeURIComponent(state.q); + if (state.category) url += '&category=' + encodeURIComponent(state.category); + if (state.myeId) { + url += '&mye_id=' + state.myeId; + } else { + if (state.make) url += '&make=' + encodeURIComponent(state.make); + if (state.model) url += '&model=' + encodeURIComponent(state.model); + if (state.year) url += '&year=' + encodeURIComponent(state.year); + } + + const data = await apiFetch(url); + if (!data) return; + state.items = data.data || []; + state.totalPages = (data.pagination || {}).total_pages || 1; + renderItems(); + renderPagination(); + }; + + window.clearFilters = function() { + document.getElementById('searchInput').value = ''; + document.getElementById('filterMake').value = ''; + document.getElementById('filterModel').innerHTML = ''; document.getElementById('filterModel').disabled = true; + document.getElementById('filterYear').innerHTML = ''; document.getElementById('filterYear').disabled = true; + document.getElementById('filterEngine').innerHTML = ''; document.getElementById('filterEngine').disabled = true; + state.q = ''; state.category = ''; state.make = ''; state.model = ''; state.year = ''; state.engine = ''; state.myeId = null; state.page = 1; + renderCategories(); + doSearch(); + }; + + // ─── Render results ───────────────────────────────────────── + function renderItems() { + const el = document.getElementById('partsGrid'); + if (!el) return; + if (!state.items.length) { + el.innerHTML = '
🔍

Sin resultados

Intenta con otros filtros o terminos de busqueda.

'; + return; + } + el.innerHTML = state.items.map(function(it) { + return '
' + + '
' + escapeHtml(it.sku) + '
' + + '
' + escapeHtml(it.name) + '
' + + '
' + + '' + escapeHtml(it.category || 'SIN CATEGORIA') + '' + + ' ' + escapeHtml(it.supplier_name) + '' + + '
' + + '
'; + }).join(''); + } + + function renderPagination() { + const el = document.getElementById('pagination'); + if (!el) return; + if (state.totalPages <= 1) { el.innerHTML = ''; return; } + let html = ''; + html += 'Pagina ' + state.page + ' de ' + state.totalPages + ''; + html += ''; + el.innerHTML = html; + } + + window.goPage = function(p) { + state.page = p; + doSearch(); + }; + + // ─── Detail modal ─────────────────────────────────────────── + window.openDetail = async function(id) { + const data = await apiFetch(API + '/items/' + id); + if (!data) return; + document.getElementById('modalTitle').textContent = escapeHtml(data.sku); + let html = ''; + html += '
' + escapeHtml(data.name) + '
'; + html += '

Informacion

' + + '

Proveedor: ' + escapeHtml(data.supplier_name) + '
Categoria: ' + escapeHtml(data.category || 'N/A') + '

'; + + if (data.interchanges && data.interchanges.length) { + html += '

Intercambios

' + + data.interchanges.map(function(ix) { + return '' + escapeHtml(ix.brand) + ' — ' + escapeHtml(ix.part_number) + ''; + }).join('') + '
'; + } + + if (data.compatibilities && data.compatibilities.length) { + var seenCompat = {}; + var uniqCompat = data.compatibilities.filter(function(c) { + var key = (c.make || '') + '|' + (c.model || '') + '|' + (c.year || '') + '|' + (c.engine || ''); + if (seenCompat[key]) return false; + seenCompat[key] = true; + return true; + }); + html += '

Vehiculos compatibles (' + uniqCompat.length + ')

' + + '
' + + uniqCompat.slice(0, 50).map(function(c) { + return '
' + + '' + escapeHtml(c.make || '') + ' ' + escapeHtml(c.model || '') + '
' + + (c.year || '') + ' ' + escapeHtml(c.engine || '') + + '
'; + }).join('') + + (uniqCompat.length > 50 ? '
... y ' + (uniqCompat.length - 50) + ' mas
' : '') + + '
'; + } + + document.getElementById('modalBody').innerHTML = html; + document.getElementById('detailModal').classList.add('open'); + }; + + window.closeModal = function() { + document.getElementById('detailModal').classList.remove('open'); + }; + + // ─── Utils ────────────────────────────────────────────────── + function escapeHtml(s) { + if (s == null) return ''; + return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + } + + // ─── Init ─────────────────────────────────────────────────── + function init() { + if (!token) { window.location.href = '/pos/login'; return; } + loadCategories(); + loadMakes(); + doSearch().then(function() { + var params = new URLSearchParams(window.location.search); + var id = params.get('id'); + if (id) { openDetail(parseInt(id)); } + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/pos/static/pwa/sw.js b/pos/static/pwa/sw.js index b89aa78..85669f2 100644 --- a/pos/static/pwa/sw.js +++ b/pos/static/pwa/sw.js @@ -1,27 +1,27 @@ // /home/Autopartes/pos/static/pwa/sw.js -// Nexus POS — Service Worker v9 +// Nexus POS — Service Worker v17 // Self-contained vanilla JS. No external imports. +// +// Bump CACHE_NAME whenever static assets change significantly. +// The fetch handler normalizes static asset URLs (strips ?v= query strings) +// so templates can use cache-busting query params freely. -const CACHE_NAME = 'nexus-pos-v11'; +const CACHE_NAME = 'nexus-pos-v17'; const APP_SHELL = [ '/pos/static/css/tokens.css', '/pos/static/css/common.css', + '/pos/static/css/pos-ui.css', '/pos/static/js/app-init.js', '/pos/static/js/sidebar.js', - '/pos/static/js/login.js', - '/pos/static/js/pos.js', - '/pos/static/js/catalog.js', - '/pos/static/js/inventory.js', - '/pos/static/js/customers.js', - '/pos/static/js/invoicing.js', - '/pos/static/js/accounting.js', - '/pos/static/js/dashboard.js', - '/pos/static/js/config.js', - '/pos/static/js/reports.js', '/pos/static/js/offline-banner.js', '/pos/static/js/sync-engine.js', - '/pos/static/js/brand-catalog.js', + '/pos/static/js/i18n.js', + '/pos/static/js/kiosk.js', + '/pos/static/js/splash-loader.js', + '/pos/static/js/pos-utils.js', + '/pos/static/js/pwa-install.js', + '/pos/static/js/chat.js', '/pos/static/pwa/manifest.json', '/pos/static/pwa/icon-192.png', '/pos/static/pwa/icon-512.png' @@ -103,6 +103,12 @@ self.addEventListener('activate', function (event) { ); }).then(function () { return self.clients.claim(); + }).then(function () { + return self.clients.matchAll({ type: 'window' }).then(function (clients) { + clients.forEach(function (client) { + client.postMessage({ type: 'SW_UPDATED', cacheName: CACHE_NAME }); + }); + }); }) ); }); @@ -117,6 +123,13 @@ self.addEventListener('fetch', function (event) { return; } + // Normalize cache key for static assets (strip query strings) so + // catalog.js?v=5 and catalog.js?v=6 share the same cache entry. + var cacheKey = req; + if (/\.(js|css|png|jpg|jpeg|webp|svg|gif|ico|woff|woff2|ttf|eot|json)$/.test(url.pathname)) { + cacheKey = new Request(url.pathname); + } + // Never cache auth endpoints if (url.pathname.indexOf('/pos/api/auth/') !== -1) { return; @@ -172,16 +185,17 @@ self.addEventListener('fetch', function (event) { } // Everything else (JS, CSS, images) -> cache-first - event.respondWith(cacheFirst(req)); + event.respondWith(cacheFirst(req, cacheKey)); }); -function cacheFirst(request) { - return caches.match(request).then(function (cached) { +function cacheFirst(request, cacheKey) { + cacheKey = cacheKey || request; + return caches.match(cacheKey).then(function (cached) { if (cached) { fetch(request).then(function (response) { if (response && response.status === 200 && request.method === 'GET') { caches.open(CACHE_NAME).then(function (cache) { - cache.put(request, response); + cache.put(cacheKey, response); }); } }).catch(function () {}); @@ -191,7 +205,7 @@ function cacheFirst(request) { if (response && response.status === 200 && request.method === 'GET') { var clone = response.clone(); caches.open(CACHE_NAME).then(function (cache) { - cache.put(request, clone); + cache.put(cacheKey, clone); }); } return response; diff --git a/pos/tasks.py b/pos/tasks.py index 3193da1..28d4ecc 100644 --- a/pos/tasks.py +++ b/pos/tasks.py @@ -218,6 +218,17 @@ def process_meli_webhook_task(self, tenant_id: int, topic: str, resource: str): # Re-use fetch_and_save_orders by passing the order directly # For simplicity, trigger a full sync for recent orders return meli_svc.fetch_and_save_orders(conn) + + if topic.startswith("questions"): + # Fetch single question and upsert locally + if resource: + question_id = resource.split("/")[-1] + try: + meli_svc.fetch_question_from_ml(conn, question_id) + return {'ok': True, 'topic': topic, 'question_id': question_id} + except Exception as qe: + return {'ok': False, 'topic': topic, 'error': str(qe)} + return {'ok': True, 'topic': topic} except Exception as e: return {'error': str(e)} @@ -270,7 +281,7 @@ def sync_vehicle_compatibility_task(self, tenant_id, item_id, part_number, name, @celery.task(bind=True) -def publish_meli_items_task(self, tenant_id: int, inventory_ids: list, category_id: str, listing_type: str = "gold_special", shipping_mode: str = "me2", custom_data: dict = None): +def publish_meli_items_task(self, tenant_id: int, inventory_ids: list, category_id: str, listing_type: str = "gold_special", shipping_mode: str = "me2", custom_data: dict = None, base_url: str = None): """Publish inventory items to MercadoLibre asynchronously.""" from services import marketplace_external_service as meli_svc from tenant_db import get_tenant_conn @@ -284,6 +295,7 @@ def publish_meli_items_task(self, tenant_id: int, inventory_ids: list, category_ listing_type_id=listing_type, shipping_mode=shipping_mode, custom_data=custom_data or {}, + base_url=base_url, ) return result except Exception as e: diff --git a/pos/templates/catalog.html b/pos/templates/catalog.html index a6244cf..a73d465 100644 --- a/pos/templates/catalog.html +++ b/pos/templates/catalog.html @@ -68,6 +68,10 @@ Catalogo + + + Cat. Proveedores + @@ -291,7 +295,7 @@ - + diff --git a/pos/templates/dashboard.html b/pos/templates/dashboard.html index 9f8c57f..5697609 100644 --- a/pos/templates/dashboard.html +++ b/pos/templates/dashboard.html @@ -15,7 +15,7 @@ - + @@ -368,7 +368,7 @@ Ventas por Hora
- +
@@ -376,7 +376,7 @@ Top Productos (Hoy)
- +
@@ -494,8 +494,8 @@ - - + + diff --git a/pos/templates/inventory.html b/pos/templates/inventory.html index 2e1dd67..f05b4bb 100644 --- a/pos/templates/inventory.html +++ b/pos/templates/inventory.html @@ -183,6 +183,10 @@

Inventario

+
+ +
+
+
+

Importar Productos Masivamente

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ Columnas esperadas: + sku, name, brand, price, stock, cost, location, description, category, make, model, year, engine, engine_code + También se aceptan sinónimos en español: numero_de_parte, nombre, marca, precio, cantidad, costo, ubicacion, categoria, fabricante, modelo, anio, motor, codigo_motor +
+ +
+ +
+
+ @@ -262,6 +265,27 @@ + +
+
+ + +
+ + +
+
+
+
+
diff --git a/pos/templates/supplier_catalog.html b/pos/templates/supplier_catalog.html new file mode 100644 index 0000000..b0ff853 --- /dev/null +++ b/pos/templates/supplier_catalog.html @@ -0,0 +1,135 @@ + + + + + + + Catalogo de Proveedores — Nexus Autoparts POS + + + + + + + + + + + + + +
+ Tema: + + +
+ + + +
+ + +
+
+
+ +
+
Catalogo de Proveedores
+
Busca por vehiculo, SKU o nombre de parte
+
+
+
+ +
+
+ +
+ +
+ + + + +
+ +
+ +
+
+ +
+
+
+
+ + +
+
+
+

Detalle

+ +
+
+
+
+ + + + + + + diff --git a/pos/tests/test_bulk_import.py b/pos/tests/test_bulk_import.py new file mode 100644 index 0000000..c997cef --- /dev/null +++ b/pos/tests/test_bulk_import.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Test bulk import endpoint — CSV parsing, column normalisation, upsert logic.""" + +import os +import sys +import io +import csv + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +os.environ.setdefault('MASTER_DB_URL', 'postgresql://postgres@/nexus_autopartes') +os.environ.setdefault('TENANT_DB_URL_TEMPLATE', 'postgresql://postgres@/{db_name}') +os.environ.setdefault('POS_JWT_SECRET', 'test-secret-12345678901234567890123456789012') + +from blueprints.inventory_bp import _to_decimal, _to_int + +RED = '\033[91m' +GREEN = '\033[92m' +YELLOW = '\033[93m' +RESET = '\033[0m' + + +def print_result(name, passed, detail=""): + status = f"{GREEN}PASS{RESET}" if passed else f"{RED}FAIL{RESET}" + print(f" [{status}] {name}" + (f" — {detail}" if detail else "")) + + +def test_to_decimal(): + assert _to_decimal("10.5") == 10.5 + assert _to_decimal("1,000.50") == 1000.50 + assert _to_decimal("") == 0 + assert _to_decimal(None, 5) == 5 + assert _to_decimal("abc", 99) == 99 + print_result("_to_decimal parsing", True) + + +def test_to_int(): + assert _to_int("42") == 42 + assert _to_int("1,000") == 1000 + assert _to_int("") == 0 + assert _to_int(None, 7) == 7 + assert _to_int("abc", 99) == 99 + print_result("_to_int parsing", True) + + +def test_csv_column_normalisation(): + """Simulate the column normalisation done in bulk_import_items.""" + raw_rows = [ + {"SKU": "ABC123", "Nombre": "Filtro de aceite", "Marca": "Bosch", "Precio": "150", "Cantidad": "10"}, + {"sku": "DEF456", "name": "Pastillas de freno", "brand": "TRW", "price": "450.50", "stock": "5"}, + ] + + # Normalise keys + for r in raw_rows: + normalised = {} + for k, v in r.items(): + nk = str(k).strip().lower().replace(' ', '_') + normalised[nk] = v + r.clear() + r.update(normalised) + + col_map = { + 'sku': 'part_number', 'numero_de_parte': 'part_number', 'parte': 'part_number', + 'nombre': 'name', 'producto': 'name', 'descripcion': 'name', + 'marca': 'brand', 'precio': 'price', 'costo': 'cost', + 'cantidad': 'stock', 'existencia': 'stock', 'inventario': 'stock', + 'ubicacion': 'location', 'categoria': 'category', + 'fabricante': 'make', 'vehiculo': 'make', 'auto': 'make', + 'modelo': 'model', 'anio': 'year', 'ano': 'year', + 'motor': 'engine', 'codigo_motor': 'engine_code', + } + for r in raw_rows: + for old_k, new_k in col_map.items(): + if old_k in r and new_k not in r: + r[new_k] = r.pop(old_k) + + assert raw_rows[0]['part_number'] == 'ABC123' + assert raw_rows[0]['name'] == 'Filtro de aceite' + assert raw_rows[0]['stock'] == '10' + assert raw_rows[1]['part_number'] == 'DEF456' + assert raw_rows[1]['price'] == '450.50' + print_result("CSV column normalisation", True) + + +def test_csv_dict_reader(): + """Verify csv.DictReader produces the expected structure.""" + csv_text = "sku,name,brand,price,stock\nABC123,Filtro,Bosch,150,10\nDEF456,Pastillas,TRW,450,5" + f = io.StringIO(csv_text) + reader = csv.DictReader(f) + rows = list(reader) + assert len(rows) == 2 + assert rows[0]['sku'] == 'ABC123' + assert rows[1]['price'] == '450' + print_result("csv.DictReader parsing", True) + + +def run_all(): + print("\nBulk Import Tests") + print("=" * 40) + test_to_decimal() + test_to_int() + test_csv_column_normalisation() + test_csv_dict_reader() + print("=" * 40) + print("Done.\n") + + +if __name__ == '__main__': + run_all() diff --git a/scripts/clean_fake_models.py b/scripts/clean_fake_models.py new file mode 100755 index 0000000..2da05d6 --- /dev/null +++ b/scripts/clean_fake_models.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +""" +Clean fake/corrupted models from master DB caused by supplier catalog imports. +Handles: +- Models ending in ' INT.' -> map to base model +- Empty-name models -> delete or merge +- Year-range models (09-15, etc.) -> delete +- Torque-spec models ((60 Nm+90°), etc.) -> delete +""" + +import sys +import re +import psycopg2 +from collections import defaultdict + +MASTER_DSN = "host=localhost dbname=nexus_autoparts user=postgres password=1123517" + + +def get_connection(): + return psycopg2.connect(MASTER_DSN) + + +def delete_model_and_myes(conn, model_id, dry_run=True): + """Delete all MYEs for a model, then the model itself.""" + cur = conn.cursor() + cur.execute("SELECT id_mye FROM model_year_engine WHERE model_id = %s", (model_id,)) + mye_ids = [r[0] for r in cur.fetchall()] + if mye_ids: + print(f" Would delete {len(mye_ids)} MYEs for model {model_id}") + if not dry_run: + # supplier_catalog_compat has no FK, just update to null + cur.execute("UPDATE supplier_catalog_compat SET model_year_engine_id = NULL WHERE model_year_engine_id = ANY(%s)", (mye_ids,)) + cur.execute("DELETE FROM vin_cache WHERE model_year_engine_id = ANY(%s)", (mye_ids,)) + cur.execute("DELETE FROM model_year_engine WHERE id_mye = ANY(%s)", (mye_ids,)) + else: + print(f" No MYEs for model {model_id}") + print(f" Would delete model {model_id}") + if not dry_run: + cur.execute("DELETE FROM models WHERE id_model = %s", (model_id,)) + cur.close() + + +def merge_int_models(conn, dry_run=True): + """Merge 'X INT.' models into their base equivalents.""" + cur = conn.cursor() + cur.execute(""" + SELECT m.id_model, m.name_model, m.brand_id, b.name_brand + FROM models m + JOIN brands b ON b.id_brand = m.brand_id + WHERE m.name_model LIKE '%INT.' + ORDER BY m.brand_id, m.name_model + """) + int_models = cur.fetchall() + print(f"Found {len(int_models)} INT. models to process") + + merged = 0 + renamed = 0 + skipped = 0 + + for model_id, name_model, brand_id, brand_name in int_models: + base_name = name_model[:-5] # Remove ' INT.' + # Find base model (case-insensitive) + cur.execute(""" + SELECT id_model, name_model FROM models + WHERE brand_id = %s AND LOWER(name_model) = LOWER(%s) + LIMIT 1 + """, (brand_id, base_name)) + base = cur.fetchone() + + if base: + base_id, base_name_exact = base + print(f"[{brand_name}] {name_model} -> {base_name_exact} (id={base_id})") + else: + # No base exists: rename this model to base name + print(f"[{brand_name}] {name_model} -> RENAME to '{base_name}' (no base found)") + if not dry_run: + cur.execute("UPDATE models SET name_model = %s WHERE id_model = %s", (base_name, model_id)) + conn.commit() + renamed += 1 + continue + + # Migrate MYEs from INT model to base model + cur.execute(""" + SELECT id_mye, year_id, engine_id FROM model_year_engine + WHERE model_id = %s + """, (model_id,)) + myes = cur.fetchall() + + mye_migrated = 0 + mye_deleted = 0 + for mye_id, year_id, engine_id in myes: + # Find equivalent MYE in base model + cur.execute(""" + SELECT id_mye FROM model_year_engine + WHERE model_id = %s AND year_id = %s + AND (engine_id = %s OR (engine_id IS NULL AND %s IS NULL)) + """, (base_id, year_id, engine_id, engine_id)) + base_mye = cur.fetchone() + + if base_mye: + base_mye_id = base_mye[0] + # Update supplier_catalog_compat + cur.execute(""" + UPDATE supplier_catalog_compat + SET model_year_engine_id = %s + WHERE model_year_engine_id = %s + """, (base_mye_id, mye_id)) + # Delete the old MYE + cur.execute("DELETE FROM vin_cache WHERE model_year_engine_id = %s", (mye_id,)) + cur.execute("DELETE FROM model_year_engine WHERE id_mye = %s", (mye_id,)) + mye_migrated += 1 + else: + # Move MYE to base model + cur.execute(""" + UPDATE model_year_engine SET model_id = %s WHERE id_mye = %s + """, (base_id, mye_id)) + mye_migrated += 1 + + # Now delete the INT model (should have no MYEs left) + if not dry_run: + cur.execute("DELETE FROM models WHERE id_model = %s", (model_id,)) + conn.commit() + + print(f" Migrated {mye_migrated} MYEs, deleted model") + merged += 1 + + print(f"\nINT. summary: merged={merged}, renamed={renamed}, skipped={skipped}") + cur.close() + return merged, renamed, skipped + + +def clean_empty_models(conn, dry_run=True): + """Delete or merge models with empty names.""" + cur = conn.cursor() + cur.execute(""" + SELECT m.id_model, m.name_model, m.brand_id, b.name_brand, + (SELECT COUNT(*) FROM model_year_engine mye WHERE mye.model_id = m.id_model) as mye_count + FROM models m + JOIN brands b ON b.id_brand = m.brand_id + WHERE m.name_model IS NULL OR TRIM(m.name_model) = '' + ORDER BY mye_count DESC + """) + empty_models = cur.fetchall() + print(f"\nFound {len(empty_models)} empty-name models") + + deleted = 0 + for model_id, name_model, brand_id, brand_name, mye_count in empty_models: + print(f"[{brand_name}] empty model id={model_id}, MYEs={mye_count}") + if mye_count == 0: + print(f" -> Safe to delete (no MYEs)") + if not dry_run: + cur.execute("DELETE FROM models WHERE id_model = %s", (model_id,)) + conn.commit() + deleted += 1 + else: + # Check MYEs: if they have supplier_catalog_compat, we need to find a target + cur.execute(""" + SELECT mye.id_mye, mye.year_id, mye.engine_id, y.year_car, e.name_engine + FROM model_year_engine mye + LEFT JOIN years y ON y.id_year = mye.year_id + LEFT JOIN engines e ON e.id_engine = mye.engine_id + WHERE mye.model_id = %s + """, (model_id,)) + myes = cur.fetchall() + print(f" -> Has {len(myes)} MYEs. Details:") + for mye_id, yid, eid, yname, ename in myes: + print(f" MYE {mye_id}: year={yname}, engine={ename}") + # Check if there's a real model in same brand with this year+engine combo + cur.execute(""" + SELECT m2.id_model, m2.name_model FROM model_year_engine mye2 + JOIN models m2 ON m2.id_model = mye2.model_id + WHERE m2.brand_id = %s AND mye2.year_id = %s + AND (mye2.engine_id = %s OR (mye2.engine_id IS NULL AND %s IS NULL)) + LIMIT 3 + """, (brand_id, yid, eid, eid)) + candidates = cur.fetchall() + print(f" Candidates: {candidates}") + if candidates and not dry_run: + target_id = candidates[0][0] + cur.execute(""" + UPDATE supplier_catalog_compat SET model_year_engine_id = NULL + WHERE model_year_engine_id = %s + """, (mye_id,)) + cur.execute("DELETE FROM vin_cache WHERE model_year_engine_id = %s", (mye_id,)) + cur.execute("DELETE FROM model_year_engine WHERE id_mye = %s", (mye_id,)) + conn.commit() + print(f" -> Cleared MYE {mye_id} (moved to NULL, manual remap needed)") + if not dry_run: + cur.execute("DELETE FROM models WHERE id_model = %s", (model_id,)) + conn.commit() + deleted += 1 + + print(f"Empty models processed: {deleted}") + cur.close() + return deleted + + +def clean_year_range_models(conn, dry_run=True): + """Delete models that are year ranges like '09-15'.""" + cur = conn.cursor() + cur.execute("SELECT id_model, name_model, brand_id FROM models") + year_ranges = [] + for mid, name, bid in cur.fetchall(): + if name and re.match(r'^(\d{2}-\d{2}|\d{4}-\d{4})$', name.strip()): + year_ranges.append((mid, name.strip(), bid)) + + print(f"\nFound {len(year_ranges)} year-range models") + deleted = 0 + for mid, name, bid in year_ranges: + cur.execute("SELECT COUNT(*) FROM model_year_engine WHERE model_id = %s", (mid,)) + count = cur.fetchone()[0] + cur.execute("SELECT name_brand FROM brands WHERE id_brand = %s", (bid,)) + bname = cur.fetchone()[0] + print(f"[{bname}] '{name}' id={mid}, MYEs={count}") + if not dry_run: + delete_model_and_myes(conn, mid, dry_run=False) + conn.commit() + deleted += 1 + print(f"Year-range models deleted: {deleted}") + cur.close() + return deleted + + +def clean_torque_models(conn, dry_run=True): + """Delete models that contain torque specs like 'Nm'.""" + cur = conn.cursor() + cur.execute("SELECT id_model, name_model, brand_id FROM models") + torque_models = [] + for mid, name, bid in cur.fetchall(): + if name and ('Nm' in name or 'nm' in name.lower()): + torque_models.append((mid, name, bid)) + + print(f"\nFound {len(torque_models)} torque-spec models") + deleted = 0 + for mid, name, bid in torque_models: + cur.execute("SELECT COUNT(*) FROM model_year_engine WHERE model_id = %s", (mid,)) + count = cur.fetchone()[0] + cur.execute("SELECT name_brand FROM brands WHERE id_brand = %s", (bid,)) + bname = cur.fetchone()[0] + print(f"[{bname}] '{name}' id={mid}, MYEs={count}") + if not dry_run: + delete_model_and_myes(conn, mid, dry_run=False) + conn.commit() + deleted += 1 + print(f"Torque-spec models deleted: {deleted}") + cur.close() + return deleted + + +def main(): + dry_run = '--execute' not in sys.argv + if dry_run: + print("=" * 60) + print("DRY RUN MODE — no changes will be made") + print("Run with --execute to apply changes") + print("=" * 60) + + conn = get_connection() + try: + merge_int_models(conn, dry_run=dry_run) + clean_empty_models(conn, dry_run=dry_run) + clean_year_range_models(conn, dry_run=dry_run) + clean_torque_models(conn, dry_run=dry_run) + finally: + conn.close() + + if dry_run: + print("\n" + "=" * 60) + print("DRY RUN complete. Run with --execute to apply.") + print("=" * 60) + + +if __name__ == '__main__': + main() diff --git a/scripts/clean_supplier_corrupted_models.py b/scripts/clean_supplier_corrupted_models.py new file mode 100755 index 0000000..4c28168 --- /dev/null +++ b/scripts/clean_supplier_corrupted_models.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +""" +Clean supplier-corrupted models from master DB. +Handles trailing years, year ranges, engine specs, trim variants, etc. + +Usage: + python scripts/clean_supplier_corrupted_models.py [--execute] +""" + +import os +import re +import sys + +import psycopg2 + +MASTER_DB_URL = os.environ.get('MASTER_DB_URL', 'postgresql://postgres@localhost/nexus_autoparts') + + +def connect(): + return psycopg2.connect(MASTER_DB_URL) + + +def delete_model_and_myes(conn, model_id): + cur = conn.cursor() + cur.execute("SELECT id_mye FROM model_year_engine WHERE model_id = %s", (model_id,)) + mye_ids = [r[0] for r in cur.fetchall()] + if mye_ids: + cur.execute("UPDATE supplier_catalog_compat SET model_year_engine_id = NULL WHERE model_year_engine_id = ANY(%s)", (mye_ids,)) + cur.execute("DELETE FROM vin_cache WHERE model_year_engine_id = ANY(%s)", (mye_ids,)) + cur.execute("DELETE FROM model_year_engine WHERE id_mye = ANY(%s)", (mye_ids,)) + cur.execute("DELETE FROM models WHERE id_model = %s", (model_id,)) + cur.close() + + +def normalize_for_match(name): + """Normalize model name for matching: uppercase, remove extra spaces, replace spaces with hyphens and vice versa.""" + if not name: + return '' + return ' '.join(str(name).upper().split()) + + +def find_base_model(cur, brand_id, base_name): + """Find a base model in same brand by normalized name match.""" + normalized = normalize_for_match(base_name) + # Try exact + cur.execute(""" + SELECT id_model, name_model FROM models + WHERE brand_id = %s AND LOWER(name_model) = LOWER(%s) + LIMIT 1 + """, (brand_id, normalized)) + row = cur.fetchone() + if row: + return row + # Try with spaces replaced by hyphens + hyphenated = normalized.replace(' ', '-') + cur.execute(""" + SELECT id_model, name_model FROM models + WHERE brand_id = %s AND REPLACE(UPPER(name_model), ' ', '-') = %s + LIMIT 1 + """, (brand_id, hyphenated)) + row = cur.fetchone() + if row: + return row + # Try with hyphens replaced by spaces + spaced = normalized.replace('-', ' ') + cur.execute(""" + SELECT id_model, name_model FROM models + WHERE brand_id = %s AND REPLACE(UPPER(name_model), '-', ' ') = %s + LIMIT 1 + """, (brand_id, spaced)) + return cur.fetchone() + + +def merge_model_to_base(conn, model_id, base_id, base_name): + cur = conn.cursor() + cur.execute("SELECT id_mye, year_id, engine_id FROM model_year_engine WHERE model_id = %s", (model_id,)) + myes = cur.fetchall() + migrated = 0 + for mye_id, year_id, engine_id in myes: + cur.execute(""" + SELECT id_mye FROM model_year_engine + WHERE model_id = %s AND year_id = %s + AND (engine_id = %s OR (engine_id IS NULL AND %s IS NULL)) + """, (base_id, year_id, engine_id, engine_id)) + base_mye = cur.fetchone() + if base_mye: + base_mye_id = base_mye[0] + cur.execute("UPDATE supplier_catalog_compat SET model_year_engine_id = %s WHERE model_year_engine_id = %s", (base_mye_id, mye_id)) + cur.execute("DELETE FROM vin_cache WHERE model_year_engine_id = %s", (mye_id,)) + cur.execute("DELETE FROM model_year_engine WHERE id_mye = %s", (mye_id,)) + else: + cur.execute("UPDATE model_year_engine SET model_id = %s WHERE id_mye = %s", (base_id, mye_id)) + migrated += 1 + cur.execute("DELETE FROM models WHERE id_model = %s", (model_id,)) + cur.close() + return migrated + + +def extract_base_name(name, reason): + n = name.strip() + if reason == 'trailing_year': + m = re.search(r'^(.*?)\s+(19|20)\d{2}$', n) + if m: + return m.group(1).strip() + elif reason == 'year_range_parens': + m = re.search(r'^(.*?)\s+\d{2}-\d{2}\s*\(', n) + if m: + return m.group(1).strip() + elif reason == 'hasta_tas': + if 'Tas.' in n: + m = re.search(r'^(.*?)(?:\s+\d+\.\d+L)?\s+\d{2}-\d{2}\s+Tas\.', n) + if m: + return m.group(1).strip() + if 'hasta' in n.lower(): + m = re.search(r'^(.*?)\s+hasta', n, re.IGNORECASE) + if m: + return m.group(1).strip() + return None + + +def main(): + dry_run = '--execute' not in sys.argv + if dry_run: + print("=" * 60) + print("DRY RUN MODE — no changes will be made") + print("Run with --execute to apply changes") + print("=" * 60) + + conn = connect() + cur = conn.cursor() + + cur.execute('SELECT id_model, name_model, brand_id FROM models') + models = cur.fetchall() + + patterns = { + 'trailing_year': (re.compile(r' (19|20)\d{2}$'), lambda b: b != 'MCLAREN'), + 'year_range_parens': (re.compile(r'[A-Za-z]+ \d{2}-\d{2} \('), None), + 'engine_spec': (re.compile(r',?\s*\(\d+ HP\)|DOHC|SOHC|Valv\.|Turbo L4|L4,\s*\(', re.IGNORECASE), None), + 'hasta_tas': (re.compile(r'hasta|Tas\.', re.IGNORECASE), None), + 'engine_only': (re.compile(r'^\d+\.\d+L$', re.IGNORECASE), None), + 'engine_config': (re.compile(r'^\d+\.\d+L\s+(?:L\d|V\d|R\s|Turbo|TDI|GSI)', re.IGNORECASE), + lambda n: not re.search(r'\([A-Z0-9_]{3,}\)$', n)), + } + + suspicious = [] + for mid, name, bid in models: + if not name: + continue + for reason, (pat, extra_check) in patterns.items(): + if pat.search(name): + ok = True + if extra_check: + if reason == 'trailing_year': + cur.execute('SELECT name_brand FROM brands WHERE id_brand=%s', (bid,)) + bname = cur.fetchone()[0] + ok = extra_check(bname) + else: + ok = extra_check(name) + if ok: + suspicious.append((bid, name, mid, reason)) + break + + # Trim variant detection: "500 POP", "FIESTA SE", etc. + trim_variants = ['LOUNGE', 'POP', 'SPORT', 'ADVENTURE', 'FIRE', 'GT', 'GTV', 'STD', 'SE', 'LE', 'XLE', 'LIMITED', 'LX', 'EX', 'SX'] + trim_pattern = re.compile(r'^(\S+?)\s*(' + '|'.join(trim_variants) + r')$') + + trim_matches = [] + for mid, name, bid in models: + if not name: + continue + if any(s[2] == mid for s in suspicious): + continue # already flagged + m = trim_pattern.match(name.upper()) + if m: + base = m.group(1) + base_model = find_base_model(cur, bid, base) + if base_model: + trim_matches.append((bid, name, mid, 'trim_variant', base_model[0], base_model[1])) + + print(f"\nFound {len(suspicious)} suspicious models by pattern") + print(f"Found {len(trim_matches)} trim variant models") + + to_merge = [] + to_delete = [] + + for bid, name, mid, reason in suspicious: + if reason in ('engine_spec', 'engine_only', 'engine_config'): + to_delete.append((bid, name, mid, reason)) + continue + base_name = extract_base_name(name, reason) + if base_name: + base = find_base_model(cur, bid, base_name) + if base: + to_merge.append((bid, name, mid, reason, base[0], base[1])) + continue + to_delete.append((bid, name, mid, reason)) + + # Add trim matches to merge list + for item in trim_matches: + to_merge.append(item) + + print(f"\nTo merge: {len(to_merge)}") + for bid, name, mid, reason, base_id, base_name in to_merge: + print(f" [{bid}] '{name}' -> '{base_name}' (reason={reason})") + + print(f"\nTo delete: {len(to_delete)}") + for bid, name, mid, reason in to_delete: + print(f" [{bid}] '{name}' reason={reason}") + + if dry_run: + print("\n" + "=" * 60) + print("DRY RUN complete. Run with --execute to apply.") + print("=" * 60) + cur.close() + conn.close() + return + + print("\nApplying merges...") + for bid, name, mid, reason, base_id, base_name in to_merge: + cur.execute('SELECT name_brand FROM brands WHERE id_brand=%s', (bid,)) + bname = cur.fetchone()[0] + migrated = merge_model_to_base(conn, mid, base_id, base_name) + print(f" [{bname}] '{name}' -> '{base_name}' ({migrated} MYEs migrated)") + conn.commit() + + print("\nApplying deletes...") + for bid, name, mid, reason in to_delete: + cur.execute('SELECT name_brand FROM brands WHERE id_brand=%s', (bid,)) + bname = cur.fetchone()[0] + delete_model_and_myes(conn, mid) + print(f" [{bname}] '{name}' deleted") + conn.commit() + + print(f"\nDone. Merged {len(to_merge)}, deleted {len(to_delete)}.") + cur.close() + conn.close() + + +if __name__ == '__main__': + main() diff --git a/scripts/clean_supplier_corrupted_models_v2.py b/scripts/clean_supplier_corrupted_models_v2.py new file mode 100644 index 0000000..f321c09 --- /dev/null +++ b/scripts/clean_supplier_corrupted_models_v2.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +""" +Second pass: clean remaining supplier-corrupted models. +More aggressive patterns for engine specs mixed with years. + +Usage: + python scripts/clean_supplier_corrupted_models_v2.py [--execute] +""" + +import os +import re +import sys + +import psycopg2 + +MASTER_DB_URL = os.environ.get('MASTER_DB_URL', 'postgresql://postgres@localhost/nexus_autoparts') + + +def connect(): + return psycopg2.connect(MASTER_DB_URL) + + +def delete_model_and_myes(conn, model_id): + cur = conn.cursor() + cur.execute("SELECT id_mye FROM model_year_engine WHERE model_id = %s", (model_id,)) + mye_ids = [r[0] for r in cur.fetchall()] + if mye_ids: + cur.execute("UPDATE supplier_catalog_compat SET model_year_engine_id = NULL WHERE model_year_engine_id = ANY(%s)", (mye_ids,)) + cur.execute("DELETE FROM vin_cache WHERE model_year_engine_id = ANY(%s)", (mye_ids,)) + cur.execute("DELETE FROM model_year_engine WHERE id_mye = ANY(%s)", (mye_ids,)) + cur.execute("DELETE FROM models WHERE id_model = %s", (model_id,)) + cur.close() + + +def main(): + dry_run = '--execute' not in sys.argv + if dry_run: + print("=" * 60) + print("DRY RUN MODE — no changes will be made") + print("Run with --execute to apply changes") + print("=" * 60) + + conn = connect() + cur = conn.cursor() + + # Aggressive patterns for remaining garbage + # Pattern: starts with displacement and contains year range or engine config + patterns = [ + re.compile(r'^\d+\.\d+L.*\d{2}-\d{2}', re.IGNORECASE), # 2.2L 98-99 L4 Amigo + re.compile(r'^\d+\.\d+L\s+[A-Za-z].*L\d', re.IGNORECASE), # 1.5L March + re.compile(r'^\d+\.\d+L\s+[A-Za-z]{3,}$', re.IGNORECASE), # 1.8L R + re.compile(r'^\d+\.\d+L\s+Datsun', re.IGNORECASE), # 1.5L Datsun 1600 + re.compile(r'\d{2}-\d{2}.*L4,', re.IGNORECASE), # ...98-99...L4, + re.compile(r'\d{2}-\d{2}.*\d+\.\d+L.*Gasolina', re.IGNORECASE), # ...07-16...2.4L Gasolina + re.compile(r'^370 Z\s+\d+\.\d+L', re.IGNORECASE), # 370 Z 1.5L + re.compile(r'Brakes.*\d{2}-\d{2}', re.IGNORECASE), # Cooper JC Works Brakes 09-15 + re.compile(r'Cabstar.*\d{2}-\d{2}', re.IGNORECASE), # Cabstar 3.5T 07-16 + re.compile(r'X Terra.*Chevrolet', re.IGNORECASE), # X Terra 05-15 Chevrolet City + ] + + # Specific known-bad models by exact name + known_bad = { + '1.8L R', '2.5L 08 - 13', 'Eclipse 2.0L Aspiración Natural', + 'Cooper JC Works Brakes 09-15 Disco de 316mm', + 'Cabstar 3.5T 07-16 C/Sensor', 'X Terra 05-15 Chevrolet City Ex-', + 'NP-300 (D-22) 2WD 2.4L GASOLINA', 'NV-350 2.5L GAS', + } + + cur.execute('SELECT id_model, name_model, brand_id FROM models') + models = cur.fetchall() + + to_delete = [] + for mid, name, bid in models: + if not name: + continue + n = name.strip() + if n in known_bad: + to_delete.append((bid, n, mid, 'known_bad')) + continue + for pat in patterns: + if pat.search(n): + # Exclude legitimate TecDoc patterns like "1100-1900 (101_)" + if re.search(r'^\d{4}-\d{4} \([A-Z0-9_]+\)$', n): + continue + to_delete.append((bid, n, mid, 'pattern')) + break + + print(f"\nFound {len(to_delete)} remaining corrupted models") + for bid, n, mid, reason in to_delete: + cur.execute('SELECT name_brand FROM brands WHERE id_brand=%s', (bid,)) + bname = cur.fetchone()[0] + cur.execute('SELECT COUNT(*) FROM model_year_engine WHERE model_id=%s', (mid,)) + mye_count = cur.fetchone()[0] + print(f" [{bname}] '{n}' id={mid} MYEs={mye_count} reason={reason}") + + if dry_run: + print("\n" + "=" * 60) + print("DRY RUN complete. Run with --execute to apply.") + print("=" * 60) + cur.close() + conn.close() + return + + print("\nApplying deletes...") + for bid, n, mid, reason in to_delete: + cur.execute('SELECT name_brand FROM brands WHERE id_brand=%s', (bid,)) + bname = cur.fetchone()[0] + delete_model_and_myes(conn, mid) + print(f" [{bname}] '{n}' deleted") + conn.commit() + + print(f"\nDone. Deleted {len(to_delete)} models.") + cur.close() + conn.close() + + +if __name__ == '__main__': + main() diff --git a/scripts/clean_supplier_corrupted_models_v3.py b/scripts/clean_supplier_corrupted_models_v3.py new file mode 100644 index 0000000..e86bfe7 --- /dev/null +++ b/scripts/clean_supplier_corrupted_models_v3.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +""" +Third pass: clean specific remaining supplier-corrupted models. + +Usage: + python scripts/clean_supplier_corrupted_models_v3.py [--execute] +""" + +import os +import sys + +import psycopg2 + +MASTER_DB_URL = os.environ.get('MASTER_DB_URL', 'postgresql://postgres@localhost/nexus_autoparts') + + +def connect(): + return psycopg2.connect(MASTER_DB_URL) + + +def delete_model_and_myes(conn, model_id): + cur = conn.cursor() + cur.execute("SELECT id_mye FROM model_year_engine WHERE model_id = %s", (model_id,)) + mye_ids = [r[0] for r in cur.fetchall()] + if mye_ids: + cur.execute("UPDATE supplier_catalog_compat SET model_year_engine_id = NULL WHERE model_year_engine_id = ANY(%s)", (mye_ids,)) + cur.execute("DELETE FROM vin_cache WHERE model_year_engine_id = ANY(%s)", (mye_ids,)) + cur.execute("DELETE FROM model_year_engine WHERE id_mye = ANY(%s)", (mye_ids,)) + cur.execute("DELETE FROM models WHERE id_model = %s", (model_id,)) + cur.close() + + +def merge_model_to_base(conn, model_id, base_id): + cur = conn.cursor() + cur.execute("SELECT id_mye, year_id, engine_id FROM model_year_engine WHERE model_id = %s", (model_id,)) + myes = cur.fetchall() + migrated = 0 + for mye_id, year_id, engine_id in myes: + cur.execute(""" + SELECT id_mye FROM model_year_engine + WHERE model_id = %s AND year_id = %s + AND (engine_id = %s OR (engine_id IS NULL AND %s IS NULL)) + """, (base_id, year_id, engine_id, engine_id)) + base_mye = cur.fetchone() + if base_mye: + cur.execute("UPDATE supplier_catalog_compat SET model_year_engine_id = %s WHERE model_year_engine_id = %s", (base_mye[0], mye_id)) + cur.execute("DELETE FROM vin_cache WHERE model_year_engine_id = %s", (mye_id,)) + cur.execute("DELETE FROM model_year_engine WHERE id_mye = %s", (mye_id,)) + else: + cur.execute("UPDATE model_year_engine SET model_id = %s WHERE id_mye = %s", (base_id, mye_id)) + migrated += 1 + cur.execute("DELETE FROM models WHERE id_model = %s", (model_id,)) + cur.close() + return migrated + + +def main(): + dry_run = '--execute' not in sys.argv + if dry_run: + print("=" * 60) + print("DRY RUN MODE — no changes will be made") + print("Run with --execute to apply changes") + print("=" * 60) + + conn = connect() + cur = conn.cursor() + + # Exact (brand, model_name) pairs to delete + delete_exact = [ + ('ISUZU', '1.9L 81 - 86 Pick-Up'), + ('ISUZU', '2.2L 98 - 02 L4 2.3L Turbo 84 - 87'), + ('ISUZU', '2.2L 98 - 99 L4 Amigo'), + ('ISUZU', '2.3L 81 - 95 2.0L Turbo 84 - 87'), + ('ISUZU', '2.3L 81 - 95 Pick-Up'), + ('ISUZU', '3.2L 92 - 93 V6'), + ('ISUZU', '3.2L 92 - 93 V6 2.3L Turbo 84 - 87'), + ('CHEVROLET', '2.0L 1984 L4'), + ('CHEVROLET', '2.0L 83 - 84 L4'), + ('CHEVROLET', '2.0L 83 - 84 L4 Jimmy'), + ('CHEVROLET', '2.8L 1984 V6'), + ('CHEVROLET', '2.8L 85 - 93 V6'), + ('CHEVROLET', '3.1L 91 - 95 V6'), + ('CHEVROLET', '3.1L 91 - 98 V6'), + ('CHEVROLET', '4.1L 81 - 84 L6'), + ('CHEVROLET', '4.1L 81 - 84 L6 10'), + ('CHEVROLET', '4.3L 10'), + ('CHEVROLET', '4.3L 90 - 93 V6'), + ('CHEVROLET', '4.3L 90 - 93 V6 10'), + ('CHEVROLET', '4.3L 96 - 00 V6 10'), + ('CHEVROLET', '5.0L 96 - 98 V8'), + ('CHEVROLET', '5.0L 96 - 98 V8 10'), + ('CHEVROLET', '5.1L 82 - 91 V8'), + ('CHEVROLET', '5.7L (19.15 mm)'), + ('CHEVROLET', '5.7L 10'), + ('CHEVROLET', '5.7L 69 - 91 V8'), + ('CHEVROLET', '5.7L 69 - 91 V8 10'), + ('CHRYSLER / DODGE', '2.5L Chasis Cabina'), + ('CHRYSLER / DODGE', '3.7L 10'), + ('CHRYSLER / DODGE', '3.7L 86 - 87 L6 23'), + ('CHRYSLER / DODGE', '3.9L 88 - 91 V6'), + ('CHRYSLER / DODGE', '3.9L 88 - 91 V6 23'), + ('CHRYSLER / DODGE', '4.7L 04 - 05 V8'), + ('CHRYSLER / DODGE', '4.7L 04 - 05 V8 10'), + ('CHRYSLER / DODGE', '4.7L 10'), + ('CHRYSLER / DODGE', '5.2L 10'), + ('CHRYSLER / DODGE', '5.2L 85 - 93 V8'), + ('CHRYSLER / DODGE', '5.7L 10'), + ('CHRYSLER / DODGE', '5.9L 10'), + ('CHRYSLER / DODGE', '5.9L 19'), + ('CHRYSLER / DODGE', '5.9L 1992 V8 19'), + ('CHRYSLER / DODGE', '5.9L 88 - 91 V8 19'), + ('CHRYSLER / DODGE', '5.9L 94 - 97 V8 Ram 7000'), + ('FORD', '2.0L 97 - 03 L4'), + ('FORD', '2.0L 97 - 03 L4 10'), + ('FORD', '2.0L LX, SE'), + ('FORD', '2.3L 87 - 88 L4'), + ('FORD', '2.3L 87 - 88 L4 10'), + ('FORD', '2.3L 87 - 88 L4 Aerostar'), + ('FORD', '2.3L 87 - 88 L4 Bronco'), + ('FORD', '3.0L 98 - 01 V6'), + ('FORD', '4.0L 93 - 97 V6'), + ('FORD', '4.6L 10'), + ('FORD', '4.9L 83 - 93 L6 10'), + ('FORD', '4.9L 88 - 89 L6'), + ('FORD', '4.9L 88 - 92 L6 10'), + ('FORD', '5.0L 10'), + ('FORD', '5.0L 65 - 91 V8 10'), + ('FORD', '5.0L 88 - 89 V8 10'), + ('FORD', '5.7L 65 - 84 V8'), + ('FORD', '5.8L 10'), + ('FORD', '5.8L 1992 10'), + ('JEEP', '4.0L 10'), + ('JEEP', '4.0L 14'), + ('JEEP', '4.0L 19'), + ('JEEP', '4.0L 1”'), + ('JEEP', '4.2L 87 - 89 L6 10'), + ('JEEP', '4.2L 87 - 89 L6 14'), + ('NISSAN', '2.4L 85 - 92 L4'), + ('NISSAN', '2.4L 85 - 92 L4 2.0L 720 74 - 83 L4, L20B, Z22, SD22'), + ('NISSAN', '2.4L 85 - 92 L4 2.4L 91 - 00 L4, D21'), + ('NISSAN', '3.0L 89 - 94 Micra'), + ('NISSAN', '3.0L 89 - 94 NX Coupé DE'), + ('MAZDA', '4.0L B4000'), + ('PONTIAC', '5.7L 1998 V8'), + ('VW', '1.6L (23.40 mm)'), + ('AUDI', '1.4L TFSI (17.0 mm)'), + ('BMW', 'Q60 14-16 Sin Paq. Sport'), + ('MERCEDES BENZ', 'Cayenne Turbo 05-10 R-19”'), + ('MERCEDES BENZ', 'ne S 05-10 R-19”'), + ('DODGE', 'Sienna 11-19 Lexux NX200t 15-'), + ('PEUGEOT', '406 00-05 4Cil.'), + ('PEUGEOT', 'RAV-4 06-18 Nacio-'), + ('TOYOTA', 'Sonic RS 12-17 Che-'), + ('SEAT', 'Ibiza 18. Volkswagen Golf 17-18 9193-D1968 SG'), + ] + + # (brand, bad_model, base_model) + merge_map = [ + ('KIA', 'Sorento 14-16 Latino', 'SORENTO'), + ('HYUNDAI', 'IX20 11-16 Latino', 'ix20 (JC)'), + ('TOYOTA', 'Corolla CE 07-11 Brasil', 'COROLLA'), + ('SUZUKI', 'Grand Vitara 09-13 Na-', 'GRAND VITARA'), + ('CHRYSLER', 'Voyager 00-04 Modelos Europeos', 'VOYAGER'), + ] + + # Resolve IDs + to_delete = [] + for bname, mname in delete_exact: + cur.execute(""" + SELECT b.id_brand, m.id_model + FROM brands b JOIN models m ON m.brand_id = b.id_brand + WHERE b.name_brand = %s AND m.name_model = %s + """, (bname, mname)) + row = cur.fetchone() + if row: + to_delete.append((bname, mname, row[1])) + + to_merge = [] + for bname, bad_name, base_name in merge_map: + cur.execute(""" + SELECT b.id_brand, m.id_model + FROM brands b JOIN models m ON m.brand_id = b.id_brand + WHERE b.name_brand = %s AND m.name_model = %s + """, (bname, bad_name)) + bad = cur.fetchone() + cur.execute(""" + SELECT m.id_model FROM models m JOIN brands b ON b.id_brand = m.brand_id + WHERE b.name_brand = %s AND m.name_model = %s + """, (bname, base_name)) + base = cur.fetchone() + if bad and base: + to_merge.append((bname, bad_name, bad[1], base_name, base[0])) + + print(f"\nTo delete: {len(to_delete)}") + for bname, mname, mid in to_delete: + print(f" [{bname}] '{mname}' id={mid}") + + print(f"\nTo merge: {len(to_merge)}") + for bname, bad_name, mid, base_name, base_id in to_merge: + print(f" [{bname}] '{bad_name}' -> '{base_name}'") + + if dry_run: + print("\n" + "=" * 60) + print("DRY RUN complete. Run with --execute to apply.") + print("=" * 60) + cur.close() + conn.close() + return + + print("\nApplying merges...") + for bname, bad_name, mid, base_name, base_id in to_merge: + migrated = merge_model_to_base(conn, mid, base_id) + print(f" [{bname}] '{bad_name}' -> '{base_name}' ({migrated} MYEs migrated)") + conn.commit() + + print("\nApplying deletes...") + for bname, mname, mid in to_delete: + delete_model_and_myes(conn, mid) + print(f" [{bname}] '{mname}' deleted") + conn.commit() + + print(f"\nDone. Merged {len(to_merge)}, deleted {len(to_delete)}.") + cur.close() + conn.close() + + +if __name__ == '__main__': + main() diff --git a/scripts/clean_supplier_corrupted_models_v4.py b/scripts/clean_supplier_corrupted_models_v4.py new file mode 100755 index 0000000..e0e17d5 --- /dev/null +++ b/scripts/clean_supplier_corrupted_models_v4.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Final wave: delete remaining engine-displacement models left by supplier imports. + +These are not real models (e.g. '5.7L 85 - 96 V8', '7.4L 73 - 91 V8', '5.8L 93 - 96 V8'). +Deleting them removes fake MYEs; compat rows are unlinked (model_year_engine_id=NULL) +and remain searchable by SKU / part number. +""" +import argparse +import os + +import psycopg2 + +MASTER_DB_URL = os.environ.get('MASTER_DB_URL', 'postgresql://postgres@localhost/nexus_autoparts') + +# Remaining engine-pattern models after v3 cleanup. +ENGINE_PATTERN_MODELS = [ + # Chevrolet + ("CHEVROLET", "5.7L 85 - 96 V8"), + ("CHEVROLET", "5.7L 85 - 96 V8 Yukon"), + ("CHEVROLET", "5.7L 99 - 00 V8"), + ("CHEVROLET", "5.7L P-300 85 - 98 V8 10"), + ("CHEVROLET", "7.4L 73 - 91 V8"), + ("CHEVROLET", "7.4L 73 - 91 V8 10"), + ("CHEVROLET", "7.4L 85 - 95 V8"), + ("CHEVROLET", "7.4L 85 - 95 V8 10"), + ("CHEVROLET", "7.4L 87 - 91 V8"), + ("CHEVROLET", "7.4L 87 - 91 V8 10"), + # Ford + ("FORD", "5.8L 1998 V8"), + ("FORD", "5.8L 1998 V8 10"), + ("FORD", "5.8L 84 - 87 V8"), + ("FORD", "5.8L 84 - 87 V8 10"), + ("FORD", "5.8L 84 - 87 V8 Pro"), + ("FORD", "5.8L 88 - 89 V8"), + ("FORD", "5.8L 88 - 95 V8 10"), + ("FORD", "5.8L 89 - 91 V8"), + ("FORD", "5.8L 93 - 96 V8"), + ("FORD", "5.8L XLT 91 - 97 V8"), + ("FORD", "6.2L 10"), + ("FORD", "6.8L XL Super Duty 05 - 06"), + ("FORD", "6.8L XL Super Duty 05 - 06 10"), + ("FORD", "7.3L 1994 V8, FI, Turbo Diesel"), + ("FORD", "7.3L 94 - 98 10"), + # Toyota + ("TOYOTA", "2.2L 74 - 80 L4, 20R Engine"), +] + + +def delete_model_and_myes(conn, model_id): + cur = conn.cursor() + cur.execute("SELECT id_mye FROM model_year_engine WHERE model_id = %s", (model_id,)) + mye_ids = [r[0] for r in cur.fetchall()] + if mye_ids: + cur.execute( + "UPDATE supplier_catalog_compat SET model_year_engine_id = NULL WHERE model_year_engine_id = ANY(%s)", + (mye_ids,), + ) + cur.execute("DELETE FROM vin_cache WHERE model_year_engine_id = ANY(%s)", (mye_ids,)) + cur.execute("DELETE FROM model_year_engine WHERE id_mye = ANY(%s)", (mye_ids,)) + cur.execute("DELETE FROM models WHERE id_model = %s", (model_id,)) + cur.close() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--execute", action="store_true") + args = parser.parse_args() + + if not args.execute: + print("=" * 60) + print("DRY RUN MODE — no changes will be made") + print("Run with --execute to apply changes") + print("=" * 60) + + conn = psycopg2.connect(MASTER_DB_URL) + conn.autocommit = False + cur = conn.cursor() + + to_delete = [] + for brand_name, model_name in ENGINE_PATTERN_MODELS: + cur.execute( + """ + SELECT m.id_model, b.name_brand, m.name_model + FROM models m + JOIN brands b ON b.id_brand = m.brand_id + WHERE UPPER(b.name_brand) = %s AND m.name_model = %s + """, + (brand_name, model_name), + ) + row = cur.fetchone() + if row: + to_delete.append(row) + else: + print(f" NOT FOUND: [{brand_name}] {model_name!r}") + + print(f"\nTo delete: {len(to_delete)}") + total_myes = 0 + for mid, bname, mname in to_delete: + cur.execute("SELECT COUNT(*) FROM model_year_engine WHERE model_id = %s", (mid,)) + cnt = cur.fetchone()[0] + total_myes += cnt + print(f" [{bname}] {mname!r} id={mid} MYEs={cnt}") + + print(f"Total MYEs to remove: {total_myes}") + + if not args.execute: + print("\n" + "=" * 60) + print("DRY RUN complete. Run with --execute to apply.") + print("=" * 60) + cur.close() + conn.close() + return + + for mid, bname, mname in to_delete: + delete_model_and_myes(conn, mid) + print(f" Deleted [{bname}] {mname!r}") + + conn.commit() + print(f"\nDone. Deleted {len(to_delete)} models ({total_myes} MYEs removed).") + + cur.close() + conn.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/clean_year_suffix_models.py b/scripts/clean_year_suffix_models.py new file mode 100755 index 0000000..616b69a --- /dev/null +++ b/scripts/clean_year_suffix_models.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Clean models with year suffixes like 'Model 17-18' or 'Model 2010-2015'.""" + +import psycopg2, re, sys + +MASTER_DSN = "host=localhost dbname=nexus_autoparts user=postgres password=1123517" + +def main(): + dry_run = '--execute' not in sys.argv + conn = psycopg2.connect(MASTER_DSN) + cur = conn.cursor() + + cur.execute(""" + SELECT m.id_model, m.name_model, m.brand_id, b.name_brand, + (SELECT COUNT(*) FROM model_year_engine mye WHERE mye.model_id = m.id_model) as mye_count + FROM models m + JOIN brands b ON b.id_brand = m.brand_id + WHERE m.name_model ~ ' [0-9]{2}-[0-9]{2}$' OR m.name_model ~ ' [0-9]{4}-[0-9]{4}$' + ORDER BY mye_count DESC + """) + rows = cur.fetchall() + print(f"Found {len(rows)} models with year suffix") + + total_myes = 0 + total_models = 0 + total_scc = 0 + + for model_id, name_model, brand_id, brand_name, mye_count in rows: + total_models += 1 + print(f"[{brand_name}] \"{name_model}\" id={model_id}, MYEs={mye_count}") + + if mye_count > 0: + cur.execute("SELECT id_mye FROM model_year_engine WHERE model_id = %s", (model_id,)) + mye_ids = [r[0] for r in cur.fetchall()] + total_myes += len(mye_ids) + + # Count supplier_catalog_compat affected + cur.execute("SELECT COUNT(*) FROM supplier_catalog_compat WHERE model_year_engine_id = ANY(%s)", (mye_ids,)) + scc_count = cur.fetchone()[0] + total_scc += scc_count + print(f" -> {scc_count} supplier_catalog_compat rows will be nulled") + + if not dry_run: + cur.execute("UPDATE supplier_catalog_compat SET model_year_engine_id = NULL WHERE model_year_engine_id = ANY(%s)", (mye_ids,)) + cur.execute("DELETE FROM vin_cache WHERE model_year_engine_id = ANY(%s)", (mye_ids,)) + cur.execute("DELETE FROM model_year_engine WHERE id_mye = ANY(%s)", (mye_ids,)) + + if not dry_run: + cur.execute("DELETE FROM models WHERE id_model = %s", (model_id,)) + conn.commit() + + print(f"\n{'DRY RUN' if dry_run else 'EXECUTED'}: {total_models} models, {total_myes} MYEs, {total_scc} SCC rows affected") + cur.close() + conn.close() + +if __name__ == '__main__': + main() diff --git a/scripts/import_keepgreen_catalog.py b/scripts/import_keepgreen_catalog.py new file mode 100644 index 0000000..ecbc260 --- /dev/null +++ b/scripts/import_keepgreen_catalog.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +""" +Import Keep Green (KG) catalog from Excel into supplier_catalog tables. + +Usage: + python scripts/import_keepgreen_catalog.py +""" + +import os +import re +import sys +from collections import defaultdict +from datetime import datetime + +import psycopg2 +from openpyxl import load_workbook + +MASTER_DB_URL = os.environ.get('MASTER_DB_URL', 'postgresql://postgres@localhost/nexus_autoparts') +EXCEL_PATH = os.path.join(os.path.dirname(__file__), '..', 'data', 'KG (1).xlsx') +SUPPLIER_NAME = 'KEEP GREEN' + +MULTI_WORD_MAKES = { + ('MERCEDES', 'BENZ'): 'MERCEDES BENZ', + ('LAND', 'ROVER'): 'LAND ROVER', + ('ALFA', 'ROMEO'): 'ALFA ROMEO', + ('AMERICAN', 'MOTORS'): 'AMERICAN MOTORS', + ('ROLLS', 'ROYCE'): 'ROLLS ROYCE', + ('ASTON', 'MARTIN'): 'ASTON MARTIN', + ('GREAT', 'WALL'): 'GREAT WALL', +} + + +def connect_master(): + return psycopg2.connect(MASTER_DB_URL) + + +def normalize_name(name): + if not name: + return '' + return ' '.join(str(name).replace('\n', ' ').split()) + + +def parse_make(carro): + """Extract make from CARRO_PERTENECIENTE text.""" + if not carro: + return None + parts = str(carro).strip().split() + if not parts: + return None + make = parts[0] + if len(parts) >= 2: + key = (parts[0].upper(), parts[1].upper()) + if key in MULTI_WORD_MAKES: + make = MULTI_WORD_MAKES[key] + return make + + +def extract_interchanges(row): + """Extract (brand, part_number) pairs from interchange columns. + KG: interchanges start at col 5 (MARCA.1) through col 16 (INTERCAMBIO.5). + """ + interchanges = [] + for i in range(6): + marca_col = 5 + i * 2 + inter_col = 6 + i * 2 + if marca_col < len(row) and row[marca_col]: + brand = str(row[marca_col]).strip() + pn = str(row[inter_col]).strip() if inter_col < len(row) and row[inter_col] else '' + if brand and pn: + interchanges.append((brand, pn)) + return interchanges + + +def expand_year(year_val): + """Return list of integer years from a year value. + Handles: 1998, 1998-1999, 98-99, '1998 1999', etc. + """ + if year_val is None: + return [None] + s = str(year_val).strip() + if not s: + return [None] + + # Single 4-digit year + if re.match(r'^(19|20)\d{2}$', s): + return [int(s)] + + # Range with dash or slash: 1998-1999, 98-99, 1998/1999 + m = re.match(r'^(\d{2,4})\s*[-/]\s*(\d{2,4})$', s) + if m: + start = int(m.group(1)) + end = int(m.group(2)) + # Normalize 2-digit years + if start < 100: + start = 1900 + start if start >= 70 else 2000 + start + if end < 100: + end = 1900 + end if end >= 70 else 2000 + end + if end < start: + start, end = end, start + # Sanity: cap range length + if end - start > 100: + return [None] + return list(range(start, end + 1)) + + # Try plain integer + try: + y = int(float(s)) + if 1900 <= y <= 2100: + return [y] + except ValueError: + pass + + return [None] + + +def main(): + print(f"[{datetime.now().isoformat()}] Starting Keep Green import...") + + if not os.path.exists(EXCEL_PATH): + print(f"ERROR: Excel not found at {EXCEL_PATH}") + sys.exit(1) + + print(f"Loading {EXCEL_PATH}...") + wb = load_workbook(EXCEL_PATH, read_only=True, data_only=True) + + master_conn = connect_master() + master_cur = master_conn.cursor() + + upsert_catalog_sql = """ + INSERT INTO supplier_catalog (supplier_name, sku, name, category, is_active) + VALUES (%s, %s, %s, %s, true) + ON CONFLICT (supplier_name, sku, category) DO UPDATE SET + name = EXCLUDED.name, + category = EXCLUDED.category, + is_active = true + RETURNING id + """ + + insert_compat_sql = """ + INSERT INTO supplier_catalog_compat + (catalog_id, make, model, year, engine, model_year_engine_id, source) + VALUES (%s, %s, %s, %s, %s, NULL, %s) + ON CONFLICT (catalog_id, make, model, year, engine) DO NOTHING + """ + + insert_interchange_sql = """ + INSERT INTO supplier_catalog_interchange (catalog_id, brand, part_number) + VALUES (%s, %s, %s) + ON CONFLICT DO NOTHING + """ + + stats = defaultdict(int) + + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + rows = list(ws.iter_rows(values_only=True)) + if not rows: + continue + data_rows = rows[1:] + stats['sheets'] += 1 + print(f"\nProcessing sheet '{sheet_name}' with {len(data_rows)} rows...") + + catalog_id_cache = {} + + for idx, row in enumerate(data_rows): + if idx % 2000 == 0 and idx > 0: + print(f" ...{idx} rows processed") + + if not row or len(row) < 5 or not row[4]: + stats['skipped_no_sku'] += 1 + continue + + make = str(row[0]).strip().upper() if row[0] else '' + model = str(row[1]).strip() if row[1] else '' + engine = normalize_name(row[2]) if row[2] else None + year_raw = row[3] + sku = str(row[4]).strip() + name = normalize_name(row[17]) if len(row) > 17 and row[17] else sheet_name + carro = str(row[18]).strip() if len(row) > 18 and row[18] else '' + + if not sku: + stats['skipped_no_sku'] += 1 + continue + if not make or not model: + stats['skipped_no_vehicle'] += 1 + continue + + stats['rows'] += 1 + + # Prefer make from MARCA column; fall back to parsing CARRO_PERTENECIENTE + parsed_make = parse_make(carro) or make + + # Upsert catalog item (keyed by sku; category = sheet name) + cache_key = sku + catalog_id = catalog_id_cache.get(cache_key) + if catalog_id is None: + master_cur.execute(upsert_catalog_sql, (SUPPLIER_NAME, sku, name, sheet_name)) + row_result = master_cur.fetchone() + catalog_id = row_result[0] if row_result else None + catalog_id_cache[cache_key] = catalog_id + stats['catalog_items'] += 1 + + if catalog_id is None: + stats['skipped_no_catalog'] += 1 + continue + + # Expand years and insert compat rows + years = expand_year(year_raw) + for year in years: + master_cur.execute(insert_compat_sql, ( + catalog_id, + parsed_make, + model, + year, + engine or None, + 'import_text', + )) + stats['compat_rows'] += 1 + + # Insert interchanges + interchanges = extract_interchanges(row) + for brand, pn in interchanges: + master_cur.execute(insert_interchange_sql, (catalog_id, brand, pn)) + stats['interchange_rows'] += 1 + + master_conn.commit() + print(f" Sheet '{sheet_name}' committed.") + + print(f"\n{'='*60}") + print("IMPORT COMPLETE") + print(f"{'='*60}") + for k, v in sorted(stats.items()): + print(f"{k:25s}: {v}") + + master_cur.close() + master_conn.close() + + +if __name__ == '__main__': + main() diff --git a/scripts/import_knadian_catalog.py b/scripts/import_knadian_catalog.py new file mode 100644 index 0000000..5d5d988 --- /dev/null +++ b/scripts/import_knadian_catalog.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +""" +Import KNADIAN catalog from Excel into supplier_catalog tables. + +Usage: + python scripts/import_knadian_catalog.py +""" + +import os +import re +import sys +from collections import defaultdict +from datetime import datetime + +import psycopg2 +from openpyxl import load_workbook + +MASTER_DB_URL = os.environ.get('MASTER_DB_URL', 'postgresql://postgres@localhost/nexus_autoparts') +EXCEL_PATH = os.path.join(os.path.dirname(__file__), '..', 'data', 'KNADIAN.xlsx') +SUPPLIER_NAME = 'KNADIAN' +MAX_IMPORT_YEAR = datetime.now().year + 1 # reject future years from bad supplier data + +MULTI_WORD_MAKES = { + ('MERCEDES', 'BENZ'): 'MERCEDES BENZ', + ('LAND', 'ROVER'): 'LAND ROVER', + ('ALFA', 'ROMEO'): 'ALFA ROMEO', + ('AMERICAN', 'MOTORS'): 'AMERICAN MOTORS', + ('ROLLS', 'ROYCE'): 'ROLLS ROYCE', + ('ASTON', 'MARTIN'): 'ASTON MARTIN', + ('GREAT', 'WALL'): 'GREAT WALL', +} + + +def connect_master(): + return psycopg2.connect(MASTER_DB_URL) + + +def normalize_name(name): + if not name: + return '' + return ' '.join(str(name).replace('\n', ' ').split()) + + +def parse_year_token(token): + """Parse a year token like '05', '1998', '2015'.""" + if not token or not re.match(r'^\d+$', str(token)): + return None + val = int(token) + if 1000 <= val <= 2100: + return val + if 70 <= val <= 99: + return 1900 + val + if 0 <= val <= 69: + return 2000 + val + return None + + +def extract_years(text): + """Extract year(s) from end of a string like '05/10', '2011', '1315', '97/99'.""" + if not text: + return [None], '' + s = str(text).strip() + + # Try trailing range with / or -: YY/YY, YYYY-YYYY, YY-YY + m = re.search(r'\s+(\d{2,4})\s*[-/]\s*(\d{2,4})$', s) + if m: + start = parse_year_token(m.group(1)) + end = parse_year_token(m.group(2)) + if start and end: + if end < start: + start, end = end, start + if end - start <= 100: + rest = s[:m.start()].strip() + return list(range(start, end + 1)), rest + + # Try trailing 4-digit year + m = re.search(r'\s+(19|20)\d{2}$', s) + if m: + year = int(m.group(0).strip()) + rest = s[:m.start()].strip() + return [year], rest + + # Try trailing 4 consecutive digits that look like a merged range: 1315 -> 2013,2014,2015 + m = re.search(r'\s+(\d{4})$', s) + if m: + digits = m.group(1) + # If first two and last two are valid years, treat as range + y1 = parse_year_token(digits[:2]) + y2 = parse_year_token(digits[2:]) + if y1 and y2 and y1 <= y2 and y2 - y1 <= 30: + rest = s[:m.start()].strip() + return list(range(y1, y2 + 1)), rest + + return [None], s + + +def parse_carro(carro): + """Parse CARRO_PERTENECIENTE like 'ACURA TL 05/10' -> make, model, years.""" + if not carro: + return {'make': None, 'model': None, 'years': [None], 'raw': carro} + + s = str(carro).strip() + years, rest = extract_years(s) + + parts = rest.split() + if not parts: + return {'make': None, 'model': None, 'years': years, 'raw': s} + + # Extract make + make = parts[0].upper() + if len(parts) >= 2: + key = (parts[0].upper(), parts[1].upper()) + if key in MULTI_WORD_MAKES: + make = MULTI_WORD_MAKES[key] + parts = parts[2:] + else: + parts = parts[1:] + else: + parts = parts[1:] + + model = ' '.join(parts) if parts else None + + return { + 'make': make, + 'model': model, + 'years': years, + 'raw': s, + } + + +def extract_engine(name): + """Extract engine description from NOMBRE_PIEZA like 'BOMBA_REFRIGERANTE L4 2.0'.""" + if not name: + return None + s = normalize_name(name) + parts = s.split() + if len(parts) <= 1: + return None + # Everything after first word + engine = ' '.join(parts[1:]) + # Filter out meaningless tokens that should not be engines + if engine.upper() in {'DEL.', 'TRAS.', 'FRONT.', 'EXT.', 'IZQ.', 'DER.', 'INF.', 'SUP.', 'TRANS.'}: + return None + return engine or None + + +def extract_interchanges(row): + """Extract (brand, part_number) pairs from interchange columns. + KNADIAN: interchanges start at col 3 (MARCA.1) through col 15 (INTERCAMBIO.5). + """ + interchanges = [] + for i in range(6): + marca_col = 3 + i * 2 + inter_col = 4 + i * 2 + if marca_col < len(row) and row[marca_col]: + brand = str(row[marca_col]).strip() + pn = str(row[inter_col]).strip() if inter_col < len(row) and row[inter_col] else '' + if brand and pn: + interchanges.append((brand, pn)) + return interchanges + + +def main(): + print(f"[{datetime.now().isoformat()}] Starting KNADIAN import...") + + if not os.path.exists(EXCEL_PATH): + print(f"ERROR: Excel not found at {EXCEL_PATH}") + sys.exit(1) + + print(f"Loading {EXCEL_PATH}...") + wb = load_workbook(EXCEL_PATH, read_only=True, data_only=True) + + master_conn = connect_master() + master_cur = master_conn.cursor() + + upsert_catalog_sql = """ + INSERT INTO supplier_catalog (supplier_name, sku, name, category, is_active) + VALUES (%s, %s, %s, %s, true) + ON CONFLICT (supplier_name, sku, category) DO UPDATE SET + name = EXCLUDED.name, + category = EXCLUDED.category, + is_active = true + RETURNING id + """ + + insert_compat_sql = """ + INSERT INTO supplier_catalog_compat + (catalog_id, make, model, year, engine, model_year_engine_id, source) + VALUES (%s, %s, %s, %s, %s, NULL, %s) + ON CONFLICT (catalog_id, make, model, year, engine) DO NOTHING + """ + + insert_interchange_sql = """ + INSERT INTO supplier_catalog_interchange (catalog_id, brand, part_number) + VALUES (%s, %s, %s) + ON CONFLICT DO NOTHING + """ + + stats = defaultdict(int) + + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + rows = list(ws.iter_rows(values_only=True)) + if not rows: + continue + data_rows = rows[1:] + stats['sheets'] += 1 + print(f"\nProcessing sheet '{sheet_name}' with {len(data_rows)} rows...") + + catalog_id_cache = {} + + for idx, row in enumerate(data_rows): + if idx % 2000 == 0 and idx > 0: + print(f" ...{idx} rows processed") + + if not row or len(row) < 3 or not row[2]: + stats['skipped_no_sku'] += 1 + continue + + make_col = str(row[0]).strip().upper() if row[0] else '' + model_col = str(row[1]).strip() if row[1] else '' + sku = str(row[2]).strip() + name = normalize_name(row[15]) if len(row) > 15 and row[15] else sheet_name + carro = str(row[16]).strip() if len(row) > 16 and row[16] else '' + + if not sku: + stats['skipped_no_sku'] += 1 + continue + + # Always try to parse year from CARRO_PERTENECIENTE + parsed = parse_carro(carro) + years = parsed['years'] + + # Prefer explicit make/model columns; fallback to parsed carro + if make_col: + make = make_col + else: + make = parsed['make'] + + if model_col: + model = model_col + else: + model = parsed['model'] + + # If year still missing, maybe the model column itself contains a year + if years == [None] and model_col: + years, _ = extract_years(model_col) + + if not make or not model: + stats['skipped_no_vehicle'] += 1 + continue + + # Filter out future years and de-duplicate + filtered_years = [] + for y in years: + if y is None: + if None not in filtered_years: + filtered_years.append(None) + elif y <= MAX_IMPORT_YEAR: + if y not in filtered_years: + filtered_years.append(y) + years = filtered_years if filtered_years else [None] + + stats['rows'] += 1 + + # Upsert catalog item (keyed by sku) + cache_key = sku + catalog_id = catalog_id_cache.get(cache_key) + if catalog_id is None: + master_cur.execute(upsert_catalog_sql, (SUPPLIER_NAME, sku, name, sheet_name)) + row_result = master_cur.fetchone() + catalog_id = row_result[0] if row_result else None + catalog_id_cache[cache_key] = catalog_id + stats['catalog_items'] += 1 + + if catalog_id is None: + stats['skipped_no_catalog'] += 1 + continue + + engine = extract_engine(name) + + for year in years: + master_cur.execute(insert_compat_sql, ( + catalog_id, + make, + model, + year, + engine, + 'import_text', + )) + stats['compat_rows'] += 1 + + interchanges = extract_interchanges(row) + for brand, pn in interchanges: + master_cur.execute(insert_interchange_sql, (catalog_id, brand, pn)) + stats['interchange_rows'] += 1 + + master_conn.commit() + print(f" Sheet '{sheet_name}' committed.") + + print(f"\n{'='*60}") + print("IMPORT COMPLETE") + print(f"{'='*60}") + for k, v in sorted(stats.items()): + print(f"{k:25s}: {v}") + + master_cur.close() + master_conn.close() + + +if __name__ == '__main__': + main() diff --git a/scripts/import_luk_catalog.py b/scripts/import_luk_catalog.py new file mode 100644 index 0000000..5c1f4df --- /dev/null +++ b/scripts/import_luk_catalog.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +""" +Import LUK catalog from Excel into supplier_catalog tables. + +Usage: + python scripts/import_luk_catalog.py +""" + +import os +import re +import sys +from collections import Counter +from datetime import datetime + +import psycopg2 +from openpyxl import load_workbook + +MASTER_DB_URL = os.environ.get('MASTER_DB_URL', 'postgresql://postgres@localhost/nexus_autoparts') +TENANT_DB_URL = os.environ.get('TENANT_DB_URL', 'postgresql://postgres@localhost/tenant_refaccionaria_rached') + +EXCEL_PATH = os.path.join(os.path.dirname(__file__), '..', 'data', 'LUK.xlsx') +SUPPLIER_NAME = 'LUK' +TENANT_ID = 31 + +MULTI_WORD_MAKES = { + ('ALFA', 'ROMEO'): 'ALFA ROMEO', + ('MERCEDES', 'BENZ'): 'MERCEDES BENZ', + ('MG', 'ROVER'): 'MG ROVER', +} + +NOTE_KEYWORDS = { + 'VOLANTE', 'SÓLIDO', 'SOLIDO', 'TIPO', 'CAJA', 'PLANO', + 'ESCALÓN', 'ESCALON', 'MOTOR', 'EMBRAGUE', 'DOBLE', 'HUMEDO', +} + + +def connect_master(): + return psycopg2.connect(MASTER_DB_URL) + + +def connect_tenant(): + return psycopg2.connect(TENANT_DB_URL) + + +def normalize_name(name): + if not name: + return '' + return ' '.join(str(name).replace('\n', ' ').split()) + + +def parse_luk(carro): + """Parse CARRO_PERTENECIENTE into make, model, year.""" + if not carro: + return None, None, None + s = ' '.join(str(carro).strip().split()) + if not s: + return None, None, None + + parts = s.split() + + # Extract year (last occurrence of 19xx or 20xx) + year = None + year_idx = None + for i in range(len(parts)): + if re.match(r'^(19|20)\d{2}$', parts[i]): + year = int(parts[i]) + year_idx = i + + # Extract make + make = parts[0] if parts else '' + make_len = 1 + if len(parts) >= 2: + key2 = (parts[0].upper(), parts[1].upper()) + if key2 in MULTI_WORD_MAKES: + make = MULTI_WORD_MAKES[key2] + make_len = 2 + elif len(parts) >= 3 and parts[0].upper() == 'CHRYSLER' and parts[1] == '/' and parts[2].upper() == 'DODGE': + make = 'CHRYSLER / DODGE' + make_len = 3 + + # Remaining parts between make and year + if year_idx is not None: + remaining = parts[make_len:year_idx] + parts[year_idx + 1:] + else: + remaining = parts[make_len:] + + # Clean note keywords + cleaned = [p for p in remaining if p.upper() not in NOTE_KEYWORDS] + model = ' '.join(cleaned) + + # If empty after cleaning, use original remaining text + if not model and remaining: + model = ' '.join(remaining) + + return make, model, year + + +def extract_interchanges(row): + """Extract (brand, part_number) pairs from 4 interchange columns.""" + interchanges = [] + for i in range(4): + marca_col = 2 + i * 2 + inter_col = 3 + i * 2 + if marca_col < len(row) and row[marca_col]: + brand = str(row[marca_col]).strip() + pn = str(row[inter_col]).strip() if inter_col < len(row) and row[inter_col] else '' + if brand and pn: + interchanges.append((brand, pn)) + return interchanges + + +def main(): + print(f"[{datetime.now().isoformat()}] Starting LUK import...") + + if not os.path.exists(EXCEL_PATH): + print(f"ERROR: Excel not found at {EXCEL_PATH}") + sys.exit(1) + + print(f"Loading {EXCEL_PATH}...") + wb = load_workbook(EXCEL_PATH, read_only=True, data_only=True) + ws = wb['KIT_CLUTCH'] + + master_conn = connect_master() + master_conn = connect_master() + master_cur = master_conn.cursor() + + # Pre-scan: determine most common name per SKU + print("Pre-scanning SKUs...") + sku_name_counter = Counter() + for row in ws.iter_rows(min_row=2, values_only=True): + sku = str(row[1]).strip() if row[1] else '' + name = normalize_name(row[10]) + if sku and name: + sku_name_counter[(sku, name)] += 1 + + sku_best_name = {} + for (sku, name), count in sku_name_counter.items(): + if sku not in sku_best_name or count > sku_best_name[sku][1]: + sku_best_name[sku] = (name, count) + + print(f" Found {len(sku_best_name)} unique SKUs") + + upsert_catalog_sql = """ + INSERT INTO supplier_catalog (supplier_name, sku, name, category) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (supplier_name, sku, category) DO UPDATE SET + name = EXCLUDED.name, + category = EXCLUDED.category + RETURNING id + """ + + insert_compat_sql = """ + INSERT INTO supplier_catalog_compat + (catalog_id, make, model, year, engine, model_year_engine_id, source) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (catalog_id, make, model, year, engine) DO NOTHING + """ + + insert_interchange_sql = """ + INSERT INTO supplier_catalog_interchange (catalog_id, brand, part_number) + VALUES (%s, %s, %s) + ON CONFLICT DO NOTHING + """ + + stats = { + 'rows': 0, + 'catalog_items': 0, + 'compat_rows': 0, + 'interchange_rows': 0, + 'vehicles_parsed': 0, + } + + catalog_id_cache = {} + + for idx, row in enumerate(ws.iter_rows(min_row=2, values_only=True)): + if idx % 1000 == 0 and idx > 0: + print(f" ...{idx} rows processed") + + if not row or not row[1]: + continue + + sku = str(row[1]).strip() + name = sku_best_name.get(sku, ('', 0))[0] + carro_raw = str(row[11]).strip() if row[11] else '' + + if not sku or not name: + continue + + stats['rows'] += 1 + + cache_key = (sku, 'KIT_CLUTCH') + catalog_id = catalog_id_cache.get(cache_key) + if catalog_id is None: + master_cur.execute(upsert_catalog_sql, (SUPPLIER_NAME, sku, name, 'KIT_CLUTCH')) + catalog_id = master_cur.fetchone()[0] + catalog_id_cache[cache_key] = catalog_id + stats['catalog_items'] += 1 + + parsed = parse_luk(carro_raw) + stats['vehicles_parsed'] += 1 + + master_cur.execute(insert_compat_sql, ( + catalog_id, + parsed[0], + parsed[1], + parsed[2], + None, + None, + 'import_text', + )) + stats['compat_rows'] += 1 + + interchanges = extract_interchanges(row) + for brand, pn in interchanges: + master_cur.execute(insert_interchange_sql, (catalog_id, brand, pn)) + stats['interchange_rows'] += 1 + + master_conn.commit() + + print(f"\n{'='*60}") + print("IMPORT COMPLETE") + print(f"{'='*60}") + print(f"Total rows read: {stats['rows']}") + print(f"Catalog items: {stats['catalog_items']}") + print(f"Compat rows: {stats['compat_rows']}") + print(f"Interchange rows: {stats['interchange_rows']}") + print(f"Vehicles parsed: {stats['vehicles_parsed']}") + + master_cur.close() + master_conn.close() + master_conn.close() + + +if __name__ == '__main__': + main() diff --git a/scripts/import_rached_excel.py b/scripts/import_rached_excel.py new file mode 100755 index 0000000..f8f19b2 --- /dev/null +++ b/scripts/import_rached_excel.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +""" +Importar inventario de refaccionaria_rached desde Excel. + +Archivo fuente: /home/Autopartes/data/PRODUCTOS_RACHED_2026.xlsx +Hoja: Hoja1 +Columnas: + A: Codigo -> part_number + B: CB -> barcode (ignored, mostly empty) + C: Cve -> sku_alias (inventory_sku_aliases) + D: Descripcion -> name + E: Precio Costo -> cost + F: Precio Venta -> price_1 + +No hay columnas de stock, marca, ni vehiculo. Stock se deja en 0. +""" + +import os +import sys +import re + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'pos')) + +import psycopg2 +from services.barcode_generator import generate_barcodes_batch + +# ─── Config ────────────────────────────────────────── +DB_NAME = "tenant_refaccionaria_rached" +BRANCH_ID = 1 +EXCEL_PATH = "/home/Autopartes/data/PRODUCTOS_RACHED_2026.xlsx" +BATCH_SIZE = 500 + +# Connect as local postgres user (peer auth) +conn = psycopg2.connect(f"dbname={DB_NAME} user=postgres") +conn.autocommit = False +cur = conn.cursor() + +# ─── Read Excel ────────────────────────────────────── +import openpyxl +wb = openpyxl.load_workbook(EXCEL_PATH, data_only=True) +ws = wb["Hoja1"] +rows = list(ws.iter_rows(min_row=2, values_only=True)) +print(f"Filas leidas del Excel: {len(rows)}") + +# ─── Pre-fetch existing part_numbers ───────────────── +existing_map = {} +cur.execute("SELECT id, part_number FROM inventory WHERE branch_id = %s", (BRANCH_ID,)) +for item_id, pn in cur.fetchall(): + existing_map[pn.strip().upper()] = item_id +cur.close() +conn.commit() + +# ─── Prepare lists ─────────────────────────────────── +to_insert = [] # (part_number, name, cost, price_1) +to_alias = [] # (part_number, alias_sku) +skipped = 0 + +for row in rows: + codigo = str(row[0]).strip() if row[0] is not None else "" + cve = str(row[2]).strip() if row[2] is not None else "" + descripcion = str(row[3]).strip() if row[3] is not None else "" + precio_costo = float(row[4]) if row[4] is not None else 0.0 + precio_venta = float(row[5]) if row[5] is not None else 0.0 + + if not codigo or not descripcion: + skipped += 1 + continue + + # Clean description (remove weird chars) + descripcion = descripcion.replace("\x81", "").replace("\x80", "").strip() + + to_insert.append((codigo, descripcion, precio_costo, precio_venta)) + if cve: + to_alias.append((codigo, cve)) + +print(f"Filas validas para importar: {len(to_insert)}") +print(f"Filas con SKU alternativo (Cve): {len(to_alias)}") +print(f"Filas saltadas (sin codigo/descripcion): {skipped}") + +# ─── Batch insert / update inventory ───────────────── +cur = conn.cursor() +inserted_count = 0 +updated_count = 0 + +# Split into new vs existing +new_items = [] +update_items = [] +for codigo, descripcion, cost, price in to_insert: + key = codigo.upper() + if key in existing_map: + update_items.append((descripcion, cost, price, existing_map[key])) + else: + new_items.append((codigo, descripcion, cost, price)) + +print(f"Nuevos: {len(new_items)} | Existentes a actualizar: {len(update_items)}") + +# Generate barcodes for new items in batch +barcodes = [] +if new_items: + barcodes = generate_barcodes_batch(conn, DB_NAME, len(new_items)) + +# Insert new items +for i, (codigo, descripcion, cost, price) in enumerate(new_items): + barcode = barcodes[i] + cur.execute( + """ + INSERT INTO inventory + (branch_id, part_number, barcode, name, cost, price_1, unit, is_active) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (branch_id, part_number) DO UPDATE SET + name = EXCLUDED.name, + cost = CASE WHEN EXCLUDED.cost > 0 THEN EXCLUDED.cost ELSE inventory.cost END, + price_1 = CASE WHEN EXCLUDED.price_1 > 0 THEN EXCLUDED.price_1 ELSE inventory.price_1 END + RETURNING id, (xmax = 0) AS inserted + """, + (BRANCH_ID, codigo, barcode, descripcion, cost, price, "PZA", True) + ) + item_id, was_inserted = cur.fetchone() + if was_inserted: + inserted_count += 1 + else: + updated_count += 1 + # Add to map for alias linking + existing_map[codigo.upper()] = item_id + + if (i + 1) % BATCH_SIZE == 0: + conn.commit() + print(f" Procesados {i + 1}/{len(new_items)} nuevos...") + +# Update existing items (that weren't caught by ON CONFLICT above, if any) +for descripcion, cost, price, item_id in update_items: + cur.execute( + """ + UPDATE inventory SET + name = %s, + cost = CASE WHEN %s > 0 THEN %s ELSE cost END, + price_1 = CASE WHEN %s > 0 THEN %s ELSE price_1 END + WHERE id = %s + """, + (descripcion, cost, cost, price, price, item_id) + ) + updated_count += 1 + +conn.commit() +print(f"Insertados: {inserted_count} | Actualizados: {updated_count}") + +# ─── Insert SKU aliases ────────────────────────────── +alias_inserted = 0 +alias_skipped = 0 +for codigo, cve in to_alias: + item_id = existing_map.get(codigo.upper()) + if not item_id: + alias_skipped += 1 + continue + try: + cur.execute( + """ + INSERT INTO inventory_sku_aliases (inventory_id, sku, label) + VALUES (%s, %s, %s) + ON CONFLICT (inventory_id, sku) DO NOTHING + """, + (item_id, cve, "Cve") + ) + if cur.rowcount > 0: + alias_inserted += 1 + except Exception as e: + print(f" Alias error for {codigo}/{cve}: {e}") + alias_skipped += 1 + +conn.commit() +cur.close() +conn.close() + +print("\n========================================") +print("IMPORTACION RACHED COMPLETADA") +print("========================================") +print(f"Filas procesadas: {len(to_insert)}") +print(f"Nuevos insertados: {inserted_count}") +print(f"Exist. actualizados:{updated_count}") +print(f"SKU aliases creados:{alias_inserted}") +print(f"Aliases fallidos: {alias_skipped}") +print(f"Filas saltadas: {skipped}") +print("========================================") diff --git a/scripts/import_raybestos_catalog.py b/scripts/import_raybestos_catalog.py new file mode 100644 index 0000000..9f3668d --- /dev/null +++ b/scripts/import_raybestos_catalog.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +""" +Import Raybestos catalog from Excel into supplier_catalog tables. + +Usage: + python scripts/import_raybestos_catalog.py +""" + +import os +import re +import sys +from collections import Counter +from datetime import datetime + +import psycopg2 +from openpyxl import load_workbook + +MASTER_DB_URL = os.environ.get('MASTER_DB_URL', 'postgresql://postgres@localhost/nexus_autoparts') +TENANT_DB_URL = os.environ.get('TENANT_DB_URL', 'postgresql://postgres@localhost/tenant_refaccionaria_rached') + +EXCEL_PATH = os.path.join(os.path.dirname(__file__), '..', 'data', 'RAYBESTOS.xlsx') +SUPPLIER_NAME = 'RAYBESTOS' +TENANT_ID = 31 + +KNOWN_MAKES = { + 'ACURA', 'ALFA', 'AMERICAN', 'ASTON', 'AUDI', 'BMW', 'BUICK', 'CADILLAC', + 'CHEVROLET', 'CHRYSLER', 'CITROEN', 'DAEWOO', 'DODGE', 'FIAT', 'FORD', + 'GMC', 'GREAT', 'HONDA', 'HYUNDAI', 'INFINITI', 'ISUZU', 'JAGUAR', 'JEEP', + 'KIA', 'LAMBORGHINI', 'LAND', 'LEXUS', 'LINCOLN', 'MAZDA', 'MERCEDES', + 'MERCURY', 'MINI', 'MITSUBISHI', 'NISSAN', 'PEUGEOT', 'PONTIAC', 'PORSCHE', + 'RENAULT', 'ROLLS', 'SATURN', 'SCION', 'SEAT', 'SKODA', 'SMART', 'SUBARU', + 'SUZUKI', 'TESLA', 'TOYOTA', 'VOLKSWAGEN', 'VOLSWAGEN', 'VOLVO', 'VW' +} + +POS_KEYWORDS = {'DELANTERA', 'TRASERA', 'TAS', 'DEL', 'TRAS', 'FRONT', 'REAR', 'LAT', 'IZQ', 'DER'} +NOTE_KEYWORDS = {'LATIN', 'AMERICA', 'NACIONAL', 'USA', 'EUROPA', 'IMPORTADO'} + + +def connect_master(): + return psycopg2.connect(MASTER_DB_URL) + + +def connect_tenant(): + return psycopg2.connect(TENANT_DB_URL) + + +def normalize_name(name): + if not name: + return '' + return ' '.join(str(name).replace('\n', ' ').split()) + + +def parse_abbr_year(token): + if not token or not token.isdigit(): + return None + n = int(token) + if n < 50: + return 2000 + n + if n < 100: + return 1900 + n + return None + + +def extract_make(parts): + """Return (make, make_len) if first words form a known make, else (None, 0).""" + if not parts: + return None, 0 + first = parts[0].upper() + if first not in KNOWN_MAKES: + return None, 0 + if first == 'ALFA' and len(parts) >= 2 and parts[1].upper() == 'ROMEO': + return 'ALFA ROMEO', 2 + if first == 'MERCEDES' and len(parts) >= 2 and parts[1].upper() == 'BENZ': + return 'MERCEDES BENZ', 2 + if first == 'ROLLS' and len(parts) >= 2 and parts[1].upper() == 'ROYCE': + return 'ROLLS ROYCE', 2 + if first == 'LAND' and len(parts) >= 2 and parts[1].upper() == 'ROVER': + return 'LAND ROVER', 2 + if first == 'GREAT' and len(parts) >= 2 and parts[1].upper() == 'WALL': + return 'GREAT WALL', 2 + if first == 'AMERICAN' and len(parts) >= 2 and parts[1].upper() == 'MOTORS': + return 'AMERICAN MOTORS', 2 + if first == 'ASTON' and len(parts) >= 2 and parts[1].upper() == 'MARTIN': + return 'ASTON MARTIN', 2 + # Normalize common typos + if first == 'VOLSWAGEN': + return 'Volkswagen', 1 + if first == 'VW': + return 'Volkswagen', 1 + return parts[0], 1 + + +def parse_raybestos(carro, last_make): + if not carro: + return None, None, None, None, last_make + s = ' '.join(str(carro).strip().split()) + if not s: + return None, None, None, None, last_make + + parts = s.split() + + # Extract 4-digit year from end + year = None + if parts and re.match(r'^(19|20)\d{2}$', parts[-1]): + year = int(parts[-1]) + parts = parts[:-1] + + # Extract make + make, make_len = extract_make(parts) + if make: + last_make = make + remaining = parts[make_len:] + elif last_make: + make = last_make + remaining = parts[:] + else: + make = None + remaining = parts[:] + + # Extract abbreviated year or year range from remaining + if year is None and remaining: + for i in range(len(remaining)): + # Year range like 17-18, 90-05 + m = re.match(r'^(\d{2})-(\d{2})$', remaining[i]) + if m: + year = parse_abbr_year(m.group(2)) # use end year + remaining = remaining[:i] + remaining[i + 1:] + break + # Single 2-digit year + if re.match(r'^\d{2}$', remaining[i]): + y = parse_abbr_year(remaining[i]) + if y: + year = y + remaining = remaining[:i] + remaining[i + 1:] + break + + # Extract position keywords and notes + position = None + cleaned = [] + for p in remaining: + pup = p.upper() + if pup in POS_KEYWORDS: + if pup == 'TAS': + position = 'TRASERA' + elif pup in ('DEL', 'FRONT'): + position = 'DELANTERA' + elif pup in ('TRAS', 'REAR'): + position = 'TRASERA' + else: + position = pup.title() + elif pup in NOTE_KEYWORDS: + pass # skip notes + else: + cleaned.append(p) + + model = ' '.join(cleaned) + + return make, model, position, year, last_make + + +def extract_interchanges(row): + """Extract (brand, part_number) pairs from 2 interchange columns.""" + interchanges = [] + for i in range(2): + marca_col = 2 + i * 2 + inter_col = 3 + i * 2 + if marca_col < len(row) and row[marca_col]: + brand = str(row[marca_col]).strip() + pn = str(row[inter_col]).strip() if inter_col < len(row) and row[inter_col] else '' + if brand and pn: + interchanges.append((brand, pn)) + return interchanges + + +def main(): + print(f"[{datetime.now().isoformat()}] Starting Raybestos import...") + + if not os.path.exists(EXCEL_PATH): + print(f"ERROR: Excel not found at {EXCEL_PATH}") + sys.exit(1) + + print(f"Loading {EXCEL_PATH}...") + wb = load_workbook(EXCEL_PATH, read_only=True, data_only=True) + ws = wb['Freno_de_disco'] + + master_conn = connect_master() + master_conn = connect_master() + master_cur = master_conn.cursor() + + # Pre-scan: determine most common name per SKU + print("Pre-scanning SKUs...") + sku_name_counter = Counter() + for row in ws.iter_rows(min_row=2, values_only=True): + sku = str(row[1]).strip() if row[1] else '' + name = normalize_name(row[6]) + if sku and name: + sku_name_counter[(sku, name)] += 1 + + sku_best_name = {} + for (sku, name), count in sku_name_counter.items(): + if sku not in sku_best_name or count > sku_best_name[sku][1]: + sku_best_name[sku] = (name, count) + + print(f" Found {len(sku_best_name)} unique SKUs") + + upsert_catalog_sql = """ + INSERT INTO supplier_catalog (supplier_name, sku, name, category) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (supplier_name, sku, category) DO UPDATE SET + name = EXCLUDED.name, + category = EXCLUDED.category + RETURNING id + """ + + insert_compat_sql = """ + INSERT INTO supplier_catalog_compat + (catalog_id, make, model, year, engine, model_year_engine_id, source) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (catalog_id, make, model, year, engine) DO NOTHING + """ + + insert_interchange_sql = """ + INSERT INTO supplier_catalog_interchange (catalog_id, brand, part_number) + VALUES (%s, %s, %s) + ON CONFLICT DO NOTHING + """ + + stats = { + 'rows': 0, + 'catalog_items': 0, + 'compat_rows': 0, + 'interchange_rows': 0, + 'vehicles_parsed': 0, + 'forward_filled_make': 0, + } + + catalog_id_cache = {} + last_make = None + + for idx, row in enumerate(ws.iter_rows(min_row=2, values_only=True)): + if idx % 1000 == 0 and idx > 0: + print(f" ...{idx} rows processed") + + if not row or not row[1]: + continue + + sku = str(row[1]).strip() + name = sku_best_name.get(sku, ('', 0))[0] + carro_raw = str(row[7]).strip() if row[7] else '' + + if not sku or not name: + continue + + stats['rows'] += 1 + + cache_key = (sku, 'Freno_de_disco') + catalog_id = catalog_id_cache.get(cache_key) + if catalog_id is None: + master_cur.execute(upsert_catalog_sql, (SUPPLIER_NAME, sku, name, 'Freno_de_disco')) + catalog_id = master_cur.fetchone()[0] + catalog_id_cache[cache_key] = catalog_id + stats['catalog_items'] += 1 + + make, model, position, year, last_make = parse_raybestos(carro_raw, last_make) + if make and carro_raw and not extract_make(carro_raw.split())[0]: + stats['forward_filled_make'] += 1 + stats['vehicles_parsed'] += 1 + + master_cur.execute(insert_compat_sql, ( + catalog_id, + make, + model, + year, + position, + None, + 'import_text', + )) + stats['compat_rows'] += 1 + + interchanges = extract_interchanges(row) + for brand, pn in interchanges: + master_cur.execute(insert_interchange_sql, (catalog_id, brand, pn)) + stats['interchange_rows'] += 1 + + master_conn.commit() + + print(f"\n{'='*60}") + print("IMPORT COMPLETE") + print(f"{'='*60}") + print(f"Total rows read: {stats['rows']}") + print(f"Catalog items: {stats['catalog_items']}") + print(f"Compat rows: {stats['compat_rows']}") + print(f"Interchange rows: {stats['interchange_rows']}") + print(f"Vehicles parsed: {stats['vehicles_parsed']}") + print(f"Forward-filled makes: {stats['forward_filled_make']}") + + master_cur.close() + master_conn.close() + master_conn.close() + + +if __name__ == '__main__': + main() diff --git a/scripts/import_vazlo_catalog.py b/scripts/import_vazlo_catalog.py new file mode 100644 index 0000000..28c4241 --- /dev/null +++ b/scripts/import_vazlo_catalog.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +""" +Import VAZLO catalog from Excel into supplier_catalog tables. + +Usage: + python scripts/import_vazlo_catalog.py +""" + +import os +import re +import sys +from collections import defaultdict +from datetime import datetime + +import psycopg2 +from openpyxl import load_workbook + +# DB connections +MASTER_DB_URL = os.environ.get('MASTER_DB_URL', 'postgresql://postgres@localhost/nexus_autoparts') +TENANT_DB_URL = os.environ.get('TENANT_DB_URL', 'postgresql://postgres@localhost/tenant_refaccionaria_rached') + +EXCEL_PATH = os.path.join(os.path.dirname(__file__), '..', 'data', 'VAZLO (1).xlsx') +SUPPLIER_NAME = 'VAZLO' +TENANT_ID = 31 + +POS_KEYWORDS = { + 'DEL.', 'TRAS.', 'FRONT.', 'EXT.', 'IZQ.', 'DER.', 'RUEDA', 'CAJA', + 'INF.', 'SUP.', 'TRANS.', 'STD', 'AWD', '2/4WD', '4WD', 'FWD', 'RWD', + '4X4', 'TURBO', 'GASOLINA', 'DIESEL', + 'DEL', 'TRAS', 'FRONT', 'EXT', 'IZQ', 'DER', 'INF', 'SUP', 'TRANS', +} + +MULTI_WORD_MAKES = { + ('MERCEDES', 'BENZ'): 'MERCEDES BENZ', + ('LAND', 'ROVER'): 'LAND ROVER', + ('ALFA', 'ROMEO'): 'ALFA ROMEO', + ('AMERICAN', 'MOTORS'): 'AMERICAN MOTORS', + ('ROLLS', 'ROYCE'): 'ROLLS ROYCE', + ('ASTON', 'MARTIN'): 'ASTON MARTIN', + ('GREAT', 'WALL'): 'GREAT WALL', +} + + +def connect_master(): + return psycopg2.connect(MASTER_DB_URL) + + +def connect_tenant(): + return psycopg2.connect(TENANT_DB_URL) + + +def collect_all_skus(wb): + """Pre-scan all SKUs to detect SKU-in-model cases.""" + skus = set() + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + for row in ws.iter_rows(min_row=2, values_only=True): + sku = str(row[1]).strip() if row[1] else '' + if sku: + skus.add(sku) + return skus + + +def parse_carro(carro, all_skus): + """ + Parse CARRO_PERTENECIENTE like: + 'ACURA TL DEL. 2015' + 'BMW X1 SDRIVE 20IA TRAS. 2018' + 'ACURA TL FRONT. DER. 2004' + 'AUDI 4000S CAJA 1980' + 'MERCEDES BENZ C350 E --' + 'ACURA TLX 3429' (3429 is a SKU inserted into model) + + Returns dict with make, model, year, position, raw. + """ + if not carro: + return {'make': None, 'model': None, 'year': None, 'position': None, 'raw': carro} + + s = str(carro).strip() + parts = s.split() + if not parts: + return {'make': None, 'model': None, 'year': None, 'position': None, 'raw': s} + + # Extract year from end + year = None + if re.match(r'^(19|20)\d{2}$', parts[-1]): + year = int(parts[-1]) + parts = parts[:-1] + + # Remove trailing '--' (no-year marker) + if parts and parts[-1] == '--': + parts = parts[:-1] + + # Extract make + make = parts[0] if parts else '' + if len(parts) >= 2: + key = (parts[0].upper(), parts[1].upper()) + if key in MULTI_WORD_MAKES: + make = MULTI_WORD_MAKES[key] + parts = parts[2:] + else: + parts = parts[1:] + else: + parts = parts[1:] + + # Extract position keywords from the end + position_parts = [] + while parts and parts[-1].upper() in POS_KEYWORDS: + position_parts.insert(0, parts[-1]) + parts = parts[:-1] + + model = ' '.join(parts) + + # Remove trailing SKU numbers that match known VAZLO SKUs + # e.g. "ACURA TLX 3429" -> model="TLX", sku_suffix="3429" + model_parts = model.split() + if model_parts and re.match(r'^\d{3,4}$', model_parts[-1]) and model_parts[-1] in all_skus: + model = ' '.join(model_parts[:-1]) + + return { + 'make': make, + 'model': model, + 'year': year, + 'position': ' '.join(position_parts), + 'raw': s, + } + + +def extract_interchanges(row): + """Extract (brand, part_number) pairs from all 11 interchange columns.""" + interchanges = [] + for i in range(11): + marca_col = 2 + i * 2 + inter_col = 3 + i * 2 + if marca_col < len(row) and row[marca_col]: + brand = str(row[marca_col]).strip() + pn = str(row[inter_col]).strip() if inter_col < len(row) and row[inter_col] else '' + if brand and pn: + interchanges.append((brand, pn)) + return interchanges + + +def normalize_name(name): + """Clean up piece name: collapse whitespace, replace newlines.""" + if not name: + return '' + return ' '.join(str(name).replace('\n', ' ').split()) + + +def main(): + print(f"[{datetime.now().isoformat()}] Starting VAZLO import...") + + if not os.path.exists(EXCEL_PATH): + print(f"ERROR: Excel not found at {EXCEL_PATH}") + sys.exit(1) + + print(f"Loading {EXCEL_PATH}...") + wb = load_workbook(EXCEL_PATH, read_only=True, data_only=True) + + # Pre-scan SKUs for SKU-in-model detection + print("Pre-scanning SKUs...") + all_skus = collect_all_skus(wb) + print(f" Found {len(all_skus)} unique SKUs") + + master_conn = connect_master() + master_conn = connect_master() + master_cur = master_conn.cursor() + + upsert_catalog_sql = """ + INSERT INTO supplier_catalog (supplier_name, sku, name, category, is_active) + VALUES (%s, %s, %s, %s, true) + ON CONFLICT (supplier_name, sku, category) DO UPDATE SET + name = EXCLUDED.name, + category = EXCLUDED.category, + is_active = true + RETURNING id + """ + + insert_compat_sql = """ + INSERT INTO supplier_catalog_compat + (catalog_id, make, model, year, engine, model_year_engine_id, source) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (catalog_id, make, model, year, engine) DO NOTHING + """ + + insert_interchange_sql = """ + INSERT INTO supplier_catalog_interchange (catalog_id, brand, part_number) + VALUES (%s, %s, %s) + ON CONFLICT DO NOTHING + """ + + stats = { + 'sheets': 0, + 'rows': 0, + 'catalog_items': 0, + 'compat_rows': 0, + 'interchange_rows': 0, + 'vehicles_parsed': 0, + 'skipped_no_sku': 0, + 'skipped_no_carro': 0, + } + + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + rows = list(ws.iter_rows(values_only=True)) + if not rows: + continue + data_rows = rows[1:] + stats['sheets'] += 1 + print(f"\nProcessing sheet '{sheet_name}' with {len(data_rows)} rows...") + + # Cache catalog_id per (sku, sheet_name) to avoid repeated upserts + catalog_id_cache = {} + + for idx, row in enumerate(data_rows): + if idx % 2000 == 0 and idx > 0: + print(f" ...{idx} rows processed") + + if not row or not row[1]: + stats['skipped_no_sku'] += 1 + continue + + sku = str(row[1]).strip() + name = normalize_name(row[24]) + carro_raw = str(row[25]).strip() if row[25] else '' + + if not sku: + stats['skipped_no_sku'] += 1 + continue + + stats['rows'] += 1 + + # Upsert catalog item (keyed by sku + category) + cache_key = (sku, sheet_name) + catalog_id = catalog_id_cache.get(cache_key) + if catalog_id is None: + master_cur.execute(upsert_catalog_sql, (SUPPLIER_NAME, sku, name, sheet_name)) + catalog_id = master_cur.fetchone()[0] + catalog_id_cache[cache_key] = catalog_id + stats['catalog_items'] += 1 + + # Parse vehicle + parsed = parse_carro(carro_raw, all_skus) + stats['vehicles_parsed'] += 1 + + # Insert compatibility (text-only, no MYE matching during import) + master_cur.execute(insert_compat_sql, ( + catalog_id, + parsed['make'], + parsed['model'], + parsed['year'], + parsed['position'] or None, + None, + 'import_text', + )) + stats['compat_rows'] += 1 + + # Insert interchanges + interchanges = extract_interchanges(row) + for brand, pn in interchanges: + master_cur.execute(insert_interchange_sql, (catalog_id, brand, pn)) + stats['interchange_rows'] += 1 + + # Commit per sheet + master_conn.commit() + print(f" Sheet '{sheet_name}' committed.") + + print(f"\n{'='*60}") + print("IMPORT COMPLETE") + print(f"{'='*60}") + print(f"Sheets processed: {stats['sheets']}") + print(f"Total rows read: {stats['rows']}") + print(f"Catalog items: {stats['catalog_items']}") + print(f"Compat rows: {stats['compat_rows']}") + print(f"Interchange rows: {stats['interchange_rows']}") + print(f"Vehicles parsed: {stats['vehicles_parsed']}") + print(f"Skipped (no SKU): {stats['skipped_no_sku']}") + + master_cur.close() + master_conn.close() + master_conn.close() + + +if __name__ == '__main__': + main() diff --git a/scripts/import_yokomitsu_catalog.py b/scripts/import_yokomitsu_catalog.py new file mode 100755 index 0000000..721e944 --- /dev/null +++ b/scripts/import_yokomitsu_catalog.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +""" +Import Yokomitsu catalog from Excel into supplier_catalog tables. + +Usage: + python scripts/import_yokomitsu_catalog.py +""" + +import os +import re +import sys +from datetime import datetime + +import psycopg2 +from openpyxl import load_workbook + +# DB connections +MASTER_DB_URL = os.environ.get('MASTER_DB_URL', 'postgresql://postgres@localhost/nexus_autoparts') +TENANT_DB_URL = os.environ.get('TENANT_DB_URL', 'postgresql://postgres@localhost/tenant_refaccionaria_rached') + +EXCEL_PATH = os.path.join(os.path.dirname(__file__), '..', 'data', 'YOKOMITSU_CATALOGOS_COMPLETOS_TODOS.xlsx') +SUPPLIER_NAME = 'YOKOMITSU' +TENANT_ID = 31 + + +def connect_master(): + return psycopg2.connect(MASTER_DB_URL) + + +def connect_tenant(): + return psycopg2.connect(TENANT_DB_URL) + + +def parse_year(token): + """Parse a 2-digit or 4-digit year string.""" + token = token.strip() + if not token: + return None + # Handle ranges like 08-13 or 08-15 -> use first year + if '-' in token: + token = token.split('-')[0] + token = token.strip() + if not token.isdigit(): + return None + n = int(token) + if n < 50: + return 2000 + n + if n < 100: + return 1900 + n + if n >= 1900 and n <= 2050: + return n + return None + + +def parse_vehicle(vehicle_raw): + """ + Parse a vehicle string like: + 'Chevrolet AVEO 1.5L 18' + 'Audi A4 1.8L/2.0L 09' + 'Dodge GRAND CHEROKEE 2/4WD 3.0L/3.7L/4.7L 08' + 'Volkswagen JETTA A4/CLASICO 1.8L/2.0L 06 V' + 'NISSAN 720 1988' + 'Dodge CARAVAN/VOYAGER 00' + 'ER 08-15 10' (garbage/unknown) + + Returns dict with make, model, year, engine, vehicle_raw. + """ + if not vehicle_raw: + return {'make': None, 'model': None, 'year': None, 'engine': None, 'vehicle_raw': vehicle_raw} + + s = str(vehicle_raw).strip() + # Remove trailing 'V' (variant marker) + s = re.sub(r'\s+V$', '', s) + + tokens = s.split() + if len(tokens) < 2: + return {'make': None, 'model': None, 'year': None, 'engine': None, 'vehicle_raw': s} + + # Last token is usually year (or year with suffix) + year = parse_year(tokens[-1]) + if year is None and len(tokens) >= 3: + # Try second-to-last if last doesn't look like year + year = parse_year(tokens[-2]) + if year: + tokens = tokens[:-2] + [tokens[-1]] # keep last as extra, but year found at -2 + year = parse_year(tokens[-2]) + if year is None: + # No year found; keep raw and try best-effort + make = tokens[0] if tokens else None + return {'make': make, 'model': ' '.join(tokens[1:]) if len(tokens) > 1 else None, + 'year': None, 'engine': None, 'vehicle_raw': s} + + # Remove year token + tokens_without_year = tokens[:-1] + make = tokens_without_year[0] if tokens_without_year else None + + # Try to extract engine from remaining tokens + # Engine patterns: contains 'L', 'WD', 'DIESEL', 'TURBO', numeric with decimal + remaining = ' '.join(tokens_without_year[1:]) if len(tokens_without_year) > 1 else '' + + # Heuristic: look for engine tokens at the END of remaining string + # Common patterns: "1.5L", "1.8L/2.0L", "2/4WD", "3.0L/3.7L/4.7L", "1.9L DIESEL" + engine = None + model = remaining + + # Try to find engine pattern from the end + engine_match = re.search(r'(\d+(?:\.\d+)?\s*L(?:/\d+(?:\.\d+)?\s*L)*|\d+/\d+WD|\d+\.\d+L\s+DIESEL|\d+\.\d+L\s+TURBO)$', remaining, re.IGNORECASE) + if engine_match: + engine = engine_match.group(1) + model = remaining[:engine_match.start()].strip() + else: + # Try simpler: anything with digits and 'L' or 'WD' at the very end + parts = remaining.split() + if parts and re.search(r'\d', parts[-1]) and ('L' in parts[-1].upper() or 'WD' in parts[-1].upper()): + engine = parts[-1] + model = ' '.join(parts[:-1]) + + return { + 'make': make, + 'model': model, + 'year': year, + 'engine': engine, + 'vehicle_raw': s, + } + + +def build_brand_cache(cur): + """Fetch all brands from master DB.""" + cur.execute("SELECT id_brand, name_brand FROM brands") + return {row[1].upper(): row[0] for row in cur.fetchall()} + + +def build_model_cache(cur): + """Fetch all models from master DB.""" + cur.execute("SELECT id_model, brand_id, name_model FROM models") + rows = cur.fetchall() + # Index by brand_id for fast lookup + cache = {} + for mid, bid, name in rows: + cache.setdefault(bid, []).append((mid, name)) + return cache + + +def build_year_cache(cur): + """Fetch all years from master DB.""" + cur.execute("SELECT id_year, year_car FROM years") + return {row[1]: row[0] for row in cur.fetchall()} + + +def build_mye_cache(cur): + """Fetch all MYE entries.""" + cur.execute("SELECT id_mye, model_id, year_id FROM model_year_engine") + cache = {} + for mye_id, model_id, year_id in cur.fetchall(): + cache.setdefault((model_id, year_id), []).append(mye_id) + return cache + + +def fuzzy_match_vehicle(parsed, brand_cache, model_cache, year_cache, mye_cache): + """ + Try to match parsed vehicle to MYE IDs. + Returns list of mye_ids (may be empty). + """ + make = parsed.get('make') + model_keyword = parsed.get('model') + year = parsed.get('year') + + if not make or not model_keyword or not year: + return [] + + # Find brand + brand_id = brand_cache.get(make.upper()) + if not brand_id: + # Try partial match + for name, bid in brand_cache.items(): + if make.upper() in name or name in make.upper(): + brand_id = bid + break + if not brand_id: + return [] + + # Find models for this brand that contain the keyword + models = model_cache.get(brand_id, []) + # Extract keyword: longest uppercase word from model string + keyword = model_keyword.upper() + # Try exact word match first + matched_model_ids = [] + for mid, mname in models: + if keyword in mname.upper(): + matched_model_ids.append(mid) + + if not matched_model_ids: + # Try with each word in keyword + words = [w for w in keyword.split() if len(w) >= 3] + for mid, mname in models: + mname_up = mname.upper() + if any(w in mname_up for w in words): + matched_model_ids.append(mid) + + if not matched_model_ids: + return [] + + # Find year_id + year_id = year_cache.get(year) + if not year_id: + return [] + + # Collect MYEs for all matched model+year combos + mye_ids = [] + for mid in matched_model_ids: + mye_ids.extend(mye_cache.get((mid, year_id), [])) + + return mye_ids + + +def extract_interchanges(row): + """Extract (brand, part_number) pairs from the interchange columns.""" + interchanges = [] + # Columns: MARCA.1=2, INTERCAMBIO=3, MARCA.2=4, INTERCAMBIO.1=5, ... up to MARCA.6=12, INTERCAMBIO.5=13 + pairs = [ + (row[2], row[3]), + (row[4], row[5]), + (row[6], row[7]), + (row[8], row[9]), + (row[10], row[11]), + (row[12], row[13]), + ] + for brand, pn in pairs: + if brand and pn: + brand = str(brand).strip() + pn = str(pn).strip() + if brand and pn: + interchanges.append((brand, pn)) + return interchanges + + +def main(): + print(f"[{datetime.now().isoformat()}] Starting import...") + + if not os.path.exists(EXCEL_PATH): + print(f"ERROR: Excel not found at {EXCEL_PATH}") + sys.exit(1) + + print(f"Loading {EXCEL_PATH}...") + wb = load_workbook(EXCEL_PATH, read_only=True, data_only=True) + + master_conn = connect_master() + master_conn = connect_master() + master_cur = master_conn.cursor() + master_cur = master_conn.cursor() + + print("Building caches...") + brand_cache = build_brand_cache(master_cur) + model_cache = build_model_cache(master_cur) + year_cache = build_year_cache(master_cur) + mye_cache = build_mye_cache(master_cur) + print(f" Brands: {len(brand_cache)}, Models: {sum(len(v) for v in model_cache.values())}, Years: {len(year_cache)}, MYE combos: {len(mye_cache)}") + + # Prepare UPSERT statements + upsert_catalog_sql = """ + INSERT INTO supplier_catalog (supplier_name, sku, name, category) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (supplier_name, sku, category) DO UPDATE SET + name = EXCLUDED.name, + category = EXCLUDED.category + RETURNING id + """ + + insert_compat_sql = """ + INSERT INTO supplier_catalog_compat + (catalog_id, make, model, year, engine, model_year_engine_id, source) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (catalog_id, make, model, year, engine) DO NOTHING + """ + + insert_interchange_sql = """ + INSERT INTO supplier_catalog_interchange (catalog_id, brand, part_number) + VALUES (%s, %s, %s) + ON CONFLICT DO NOTHING + """ + + # Track stats + stats = { + 'sheets': 0, + 'rows': 0, + 'catalog_items': 0, + 'compat_rows': 0, + 'interchange_rows': 0, + 'vehicles_parsed': 0, + 'vehicles_matched': 0, + 'mye_matches': 0, + } + + # Process each sheet + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + rows = list(ws.iter_rows(values_only=True)) + if not rows: + continue + headers = rows[0] + data_rows = rows[1:] + stats['sheets'] += 1 + print(f"\nProcessing sheet '{sheet_name}' with {len(data_rows)} rows...") + + for idx, row in enumerate(data_rows): + if idx % 1000 == 0 and idx > 0: + print(f" ...{idx} rows processed") + + # Skip empty rows + if not row or not row[1]: + continue + + sku = str(row[1]).strip() + name = str(row[14]).strip() if row[14] else '' + vehicle_raw = str(row[15]).strip() if row[15] else '' + + if not sku or not name: + continue + + stats['rows'] += 1 + + # Upsert catalog item + master_cur.execute(upsert_catalog_sql, (SUPPLIER_NAME, sku, name, sheet_name)) + catalog_id = master_cur.fetchone()[0] + stats['catalog_items'] += 1 + + # Parse vehicle + parsed = parse_vehicle(vehicle_raw) + stats['vehicles_parsed'] += 1 + + mye_ids = fuzzy_match_vehicle(parsed, brand_cache, model_cache, year_cache, mye_cache) + if mye_ids: + stats['vehicles_matched'] += 1 + stats['mye_matches'] += len(mye_ids) + + # Insert compatibility rows + # If we have MYE matches, insert one row per MYE + if mye_ids: + for mye_id in mye_ids: + master_cur.execute(insert_compat_sql, ( + catalog_id, + parsed['make'], + parsed['model'], + parsed['year'], + parsed['engine'], + mye_id, + 'fuzzy_match', + )) + stats['compat_rows'] += 1 + else: + # No MYE match: insert with text only + master_cur.execute(insert_compat_sql, ( + catalog_id, + parsed['make'], + parsed['model'], + parsed['year'], + parsed['engine'], + None, + 'import_text', + )) + stats['compat_rows'] += 1 + + # Insert interchanges + interchanges = extract_interchanges(row) + for brand, pn in interchanges: + master_cur.execute(insert_interchange_sql, (catalog_id, brand, pn)) + stats['interchange_rows'] += 1 + + # Commit per sheet + master_conn.commit() + print(f" Sheet '{sheet_name}' committed.") + + # Final stats + print(f"\n{'='*60}") + print("IMPORT COMPLETE") + print(f"{'='*60}") + print(f"Sheets processed: {stats['sheets']}") + print(f"Total rows read: {stats['rows']}") + print(f"Catalog items: {stats['catalog_items']}") + print(f"Compat rows: {stats['compat_rows']}") + print(f"Interchange rows: {stats['interchange_rows']}") + print(f"Vehicles parsed: {stats['vehicles_parsed']}") + print(f"Vehicles with MYE: {stats['vehicles_matched']}") + print(f"Total MYE matches: {stats['mye_matches']}") + + master_cur.close() + master_cur.close() + master_conn.close() + master_conn.close() + + +if __name__ == '__main__': + main() diff --git a/scripts/match_supplier_compat_to_mye.py b/scripts/match_supplier_compat_to_mye.py new file mode 100755 index 0000000..ad3c5b0 --- /dev/null +++ b/scripts/match_supplier_compat_to_mye.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +""" +Match supplier_catalog_compat rows to model_year_engine ids by fuzzy (make, model, year). + +Supports exact match, parenthesis-stripped match, whitespace/dash normalization, +prefix/substring fallback, model aliases, and year proximity (±2 years). + +Usage: + python scripts/match_supplier_compat_to_mye.py [--dry-run] +""" + +import os +import re +import sys +from collections import defaultdict + +import psycopg2 + +MASTER_DB_URL = os.environ.get('MASTER_DB_URL', 'postgresql://postgres@localhost/nexus_autoparts') + +MAKE_ALIASES = { + 'VOLKSWAGEN': 'VW', + 'VOLKWAGEN': 'VW', + 'MERCEDES BENZ': 'MERCEDES BENZ', + 'MERCEDES-BENZ': 'MERCEDES BENZ', + 'BMW MOTORRAD': 'BMW', +} + +NOISE_SUFFIXES = { + 'SEDAN', 'SALOON', 'COUPE', 'HATCHBACK', 'HATCH', 'WAGON', 'ESTATE', + 'SUV', 'VAN', 'PICK', 'UP', 'PICKUP', 'CABRIOLET', 'CONVERTIBLE', + 'LATINO', 'BRASIL', 'MEXICO', 'USA', 'EUROPA', 'EUROPE', 'NACIO', + 'LIMITED', 'LTD', 'XLT', 'LE', 'SE', 'XLE', 'SPORT', 'LX', 'EX', + '4X2', '4X4', '4WD', 'AWD', 'FWD', 'RWD', '2WD', +} + +# Specific model aliases: (make, supplier_model) -> list of possible master model substrings +MODEL_ALIASES = { + ('INFINITI', 'JX35'): ['JX SUV'], + ('INFINITI', 'G35'): ['G Coupe', 'G Saloon', 'G37'], + ('INFINITI', 'G37'): ['G Coupe', 'G Saloon', 'G37'], + ('HONDA', 'CRX'): ['CRX'], + ('MAZDA', 'PROTEGE'): ['PROTEGE'], + ('MAZDA', 'PROTEGE5'): ['PROTEGE'], + ('KIA', 'SPECTRA'): ['SPECTRA', 'SEPHIA'], + ('KIA', 'FORTE5'): ['FORTE'], + ('CHEVROLET', 'OPTRA'): ['OPTRA', 'LACETTI'], + ('CHEVROLET', 'AGILE'): ['AGILE'], + ('FIAT', 'SIENA'): ['SIENA'], + ('PONTIAC', 'G4'): ['G4', 'PURSUIT'], + ('FORD', 'FIVE HUNDRED'): ['FIVE HUNDRED', '500', 'TAURUS'], + ('FORD', 'POLICE INTERCEPTOR UTILITY'): ['POLICE INTERCEPTOR UTILITY', 'EXPLORER'], + ('FORD', 'POLICE INTERCEPTOR SEDAN'): ['POLICE INTERCEPTOR SEDAN', 'TAURUS'], + ('SCION', 'XA'): ['XA'], + ('SAAB', '9-2X'): ['9-2X'], + ('BUICK', 'LACROSSE'): ['LACROSSE'], + ('DODGE', 'CALIBER'): ['CALIBER'], + ('SUZUKI', 'EQUATOR'): ['EQUATOR'], + ('CHRYSLER', 'LEBARON K'): ['LEBARON'], + ('MERCEDES BENZ', 'A170'): ['A-CLASS'], + ('MERCEDES BENZ', 'A210'): ['A-CLASS'], +} + +# Regex-based class extraction for Mercedes: e.g. C350E -> C-Class, SL600 -> SL +MERCEDES_CLASS_PATTERNS = [ + # These Mercedes classes use "X-CLASS" in master (C-CLASS, E-CLASS, S-CLASS, etc.) + (r'^(A|B|C|E|G|GL|GLA|GLB|GLC|GLE|GLK|GLS|M|R|S|V|X)\d', 'CLASS'), + # These use just the letters (SL, SLK, CLS, CL, CLK) without -CLASS + (r'^(SL|SLK|CLS|CL|CLK)\d', 'LETTERS'), + (r'^(260E|300E|320E|400E|500E)$', 'E-CLASS'), + (r'^(300SL|500SL)$', 'SL'), + (r'^(400SEL|500SEL|600SEL)$', 'S-CLASS'), +] + + +def normalize_make(make): + if not make: + return '' + m = str(make).strip().upper() + return MAKE_ALIASES.get(m, m) + + +def normalize_model(model): + if not model: + return '' + return ' '.join(str(model).upper().split()) + + +def strip_parentheses(text): + return re.sub(r'\s*\([^)]*\)', '', text).strip() + + +def strip_noise_suffixes(text): + parts = text.split() + cleaned = [] + for p in parts: + if p in NOISE_SUFFIXES: + break + cleaned.append(p) + return ' '.join(cleaned) + + +def compact_alnum(text): + return re.sub(r'[^A-Z0-9]', '', text) + + +def build_model_variants(model_name): + variants = set() + base = normalize_model(model_name) + if not base: + return variants + no_paren = strip_parentheses(base) + no_noise = strip_noise_suffixes(no_paren) + compact = compact_alnum(no_noise) + compact_paren = compact_alnum(no_paren) + compact_base = compact_alnum(base) + + variants.add(base) + if no_paren: + variants.add(no_paren) + if no_noise: + variants.add(no_noise) + if compact: + variants.add(compact) + if compact_paren: + variants.add(compact_paren) + if compact_base: + variants.add(compact_base) + return variants + + +def mercedes_class_alias(model): + """Return a master model substring for Mercedes class-based models.""" + m = normalize_model(model) + for pat, repl in MERCEDES_CLASS_PATTERNS: + match = re.match(pat, m) + if match: + if repl == 'CLASS': + return match.group(1) + '-CLASS' + if repl == 'LETTERS': + return match.group(1) + return repl + return None + + +def connect(): + return psycopg2.connect(MASTER_DB_URL) + + +def build_mye_index(cur): + print('Building MYE index...') + cur.execute(''' + SELECT b.name_brand, m.name_model, y.year_car, mye.id_mye + 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 + ''') + + exact_index = defaultdict(list) + compact_index = defaultdict(list) + models_by_make = defaultdict(list) + # For year proximity: make -> compact_model -> {year: [mye_ids]} + year_range_index = defaultdict(lambda: defaultdict(lambda: defaultdict(list))) + + for make, model, year, mye_id in cur.fetchall(): + nmake = normalize_make(make) + if not nmake or not model or year is None: + continue + variants = build_model_variants(model) + for v in variants: + exact_index[(nmake, v, year)].append(mye_id) + + compact = compact_alnum(strip_parentheses(normalize_model(model))) + if compact: + compact_index[(nmake, compact, year)].append(mye_id) + year_range_index[nmake][compact][year].append(mye_id) + + models_by_make[nmake].append((normalize_model(model), mye_id, year, compact)) + + total_myes = sum(len(v) for v in exact_index.values()) + print(f' {len(exact_index):,} exact keys, {total_myes:,} MYE entries') + return exact_index, compact_index, models_by_make, year_range_index + + +def find_by_alias(nmake, nmodel, year, models_by_make): + """Try specific model aliases and Mercedes class patterns.""" + aliases = MODEL_ALIASES.get((nmake, nmodel), []) + # Mercedes fallback + if nmake == 'MERCEDES BENZ': + cls = mercedes_class_alias(nmodel) + if cls and cls not in aliases: + aliases = list(aliases) + [cls] + + if not aliases: + return None + + # Try to find a master model that contains any alias substring and matches year + for alias in aliases: + alias_compact = compact_alnum(alias) + for master_model, mye_id, mye_year, master_compact in models_by_make.get(nmake, []): + if mye_year != year: + continue + if alias in master_model or alias_compact in master_compact: + return mye_id + return None + + +def find_by_year_proximity(nmake, supplier_compact, year, year_range_index, max_diff=2): + """If exact year missing, find closest year within ±max_diff for same model.""" + years = year_range_index.get(nmake, {}).get(supplier_compact) + if not years: + return None + best_y = None + best_diff = None + for y in years.keys(): + diff = abs(y - year) + if diff <= max_diff: + if best_diff is None or diff < best_diff: + best_diff = diff + best_y = y + if best_y is not None: + return year_range_index[nmake][supplier_compact][best_y][0] + return None + + +def find_mye_id(make, model, year, exact_index, compact_index, models_by_make, year_range_index): + nmake = normalize_make(make) + nmodel = normalize_model(model) + if not nmake or not nmodel: + return None + + variants = build_model_variants(nmodel) + + # 1) Exact/near-exact on any variant + for v in variants: + myes = exact_index.get((nmake, v, year)) + if myes: + return myes[0] + + supplier_compact = compact_alnum(strip_parentheses(nmodel)) + + # 2) Compact match + myes = compact_index.get((nmake, supplier_compact, year)) + if myes: + return myes[0] + + # 3) Prefix/substring containment + for master_model, mye_id, mye_year, master_compact in models_by_make.get(nmake, []): + if mye_year != year: + continue + if not master_compact: + continue + if supplier_compact in master_compact or master_compact in supplier_compact: + return mye_id + + if year is None: + return None + + # 4) Model aliases + mye_id = find_by_alias(nmake, nmodel, year, models_by_make) + if mye_id: + return mye_id + + # 5) Year proximity ±3 years (same compact model) + if supplier_compact: + mye_id = find_by_year_proximity(nmake, supplier_compact, year, year_range_index, max_diff=3) + if mye_id: + return mye_id + + return None + + +def main(): + args = sys.argv[1:] + dry_run = False + if '--dry-run' in args: + dry_run = True + args.remove('--dry-run') + + if len(args) < 1: + print('Usage: match_supplier_compat_to_mye.py [--dry-run] ') + sys.exit(1) + + supplier_arg = args[0] + suppliers = None if supplier_arg == '--all' else [supplier_arg] + + if dry_run: + print('=' * 60) + print('DRY RUN MODE — no changes will be made') + print('=' * 60) + + conn = connect() + cur = conn.cursor() + + exact_index, compact_index, models_by_make, year_range_index = build_mye_index(cur) + + if suppliers: + cur.execute(''' + SELECT scc.id, scc.make, scc.model, scc.year + FROM supplier_catalog_compat scc + JOIN supplier_catalog sc ON sc.id = scc.catalog_id + WHERE sc.supplier_name = ANY(%s) AND scc.model_year_engine_id IS NULL + ''', (suppliers,)) + else: + cur.execute(''' + SELECT scc.id, scc.make, scc.model, scc.year + FROM supplier_catalog_compat scc + WHERE scc.model_year_engine_id IS NULL + ''') + + rows = cur.fetchall() + print(f'\nMatching {len(rows):,} compat rows...') + + matched = 0 + unmatched = 0 + sample_matches = [] + sample_unmatched = [] + updates = [] + + for scc_id, make, model, year in rows: + mye_id = find_mye_id(make, model, year, exact_index, compact_index, models_by_make, year_range_index) + if mye_id: + updates.append((mye_id, scc_id)) + matched += 1 + if len(sample_matches) < 10: + sample_matches.append((make, model, year, mye_id)) + else: + unmatched += 1 + if len(sample_unmatched) < 10: + sample_unmatched.append((make, model, year)) + + print(f'Matched: {matched:,}') + print(f'Unmatched: {unmatched:,}') + + if sample_matches: + print('\nSample matches:') + for make, model, year, mye_id in sample_matches: + print(f' {make} {model} {year} -> mye_id={mye_id}') + + if sample_unmatched: + print('\nSample unmatched:') + for make, model, year in sample_unmatched: + print(f' {make} {model} {year}') + + if dry_run or not updates: + cur.close() + conn.close() + if dry_run: + print('\n' + '=' * 60) + print('DRY RUN complete. Run without --dry-run to apply.') + print('=' * 60) + return + + print(f'\nApplying {len(updates):,} updates...') + cur.executemany(''' + UPDATE supplier_catalog_compat + SET model_year_engine_id = %s, source = 'matched_fuzzy' + WHERE id = %s + ''', updates) + conn.commit() + print('Updates committed.') + + cur.close() + conn.close() + + +if __name__ == '__main__': + main() diff --git a/test_catalog.js b/test_catalog.js new file mode 100644 index 0000000..6669bc5 --- /dev/null +++ b/test_catalog.js @@ -0,0 +1,91 @@ +const { chromium } = require('playwright'); + +(async () => { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + + // Set token directly via localStorage + await page.goto('http://127.0.0.1:5001/pos/catalog', { waitUntil: 'networkidle' }); + await page.evaluate(() => { + localStorage.setItem('pos_token', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoicG9zX2FjY2VzcyIsInRlbmFudF9pZCI6MzEsImVtcGxveWVlX2lkIjoxLCJyb2xlIjoib3duZXIiLCJuYW1lIjoiVGVzdCIsImJyYW5jaF9pZCI6MSwicGVybWlzc2lvbnMiOlsiaW52ZW50b3J5LnZpZXciLCJpbnZlbnRvcnkuZWRpdCIsImludmVudG9yeS5jcmVhdGUiLCJjYXRhbG9nLnZpZXciLCJjb25maWcuZWRpdF9wcmljZXMiXX0.iWLHGSnOeNW-eprH0-U1YkWZksIJqiuBc0ZZ20xdZq0'); + }); + await page.reload({ waitUntil: 'networkidle' }); + + // Wait for console logs + page.on('console', msg => { + if (msg.type() === 'error') { + console.log('CONSOLE ERROR:', msg.text()); + } + }); + + await page.waitForTimeout(2000); + await page.screenshot({ path: '/tmp/catalog_initial.png', fullPage: false }); + + // Try to select Chevrolet brand + const brands = await page.locator('.nav-card').all(); + console.log('Brands count:', brands.length); + + for (const b of brands.slice(0, 5)) { + const text = await b.textContent(); + console.log('Brand:', text); + } + + // Find Chevrolet + const chevy = await page.locator('.nav-card').filter({ hasText: /Chevrolet/i }).first(); + if (await chevy.isVisible().catch(() => false)) { + await chevy.click(); + await page.waitForTimeout(1500); + await page.screenshot({ path: '/tmp/catalog_models.png', fullPage: false }); + + // Find Aveo + const aveo = await page.locator('.nav-card').filter({ hasText: /Aveo/i }).first(); + if (await aveo.isVisible().catch(() => false)) { + await aveo.click(); + await page.waitForTimeout(1500); + await page.screenshot({ path: '/tmp/catalog_years.png', fullPage: false }); + + // Find 2018 + const y2018 = await page.locator('.nav-card').filter({ hasText: /2018/i }).first(); + if (await y2018.isVisible().catch(() => false)) { + await y2018.click(); + await page.waitForTimeout(1500); + await page.screenshot({ path: '/tmp/catalog_engines.png', fullPage: false }); + + // Find 1.5L + const engine = await page.locator('.nav-card').filter({ hasText: /1\.5/i }).first(); + if (await engine.isVisible().catch(() => false)) { + await engine.click(); + await page.waitForTimeout(2500); + await page.screenshot({ path: '/tmp/catalog_categories.png', fullPage: false }); + + // Try to find Supplier Catalog + const sc = await page.locator('.nav-card').filter({ hasText: /Proveedores/i }).first(); + if (await sc.isVisible().catch(() => false)) { + console.log('FOUND Supplier Catalog card'); + await sc.click(); + await page.waitForTimeout(2000); + await page.screenshot({ path: '/tmp/catalog_subgroups.png', fullPage: false }); + + // Find SUSPENSION + const susp = await page.locator('.nav-card').filter({ hasText: /Suspension/i }).first(); + if (await susp.isVisible().catch(() => false)) { + await susp.click(); + await page.waitForTimeout(2000); + await page.screenshot({ path: '/tmp/catalog_parts.png', fullPage: false }); + } + } else { + console.log('Supplier Catalog card NOT found'); + const allCards = await page.locator('.nav-card').all(); + for (const c of allCards) { + console.log('Card:', await c.textContent()); + } + } + } + } + } + } + + await browser.close(); + console.log('Done. Screenshots saved to /tmp/catalog_*.png'); +})();