feat(catalog): supplier catalog cleanup, fuzzy matching, and navigation fixes

- 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%
This commit is contained in:
2026-06-09 07:47:42 +00:00
parent 5ea667b80e
commit ea29cc31c0
53 changed files with 7727 additions and 548 deletions

View File

@@ -19,5 +19,8 @@
"type": "commonjs",
"devDependencies": {
"@playwright/test": "^1.59.1"
},
"dependencies": {
"playwright": "^1.60.0"
}
}

View File

@@ -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')

View File

@@ -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

View File

@@ -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})

View File

@@ -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)
try:
# Sales today
today_sales = db.execute(
cur.execute(
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
FROM sales WHERE DATE(created_at) = %s""", (today,)
).fetchone()
)
today_sales = cur.fetchone()
# Sales this month
month_sales = db.execute(
cur.execute(
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
FROM sales WHERE DATE(created_at) >= %s""", (month_start,)
).fetchone()
)
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
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_sale
JOIN parts p ON si.part_id = p.id_part
JOIN sales s ON si.sale_id = s.id
WHERE DATE(s.created_at) = %s
GROUP BY p.name
GROUP BY si.name
ORDER BY revenue DESC
LIMIT 5""", (today,)
).fetchall()
)
top_products = cur.fetchall()
# Hourly sales today (0-23)
hourly = db.execute(
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,)
).fetchall()
hourly_map = {row['hour']: {'count': row['count'], 'total': row['total']} for row in hourly}
)
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'],
'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['count'],
'sales_total': month_sales['total'],
'sales_count': month_sales[0],
'sales_total': float(month_sales[1]) if month_sales[1] is not None else 0,
},
'top_products': [
{'name': row['name'], 'quantity': row['qty'], 'revenue': row['revenue']}
{'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': hourly_map.get(h, {}).get('total', 0)}
'total': float(hourly_map.get(h, {}).get('total', 0))}
for h in range(24)
],
}, cls=DecimalEncoder)
})
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
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_employee
JOIN employees e ON s.employee_id = e.id
WHERE DATE(s.created_at) = %s
GROUP BY e.name
ORDER BY total DESC""", (today,)
).fetchall()
)
rows = cur.fetchall()
return jsonify({
'employees': [
{'name': row['name'], 'sales': row['sales'], 'total': row['total']}
{'name': row[0], 'sales': row[1], 'total': float(row[2]) if row[2] is not None else 0}
for row in rows
]
}, cls=DecimalEncoder)
})
finally:
cur.close()
conn.close()

View File

@@ -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/<sku>", 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()

View File

@@ -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/<part_number>', 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/<int:item_id>', 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/<int:item_id>/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/<int:item_id>/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/<int:item_id>/skus/<int:alias_id>', 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/<int:item_id>/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/<int:category_id>/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'])

View File

@@ -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/<int:listing_id>/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/<int:question_id>/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
# ═══════════════════════════════════════════════════════════════════════════

View File

@@ -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/<int:item_id>', 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/<int:item_id>', 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})

View File

@@ -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)
try:
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)
rows = db.execute(
f"""SELECT g.name as group_name, b.name as branch_name,
COUNT(DISTINCT s.id_sale) as orders,
cur.execute(
f"""SELECT b.name as branch_name,
COUNT(DISTINCT s.id) as orders,
SUM(si.quantity) as qty_requested,
COALESCE(SUM(si.total), 0) as revenue
COALESCE(SUM(si.subtotal), 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
JOIN sales s ON si.sale_id = s.id
LEFT JOIN branches b ON s.branch_id = b.id
WHERE {filters}
GROUP BY g.name, b.name
GROUP BY b.name
ORDER BY revenue DESC
LIMIT 100""", tuple(params)
).fetchall()
)
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']}
{'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
]
}, cls=DecimalEncoder)
})
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
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_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
JOIN sales s ON si.sale_id = s.id
WHERE s.created_at >= %s
GROUP BY p.oem_part_number, p.name, g.name
GROUP BY si.part_number, si.name
ORDER BY sold DESC
LIMIT 50""", (since,)
).fetchall()
)
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']}
{'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
]
}, cls=DecimalEncoder)
})
finally:
cur.close()
conn.close()

View File

@@ -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',
}

View File

@@ -386,3 +386,4 @@ CREATE TABLE IF NOT EXISTS tenant_config (
-- Barcode sequence
CREATE SEQUENCE IF NOT EXISTS barcode_seq START 1;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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}

View File

@@ -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,6 +190,26 @@ def get_brands(master_conn, year_id=None, mode='oem'):
"""
allowed = list(get_brands_for_mode(mode))
cur = master_conn.cursor()
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:
if year_id:
cur.execute("""
SELECT DISTINCT b.id_brand, b.name_brand
@@ -148,7 +233,7 @@ def get_brands(master_conn, year_id=None, mode='oem'):
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,6 +245,24 @@ def get_models(master_conn, brand_id, year_id=None, brand_name=None):
row = cur.fetchone()
brand_name = row[0] if row else ''
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:
if year_id:
cur.execute("""
SELECT DISTINCT m.id_model, m.name_model
@@ -200,9 +303,18 @@ 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()
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
@@ -215,16 +327,21 @@ def get_years(master_conn, model_id):
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,6 +424,8 @@ def _classify_vehicle_parts(master_conn, mye_id):
from services.nexpart_taxonomy import tecdoc_to_nexpart
rows = []
try:
cur = master_conn.cursor()
cur.execute("""
SELECT p.id_part, p.name_part
@@ -316,6 +435,12 @@ def _classify_vehicle_parts(master_conn, mye_id):
""", (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, [])
)
# 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=page, per_page=per_page,
oem_part_ids=part_ids,
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,6 +1307,7 @@ def get_nexpart_part_types_for_vehicle(master_conn, mye_id, group_slug, subgroup
]
image_map = {}
if all_part_ids:
try:
cur = master_conn.cursor()
cur.execute("""
SELECT id_part, image_url
@@ -858,6 +1317,9 @@ def get_nexpart_part_types_for_vehicle(master_conn, mye_id, group_slug, subgroup
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,16 +1555,12 @@ 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 as the COUNT, plus tiers + paging.
fetch_params = list(where_params_count) + [tier1, tier2, per_page, offset]
# 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]
rows = []
try:
cur.execute("""
WITH aftermarket_for_vehicle AS (
SELECT DISTINCT
@@ -1149,24 +1607,27 @@ def get_parts_local(master_conn, mye_id, group_id, tenant_conn, branch_id,
(COALESCE(s.bodega_count, 0) > 0) DESC,
afv.name_manufacture ASC,
afv.am_name ASC
LIMIT %s OFFSET %s
""", fetch_params)
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()
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)
items = []
seen_part_numbers = set()
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)
for r in rows:
aft_id = r[0]
oem_part_id = r[1]
@@ -1183,7 +1644,6 @@ def get_parts_local(master_conn, mye_id, group_id, tenant_conn, branch_id,
warehouse_stock = r[12]
tier = r[13]
# 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
@@ -1192,17 +1652,15 @@ def get_parts_local(master_conn, mye_id, group_id, tenant_conn, branch_id,
seen_part_numbers.add(part_number.upper())
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)
'id_part': oem_part_id,
'id_aftermarket': aft_id,
'oem_part_number': oem_number,
'part_number': aft_number, # aftermarket SKU
'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, # 1, 2, or 3
'priority_tier': tier,
'local_stock': local['stock'] if local else 0,
'local_price': local['price_1'] if local else None,
'bodega_count': bodega_count,
@@ -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,

View File

@@ -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

View File

@@ -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
# ═══════════════════════════════════════════════════════════════════════════

View File

@@ -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:

View File

@@ -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,

View File

@@ -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

View File

@@ -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;

View File

@@ -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();
}
});
}
})();

View File

@@ -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, '&quot;');
}
// ─── 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 = '<span class="stock-badge stock-badge--none" style="background:#f59e0b;color:#fff;">Cat. Proveedor</span>';
} else if (p.local_stock > 0) {
stockBadge = '<span class="stock-badge stock-badge--local">En stock: ' + p.local_stock + '</span>';
} else if (p.in_stock_network || p.bodega_count > 0) {
stockBadge = '<span class="stock-badge stock-badge--bodega">' + p.bodega_count + ' bodega' + (p.bodega_count > 1 ? 's' : '') + '</span>';
} else {
stockBadge = '<span class="stock-badge stock-badge--none">Sin stock</span>';
}
// Local inventory native badge
var sourceBadge = p.source === 'local_inventory'
? '<span class="stock-badge stock-badge--local" style="margin-left:4px;background:#4f46e5;">Stock Local</span>'
: '';
// Source badge for local inventory or supplier catalog
var sourceBadge = '';
if (p.source === 'local_inventory') {
sourceBadge = '<span class="stock-badge stock-badge--local" style="margin-left:4px;background:#4f46e5;">Stock Local</span>';
} else if (isSupplier) {
sourceBadge = '<span class="stock-badge stock-badge--local" style="margin-left:4px;background:#f59e0b;color:#fff;">Cat. Proveedor</span>';
}
var imgHtml = p.image_url
? '<img src="' + esc(p.image_url) + '" alt="' + esc(p.name) + '" loading="lazy" decoding="async">'
@@ -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 = '<div class="loading is-visible"><div class="spinner"></div></div>';
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 = '<p style="color:var(--color-error);padding:var(--space-4);">Error al cargar detalle.</p>';
return;
}
var p = data;
var html = '';
html += '<div class="detail-section">';
html += '<div style="font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:var(--space-1);">' + esc(p.supplier_name) + ' &gt; ' + esc(p.category || '') + '</div>';
html += '<div class="detail-oem">' + esc(p.sku) + '</div>';
html += '<div class="detail-name">' + esc((p.name || '').replace(/\\n/g, ' ')) + '</div>';
if (p.description) html += '<div class="detail-desc">' + esc(p.description) + '</div>';
if (p.image_url) html += '<div style="margin-top:var(--space-3);text-align:center;"><img src="' + esc(p.image_url) + '" alt="" loading="lazy" decoding="async" style="max-width:100%;max-height:200px;object-fit:contain;border-radius:var(--radius-sm);"></div>';
html += '</div>';
// Interchanges
if (p.interchanges && p.interchanges.length) {
html += '<div class="detail-section">';
html += '<div class="detail-section__title">Intercambios OEM</div>';
var seen = {};
p.interchanges.forEach(function(ix) {
var key = (ix.brand || '') + '|' + (ix.interchange_number || '');
if (seen[key]) return;
seen[key] = true;
html += '<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid var(--color-border);">' +
'<span style="font-weight:600;">' + esc(ix.brand || '') + '</span>' +
'<span style="color:var(--color-text-muted);font-family:monospace;">' + esc(ix.interchange_number || '') + '</span>' +
'</div>';
});
html += '</div>';
}
// Compatibilities — deduplicate by (make, model, year, engine)
if (p.compatibilities && p.compatibilities.length) {
html += '<div class="detail-section">';
html += '<div class="detail-section__title">Vehiculos compatibles</div>';
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 += '<div style="font-weight:600;margin-top:8px;">' + esc(c.make) + '</div>';
}
html += '<div style="padding-left:12px;color:var(--color-text-muted);font-size:var(--text-body-sm);">' +
esc(c.model) + ' ' + c.year + ' ' + esc(c.engine || '') + '</div>';
});
html += '</div>';
}
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
? '<span class="stock-badge stock-badge--local" style="margin-left:auto;">Stock: ' + r.local_stock + '</span>'
: '';
var localBadge = isLocal
? '<span class="stock-badge stock-badge--local" style="margin-right:4px;background:#4f46e5;">Stock Local</span>'
: '';
var sourceBadge = '';
if (isLocal) {
sourceBadge = '<span class="stock-badge stock-badge--local" style="margin-right:4px;background:#4f46e5;">Stock Local</span>';
} else if (isSupplier) {
sourceBadge = '<span class="stock-badge stock-badge--local" style="margin-right:4px;background:#f59e0b;color:#fff;">Cat. Proveedor</span>';
}
var oemNum = isLocal ? (r.oem_part_number || r.part_number || '') : (r.oem_part_number || '');
return '<div class="search-result-item" data-part-id="' + r.id_part + '" data-name="' + esc(r.name) + '" data-pn="' + esc(oemNum) + '" data-price="' + (r.local_price || '') + '" data-stock="' + (r.local_stock || 0) + '">' +
var cleanName = (r.name || '').replace(/\\n/g, ' ');
return '<div class="search-result-item" data-part-id="' + r.id_part + '" data-name="' + esc(cleanName) + '" data-pn="' + esc(oemNum) + '" data-price="' + (r.local_price || '') + '" data-stock="' + (r.local_stock || 0) + '" data-source="' + (r.source || '') + '">' +
'<div style="flex:1;">' +
'<div class="search-result__oem">' + localBadge + esc(oemNum) + '</div>' +
'<div class="search-result__name">' + esc(r.name) + '</div>' +
'<div class="search-result__oem">' + sourceBadge + esc(oemNum) + '</div>' +
'<div class="search-result__name">' + esc(cleanName) + '</div>' +
(r.vehicle_info ? '<div class="search-result__vehicle">' + esc(r.vehicle_info) + '</div>' : '') +
'</div>' +
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));
});
});

View File

@@ -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,

View File

@@ -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 = '<option value="">Selecciona categoría</option>';
data.categories.forEach(function(c) {
sel.innerHTML += '<option value="' + c.id + '">' + esc(c.name) + '</option>';
});
});
}
window.loadCategories = loadCategories;
function onCategoryChange(categoryId) {
var subSel = document.getElementById('newSubcategory');
if (!subSel) return;
if (!categoryId) {
subSel.innerHTML = '<option value="">Selecciona categoría primero</option>';
subSel.disabled = true;
return;
}
apiFetch(API + '/categories/' + categoryId + '/subcategories').then(function(data) {
if (!data || !data.subcategories) return;
subSel.innerHTML = '<option value="">Selecciona subcategoría</option>';
data.subcategories.forEach(function(s) {
subSel.innerHTML += '<option value="' + s.id + '">' + esc(s.name) + '</option>';
});
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 = '<option value="">Selecciona categoría</option>';
if (subSel) { subSel.innerHTML = '<option value="">Selecciona categoría primero</option>'; 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 = '<span style="color:var(--color-error);">Numero de parte y nombre son obligatorios</span>';
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 = '<span style="color:var(--color-error);">Selecciona un archivo CSV o Excel.</span>';
return;
}
var file = fileInput.files[0];
var formData = new FormData();
formData.append('file', file);
resultEl.style.display = 'block';
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Importando...</span>';
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 = '<span style="color:var(--color-error);">' + esc(data.error) + '</span>';
return;
}
var html = '<div style="color:var(--color-success);">Importacion completada: <strong>' + data.created + '</strong> producto(s) creado(s)';
if (data.skipped > 0) html += ', <strong>' + data.skipped + '</strong> saltado(s)';
html += '</div>';
if (data.warnings && data.warnings.length) {
html += '<div style="margin-top:8px;max-height:160px;overflow:auto;background:var(--color-surface);padding:8px;border-radius:6px;font-size:var(--text-caption);">';
html += '<strong style="color:var(--color-warning);">Advertencias (' + data.warnings.length + '):</strong><ul style="margin:4px 0 0 16px;padding:0;">';
data.warnings.forEach(function(w) {
html += '<li>' + esc(w) + '</li>';
});
html += '</ul></div>';
}
resultEl.innerHTML = html;
loadItems(currentPage);
if (window.loadInventoryStats) window.loadInventoryStats();
}).catch(function(err) {
resultEl.innerHTML = '<span style="color:var(--color-error);">Error de red: ' + esc(err.message) + '</span>';
});
}
window.submitBulkImport = submitBulkImport;
// =====================================================================
// PURCHASE / ENTRADA (purchaseModal)
// =====================================================================
@@ -1006,17 +1103,36 @@
var attrName = esc(attr.name || attr.id);
var inputHtml = '<input type="text" class="meli-title-input" id="meliAttr-' + attrId + '" placeholder="' + attrName + '">';
if (attr.values && attr.values.length) {
inputHtml = '<select class="meli-title-input" id="meliAttr-' + attrId + '">' +
// Some ML attributes (like BRAND) have a closed list but the API still
// accepts free-text via value_name. Provide a select + "Other" fallback.
var selectId = 'meliAttrSel-' + attrId;
var otherId = 'meliAttrOther-' + attrId;
inputHtml = '<select class="meli-title-input" id="' + selectId + '" onchange="onMeliAttrSelectChange(\'' + attrId + '\')">' +
'<option value="">Selecciona ' + attrName + '</option>' +
attr.values.map(function(v) { return '<option value="' + esc(v.name) + '">' + esc(v.name) + '</option>'; }).join('') +
'</select>';
'<option value="__other__">Otra marca (escribir)...</option>' +
'</select>' +
'<input type="text" class="meli-title-input" id="' + otherId + '" placeholder="Escribe la ' + attrName + '" style="display:none;margin-top:6px;">';
}
html += '<div class="inv-field"><label>' + attrName + (attr.tags && attr.tags.required ? ' *' : '') + '</label>' + inputHtml + '</div>';
html += '<div class="inv-field" id="meliAttrWrap-' + attrId + '"><label>' + attrName + (attr.tags && attr.tags.required ? ' *' : '') + '</label>' + inputHtml + '</div>';
});
grid.innerHTML = html;
}).catch(function() { grid.innerHTML = '<p style="color:var(--color-error);font-size:var(--text-caption);">Error cargando atributos</p>'; });
};
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 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 && el.value) {
attrs.push({ id: attr.id, value_name: el.value });
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 += '<style>';
html += '.compat-tabs{display:flex;gap:4px;border-bottom:1px solid var(--color-border);margin-bottom:16px;}';
html += '.compat-tab-btn{padding:8px 16px;font-size:var(--text-body-sm);font-weight:600;cursor:pointer;border:none;background:transparent;color:var(--color-text-muted);border-bottom:2px solid transparent;margin-bottom:-1px;}';
html += '.compat-tab-btn.is-active{color:var(--color-primary);border-bottom-color:var(--color-primary);background:var(--color-surface-0);border-radius:var(--radius-sm) var(--radius-sm) 0 0;}';
html += '.compat-tab-panel{display:none;}';
html += '.compat-tab-panel.is-active{display:block;}';
html += '.compat-form{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;}';
html += '.compat-form label{font-size:var(--text-caption);color:var(--color-text-muted);display:block;margin-bottom:4px;}';
html += '</style>';
// Tabs
html += '<div class="compat-tabs">';
html += '<button class="compat-tab-btn is-active" onclick="switchCompatTab(\'detail\',this)">Detalle</button>';
html += '<button class="compat-tab-btn" onclick="switchCompatTab(\'compat\',this)">Compatibilidad</button>';
html += '</div>';
// Detail panel
html += '<div id="compatTab-detail" class="compat-tab-panel is-active">';
// Product image section
html += '<div style="text-align:center;margin-bottom:16px;padding-bottom:16px;border-bottom:1px solid var(--color-border);">';
if (data.image_url) {
@@ -1330,12 +1478,19 @@
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">ID Inventario</span><strong style="font-family:var(--font-mono);">' + data.id + '</strong></div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">No. Parte</span><strong>' + esc(data.part_number) + '</strong></div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Nombre</span><strong>' + esc(data.name) + '</strong></div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Marca</span>' + esc(data.brand) + '</div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Marca</span>' + esc(data.brand || '-') + '</div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Categoría</span>' + esc(data.category_name || '-') + '</div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Codigo de Barras</span><span style="font-family:var(--font-mono);">' + esc(data.barcode) + '</span></div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Ubicacion</span>' + esc(data.location || '-') + '</div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Stock</span><strong style="font-size:1.2em;">' + (data.stock || 0) + '</strong></div>';
html += '</div>';
// SKU Aliases section
html += '<div style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:var(--tracking-widest);margin-bottom:8px;">SKU Alternativos</div>';
html += '<div id="skuAliasContent" style="margin-bottom:16px;padding-bottom:16px;border-bottom:1px solid var(--color-border);">';
html += '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Cargando SKU alternativos...</p>';
html += '</div>';
// Prices
html += '<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:16px;padding-bottom:16px;border-bottom:1px solid var(--color-border);">';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Costo</span><span class="td--amount">$' + fmt(data.cost) + '</span></div>';
@@ -1415,14 +1570,67 @@
el.innerHTML = html2;
}
// Vehicle compatibility section
html += '<div style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:var(--tracking-widest);margin-bottom:8px;">Vehiculos Compatibles</div>';
// Close detail panel
html += '</div>';
// Compatibility panel
html += '<div id="compatTab-compat" class="compat-tab-panel">';
// Existing compatibilities
html += '<div id="compatContent" style="margin-bottom:16px;padding-bottom:16px;border-bottom:1px solid var(--color-border);">';
html += '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Cargando compatibilidades...</p>';
html += '</div>';
// Load vehicle compatibilities
(function loadCompat() {
// Manual add form
html += '<div style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:var(--tracking-widest);margin-bottom:8px;">Agregar Manualmente</div>';
html += '<div class="compat-form">';
html += '<div><label>Marca</label><select class="select-filter" id="manualMake" onchange="onManualMakeChange(' + itemId + ')" style="width:100%;"><option value="">Cargando...</option></select></div>';
html += '<div><label>Modelo</label><select class="select-filter" id="manualModel" onchange="onManualModelChange(' + itemId + ')" style="width:100%;" disabled><option value="">Selecciona marca</option></select></div>';
html += '<div><label>Ano</label><select class="select-filter" id="manualYear" onchange="onManualYearChange(' + itemId + ')" style="width:100%;" disabled><option value="">Selecciona modelo</option></select></div>';
html += '<div><label>Motor</label><select class="select-filter" id="manualEngine" style="width:100%;" disabled><option value="">Selecciona ano</option></select></div>';
html += '</div>';
html += '<button class="btn btn--primary btn--sm" onclick="submitManualCompat(' + itemId + ')">Agregar compatibilidad</button>';
// Auto-match button
var btnLabel = compatSource === 'qwen' ? 'Auto-Match con IA (QWEN)' : (compatSource === 'both' ? 'Auto-Match (TecDoc + IA)' : 'Auto-Match por TecDoc');
html += '<div style="margin-top:16px;"><button class="btn btn--ghost btn--sm" onclick="autoMatchCompat(' + itemId + ')">' + btnLabel + '</button></div>';
html += '</div>';
// 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 += '<table class="data-table"><thead><tr><th>SKU</th><th>Etiqueta</th><th></th></tr></thead><tbody>';
list.forEach(function(a) {
html2 += '<tr><td class="td--mono">' + esc(a.sku) + '</td><td>' + esc(a.label || '-') + '</td>';
html2 += '<td><button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="removeSkuAlias(' + itemId + ',' + a.id + ')">Quitar</button></td></tr>';
});
html2 += '</tbody></table>';
} else {
html2 += '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Sin SKU alternativos.</p>';
}
html2 += '<div style="margin-top:8px;display:flex;gap:8px;">';
html2 += '<input type="text" class="meli-title-input" id="newAliasSku-' + itemId + '" placeholder="Nuevo SKU" style="flex:1;">';
html2 += '<input type="text" class="meli-title-input" id="newAliasLabel-' + itemId + '" placeholder="Etiqueta (opcional)" style="flex:1;">';
html2 += '<button class="btn btn--primary btn--sm" onclick="addSkuAlias(' + itemId + ')">Agregar</button>';
html2 += '</div>';
el.innerHTML = html2;
})
.catch(function() {
var el = document.getElementById('skuAliasContent');
if (el) el.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Error al cargar SKU alternativos.</p>';
});
})();
// 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 += '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Sin vehiculos vinculados.</p>';
}
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 += '<div style="margin-top:8px;"><button class="btn btn--primary btn--sm" onclick="autoMatchCompat(' + itemId + ')">' + btnLabel + '</button> <span style="font-size:var(--text-caption);color:var(--color-text-muted);">' + btnDesc + '</span></div>';
el.innerHTML = html2;
})
.catch(function() {
var el = document.getElementById('compatContent');
if (el) el.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Error al cargar compatibilidades.</p>';
});
// 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 = '<option value="">Selecciona marca</option>';
(d.makes || []).forEach(function(m) { opts += '<option value="' + esc(m.name) + '" data-id="' + m.id + '">' + esc(m.name) + '</option>'; });
sel.innerHTML = opts;
sel.disabled = false;
})
.catch(function() {
var sel = document.getElementById('manualMake');
if (sel) { sel.innerHTML = '<option value="">Error al cargar</option>'; }
});
})();
// 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 = '<option value="">Cargando...</option>';
modelSel.disabled = true;
yearSel.innerHTML = '<option value="">Selecciona modelo</option>';
yearSel.disabled = true;
engineSel.innerHTML = '<option value="">Selecciona ano</option>';
engineSel.disabled = true;
if (!brandId) {
modelSel.innerHTML = '<option value="">Selecciona marca</option>';
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 = '<option value="">Selecciona modelo</option>';
(d.models || []).forEach(function(m) { opts += '<option value="' + esc(m.name) + '" data-id="' + m.id + '">' + esc(m.name) + '</option>'; });
modelSel.innerHTML = opts;
modelSel.disabled = false;
})
.catch(function() { modelSel.innerHTML = '<option value="">Error</option>'; });
};
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 = '<option value="">Cargando...</option>';
yearSel.disabled = true;
engineSel.innerHTML = '<option value="">Selecciona ano</option>';
engineSel.disabled = true;
if (!modelId) {
yearSel.innerHTML = '<option value="">Selecciona modelo</option>';
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 = '<option value="">Selecciona ano</option>';
(d.years || []).forEach(function(y) { opts += '<option value="' + y.year + '" data-id="' + y.id + '">' + y.year + '</option>'; });
yearSel.innerHTML = opts;
yearSel.disabled = false;
})
.catch(function() { yearSel.innerHTML = '<option value="">Error</option>'; });
};
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 = '<option value="">Cargando...</option>';
engineSel.disabled = true;
if (!modelId || !yearId) {
engineSel.innerHTML = '<option value="">Selecciona ano</option>';
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 = '<option value="">Selecciona motor</option>';
(d.engines || []).forEach(function(e) { opts += '<option value="' + esc(e.name) + '" data-code="' + esc(e.code || '') + '">' + esc(e.name + (e.code ? ' (' + e.code + ')' : '')) + '</option>'; });
engineSel.innerHTML = opts;
engineSel.disabled = false;
})
.catch(function() { engineSel.innerHTML = '<option value="">Error</option>'; });
};
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)
// =====================================================================

View File

@@ -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 '<div class="meli-card">'
+ '<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:var(--space-3);">'
+ '<div style="font-weight:700;font-size:var(--text-body-sm);line-height:1.3;">' + escapeHtml(l.title || l.inventory_name || 'Sin título') + '</div>'
+ '<a href="' + escapeHtml(l.external_permalink || '#') + '" target="_blank" rel="noopener" style="font-weight:700;font-size:var(--text-body-sm);line-height:1.3;color:var(--color-primary);text-decoration:none;">' + escapeHtml(l.title || l.inventory_name || 'Sin título') + ' ↗</a>'
+ '<span class="meli-status ' + statusClass + '">' + (l.external_status || '—') + '</span>'
+ '</div>'
+ '<div style="font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:var(--space-2);">'
+ 'SKU: ' + escapeHtml(l.part_number || '—') + ' · ID ML: ' + escapeHtml(l.external_item_id || '—')
+ 'SKU: ' + escapeHtml(l.part_number || '—') + ' · ID ML: <a href="' + escapeHtml(l.external_permalink || '#') + '" target="_blank" rel="noopener" style="color:var(--color-primary);">' + escapeHtml(l.external_item_id || '—') + '</a>'
+ '</div>'
+ '<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3);">'
+ '<button class="btn btn--ghost btn--xs" onclick="syncListing(' + l.id + ')">Sync</button>'
+ (l.external_status === 'active' ? '<button class="btn btn--ghost btn--xs" onclick="pauseListing(' + l.id + ')">Pausar</button>' : '<button class="btn btn--ghost btn--xs" onclick="activateListing(' + l.id + ')">Activar</button>')
+ '<button class="btn btn--danger btn--xs" onclick="deleteListing(' + l.id + ')">Cerrar</button>'
+ (l.external_status === 'closed' || !l.is_active
? '<button class="btn btn--danger btn--xs" onclick="deleteListingPermanently(' + l.id + ')">Eliminar</button>'
: '<button class="btn btn--danger btn--xs" onclick="deleteListing(' + l.id + ')">Cerrar</button>')
+ '</div>'
+ '</div>';
}).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 = '<div class="skeleton-grid">' + Array(6).fill('<div class="skeleton skeleton--card"></div>').join('') + '</div>';
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: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
title: 'Sin preguntas',
subtitle: 'No hay preguntas de compradores pendientes. Sincroniza con MercadoLibre para obtenerlas.',
action: '<button class="btn btn--meli btn--sm" onclick="syncQuestions()">Sincronizar con ML</button>'
});
}
};
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 = '<div style="display:flex;gap:var(--space-4);flex-wrap:wrap;margin-bottom:var(--space-4);">' +
'<div class="meli-card" style="flex:1;min-width:140px;text-align:center;"><div style="font-size:28px;font-weight:800;color:var(--color-primary);">' + total + '</div><div style="font-size:var(--text-caption);color:var(--color-text-muted);">Total preguntas</div></div>' +
'<div class="meli-card" style="flex:1;min-width:140px;text-align:center;"><div style="font-size:28px;font-weight:800;color:var(--color-error);">' + unanswered + '</div><div style="font-size:var(--text-caption);color:var(--color-text-muted);">Sin responder</div></div>' +
'<div class="meli-card" style="flex:1;min-width:140px;text-align:center;"><div style="font-size:28px;font-weight:800;color:var(--color-success);">' + answered + '</div><div style="font-size:var(--text-caption);color:var(--color-text-muted);">Respondidas</div></div>' +
'</div>';
document.getElementById('questionsStatsBar').innerHTML = statsHtml;
if (!filtered.length) {
container.innerHTML = renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
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 = '<div style="margin-top:var(--space-2);">' +
'<textarea class="meli-title-input" id="qAnswer-' + q.id + '" rows="2" placeholder="Escribe tu respuesta..."></textarea>' +
'<button class="btn btn--primary btn--xs" style="margin-top:6px;" onclick="submitAnswer(' + q.id + ')">Enviar respuesta</button>' +
'</div>';
} else if (q.answer_text) {
answerHtml = '<div style="margin-top:var(--space-2);padding:var(--space-2);background:var(--color-surface-0);border-radius:var(--radius-sm);font-size:var(--text-caption);color:var(--color-text-secondary);">' +
'<strong>Respuesta:</strong> ' + escapeHtml(q.answer_text) +
'</div>';
}
return '<div class="meli-card">'
+ '<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:var(--space-3);">'
+ '<div style="font-weight:700;font-size:var(--text-body-sm);line-height:1.3;">' + escapeHtml(q.listing_title || 'Artículo sin título') + '</div>'
+ '<span class="meli-status ' + statusClass + '">' + statusLabel + '</span>'
+ '</div>'
+ '<div style="font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:var(--space-2);">'
+ 'Comprador: ' + escapeHtml(q.buyer_nickname || '—') + ' · ' + (q.question_date ? new Date(q.question_date).toLocaleString('es-MX') : '—')
+ '</div>'
+ '<div style="font-size:var(--text-body-sm);color:var(--color-text-primary);margin-bottom:var(--space-2);">'
+ '<strong>Pregunta:</strong> ' + escapeHtml(q.question_text)
+ '</div>'
+ answerHtml
+ '</div>';
}).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() {

View File

@@ -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 = '<div class="sc-cat-card' + (state.category === '' ? ' active' : '') + '" onclick="selectCategory(\'\')">' +
'<div>Todas</div><div class="count">' + state.categories.reduce((a,c)=>a+c.count,0) + ' items</div></div>';
state.categories.forEach(function(c) {
html += '<div class="sc-cat-card' + (state.category === c.name ? ' active' : '') + '" onclick="selectCategory(\'' + escapeHtml(c.name) + '\')">' +
'<div>' + escapeHtml(c.name) + '</div><div class="count">' + c.count + ' items</div></div>';
});
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 = '<option value="">Marca vehiculo</option>';
(data.data || []).forEach(function(m) {
sel.innerHTML += '<option value="' + escapeHtml(m.name_brand) + '">' + escapeHtml(m.name_brand) + '</option>';
});
}
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 = '<option value="">Modelo</option>';
(models.data || []).forEach(function(m) {
msel.innerHTML += '<option value="' + m.id_model + '">' + escapeHtml(m.name_model) + '</option>';
});
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 = '<option value="">Año</option>';
(years.data || []).forEach(function(y) {
ysel.innerHTML += '<option value="' + y.id_year + '">' + y.year_car + '</option>';
});
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 = '<option value="">Motorizacion</option>';
(engines.data || []).forEach(function(e) {
const label = escapeHtml(e.name_engine) + (e.trim_level ? ' (' + escapeHtml(e.trim_level) + ')' : '');
esel.innerHTML += '<option value="' + e.id_mye + '">' + label + '</option>';
});
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 = '<option value="">Modelo</option>'; document.getElementById('filterModel').disabled = true;
document.getElementById('filterYear').innerHTML = '<option value="">Año</option>'; document.getElementById('filterYear').disabled = true;
document.getElementById('filterEngine').innerHTML = '<option value="">Motorizacion</option>'; 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 = '<div class="sc-empty" style="grid-column:1/-1;"><div style="font-size:48px;margin-bottom:var(--space-4);">🔍</div><h3>Sin resultados</h3><p>Intenta con otros filtros o terminos de busqueda.</p></div>';
return;
}
el.innerHTML = state.items.map(function(it) {
return '<div class="sc-card" onclick="openDetail(' + it.id + ')">' +
'<div class="sc-card__sku">' + escapeHtml(it.sku) + '</div>' +
'<div class="sc-card__name">' + escapeHtml(it.name) + '</div>' +
'<div class="sc-card__meta">' +
'<span class="sc-card__badge">' + escapeHtml(it.category || 'SIN CATEGORIA') + '</span>' +
' <span>' + escapeHtml(it.supplier_name) + '</span>' +
'</div>' +
'</div>';
}).join('');
}
function renderPagination() {
const el = document.getElementById('pagination');
if (!el) return;
if (state.totalPages <= 1) { el.innerHTML = ''; return; }
let html = '<button ' + (state.page <= 1 ? 'disabled' : '') + ' onclick="goPage(' + (state.page - 1) + ')">Anterior</button>';
html += '<span>Pagina ' + state.page + ' de ' + state.totalPages + '</span>';
html += '<button ' + (state.page >= state.totalPages ? 'disabled' : '') + ' onclick="goPage(' + (state.page + 1) + ')">Siguiente</button>';
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 += '<div><strong style="font-size:var(--text-h6);">' + escapeHtml(data.name) + '</strong></div>';
html += '<div class="sc-modal__section"><h4>Informacion</h4>' +
'<p>Proveedor: ' + escapeHtml(data.supplier_name) + '<br>Categoria: ' + escapeHtml(data.category || 'N/A') + '</p></div>';
if (data.interchanges && data.interchanges.length) {
html += '<div class="sc-modal__section"><h4>Intercambios</h4><div class="sc-interchange-list">' +
data.interchanges.map(function(ix) {
return '<span class="sc-interchange-chip">' + escapeHtml(ix.brand) + ' — ' + escapeHtml(ix.part_number) + '</span>';
}).join('') + '</div></div>';
}
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 += '<div class="sc-modal__section"><h4>Vehiculos compatibles (' + uniqCompat.length + ')</h4>' +
'<div class="sc-compat-grid">' +
uniqCompat.slice(0, 50).map(function(c) {
return '<div class="sc-compat-item">' +
'<strong>' + escapeHtml(c.make || '') + ' ' + escapeHtml(c.model || '') + '</strong><br>' +
(c.year || '') + ' ' + escapeHtml(c.engine || '') +
'</div>';
}).join('') +
(uniqCompat.length > 50 ? '<div style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">... y ' + (uniqCompat.length - 50) + ' mas</div>' : '') +
'</div></div>';
}
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ─── 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();
}
})();

View File

@@ -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;

View File

@@ -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:

View File

@@ -68,6 +68,10 @@
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 7h8M8 12h8M8 17h5"/></svg>
Catalogo
</a>
<a class="nav-item" href="/pos/supplier-catalog" role="menuitem">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><path d="M2 3h6l2 3h12v14H2V3z"/></svg>
Cat. Proveedores
</a>
<div class="nav-section-label" style="margin-top: var(--space-2);">Gestion</div>
<a class="nav-item" href="/pos/customers" role="menuitem">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
@@ -291,7 +295,7 @@
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/catalog.js?v=2" defer></script>
<script src="/pos/static/js/catalog.js?v=5" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>

View File

@@ -15,7 +15,7 @@
<meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/dashboard.css">
<link rel="stylesheet" href="/pos/static/css/dashboard.css?v=3">
</head>
<body>
@@ -368,7 +368,7 @@
Ventas por Hora
</div>
</div>
<canvas id="hourlySalesChart" height="180"></canvas>
<div class="chart-canvas-wrap"><canvas id="hourlySalesChart"></canvas></div>
</div>
<div class="rank-card">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4);">
@@ -376,7 +376,7 @@
Top Productos (Hoy)
</div>
</div>
<canvas id="topProductsChart" height="180"></canvas>
<div class="chart-canvas-wrap"><canvas id="topProductsChart"></canvas></div>
</div>
</div>
</section>
@@ -494,8 +494,8 @@
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/dashboard-stats.js" defer></script>
<script src="/pos/static/js/dashboard.js?v=2" defer></script>
<script src="/pos/static/js/dashboard-stats.js?v=3" defer></script>
<script src="/pos/static/js/dashboard.js?v=3" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>

View File

@@ -183,6 +183,10 @@
<h1 class="page-header__title">Inventario</h1>
</div>
<div class="page-header__actions">
<button class="btn btn--ghost" onclick="document.getElementById('bulkImportModal').classList.add('is-open')">
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Importar CSV
</button>
<button class="btn btn--ghost" onclick="exportVisibleTableCSV('inventario')">
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Exportar CSV
@@ -708,8 +712,20 @@
<div class="inv-form-grid">
<div class="inv-field"><label>No. Parte *</label><input type="text" id="newPartNumber" placeholder="Ej: GAT-50104" /></div>
<div class="inv-field"><label>Nombre *</label><input type="text" id="newName" placeholder="Nombre del producto" /></div>
<div class="inv-field"><label>Marca</label><input type="text" id="newBrand" placeholder="Marca" /></div>
<div class="inv-field"><label>Marca</label><input type="text" id="newBrand" placeholder="Marca del fabricante" /></div>
<div class="inv-field"><label>Categoría</label>
<select class="select-filter" id="newCategory" onchange="onCategoryChange(this.value)" style="width:100%;">
<option value="">Selecciona categoría</option>
</select>
</div>
<div class="inv-field"><label>Subcategoría</label>
<select class="select-filter" id="newSubcategory" style="width:100%;" disabled>
<option value="">Selecciona categoría primero</option>
</select>
</div>
<div class="inv-field"><label>Barcode</label><input type="text" id="newBarcode" placeholder="Auto-generado si vacío" /></div>
<div class="inv-field"><label>SKU Alternativo 1</label><input type="text" id="newSku2" placeholder="Ej: SKU-Bodega-A" /></div>
<div class="inv-field"><label>SKU Alternativo 2</label><input type="text" id="newSku3" placeholder="Ej: SKU-Bodega-B" /></div>
<div class="inv-field"><label>Costo</label><input type="number" id="newCost" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Precio Mostrador</label><input type="number" id="newPrice1" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Stock Mínimo</label><input type="number" id="newMinStock" placeholder="0" /></div>
@@ -973,6 +989,47 @@
</div>
</div>
<!-- ══════════ Bulk Import Modal ══════════ -->
<div class="inv-modal-overlay" id="bulkImportModal">
<div class="inv-modal" style="max-width:520px;">
<div class="inv-modal__header">
<h3>Importar Productos Masivamente</h3>
<button class="inv-modal__close" onclick="document.getElementById('bulkImportModal').classList.remove('is-open')">&times;</button>
</div>
<div class="inv-modal__body">
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:var(--text-caption);color:var(--color-text-muted);">Archivo CSV o Excel</label>
<input type="file" id="bulkImportFile" accept=".csv,.xlsx,.xls" style="width:100%;padding:8px;border:1px dashed var(--color-border);border-radius:6px;background:var(--color-surface);color:var(--color-text);" />
</div>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:var(--text-caption);color:var(--color-text-muted);">Modo de importación</label>
<select id="bulkImportMode" class="select-filter" style="width:100%;">
<option value="strict">Estricto — abortar al primer error</option>
<option value="lenient" selected>Permisivo — saltar filas con error</option>
</select>
</div>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:var(--text-caption);color:var(--color-text-muted);">Compatibilidad de vehículo faltante</label>
<select id="bulkImportStrategy" class="select-filter" style="width:100%;">
<option value="qwen" selected>Auto-generar con IA (QWEN)</option>
<option value="skip">Omitir compatibilidad</option>
<option value="reject">Rechazar filas sin compatibilidad</option>
</select>
</div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);background:var(--color-surface);padding:10px;border-radius:6px;">
<strong>Columnas esperadas:</strong>
<code style="display:block;margin-top:4px;word-break:break-all;">sku, name, brand, price, stock, cost, location, description, category, make, model, year, engine, engine_code</code>
<span style="display:block;margin-top:4px;">También se aceptan sinónimos en español: <em>numero_de_parte, nombre, marca, precio, cantidad, costo, ubicacion, categoria, fabricante, modelo, anio, motor, codigo_motor</em></span>
</div>
<div id="bulkImportResult" style="margin-top:12px;display:none;"></div>
</div>
<div class="inv-modal__footer">
<button class="btn btn--ghost" onclick="document.getElementById('bulkImportModal').classList.remove('is-open')">Cancelar</button>
<button class="btn btn--primary" onclick="submitBulkImport()">Importar</button>
</div>
</div>
</div>
<!-- Offline Banner -->
<div id="offlineBanner" class="banner banner--warning" style="display:none;position:fixed;top:0;left:0;right:0;z-index:9999;border-radius:0;animation:none;">
<span class="banner__icon"></span>

View File

@@ -184,6 +184,9 @@
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-orders" onclick="switchTab('orders')">
Órdenes
</button>
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-questions" onclick="switchTab('questions')">
Preguntas
</button>
</div>
<!-- Tab Panels -->
@@ -262,6 +265,27 @@
<div id="listingsPagination" class="table-footer" style="margin-top:var(--space-4);"></div>
</div>
<!-- ══════════ TAB: Preguntas ══════════ -->
<div class="tab-panel" id="panel-questions" role="tabpanel">
<div class="toolbar">
<div class="search-box">
<svg viewBox="0 0 24 24" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="questionSearch" placeholder="Buscar pregunta..." oninput="filterQuestions()" />
</div>
<select class="select-filter" id="questionStatusFilter" onchange="filterQuestions()">
<option value="">Todas</option>
<option value="unanswered">Sin responder</option>
<option value="answered">Respondidas</option>
<option value="closed">Cerradas</option>
</select>
<div class="toolbar__spacer"></div>
<button class="btn btn--ghost btn--sm" onclick="syncQuestions()">🔄 Sincronizar con ML</button>
<button class="btn btn--primary" onclick="loadQuestions()">🔄 Actualizar</button>
</div>
<div id="questionsStatsBar" style="margin-bottom:var(--space-4);"></div>
<div id="questionsContainer" class="meli-grid"></div>
</div>
<!-- ══════════ TAB: Órdenes ══════════ -->
<div class="tab-panel" id="panel-orders" role="tabpanel">
<div class="toolbar">

View File

@@ -0,0 +1,135 @@
<!DOCTYPE html>
<html lang="es">
<head>
<script>(function(){var t=localStorage.getItem("pos_theme")||"industrial";document.documentElement.setAttribute("data-theme",t);})()</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Catalogo de Proveedores — Nexus Autoparts POS</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<style>
.supplier-catalog { padding: var(--space-5); max-width: 1400px; margin: 0 auto; }
.sc-header { display:flex; align-items:center; justify-content:space-between; gap:var(--space-4); margin-bottom:var(--space-5); flex-wrap:wrap; }
.sc-search { display:flex; gap:var(--space-3); flex:1; min-width:280px; }
.sc-search input { flex:1; padding:var(--space-3) var(--space-4); border:1px solid var(--color-border); border-radius:var(--radius-md); background:var(--color-bg-elevated); color:var(--color-text-primary); font-size:var(--text-body); }
.sc-filters { display:flex; gap:var(--space-3); flex-wrap:wrap; margin-bottom:var(--space-5); }
.sc-filters select { padding:var(--space-2) var(--space-3); border:1px solid var(--color-border); border-radius:var(--radius-md); background:var(--color-bg-elevated); color:var(--color-text-primary); min-width:140px; }
.sc-categories { display:grid; grid-template-columns:repeat(auto-fill, minmax(160px, 1fr)); gap:var(--space-3); margin-bottom:var(--space-5); }
.sc-cat-card { background:var(--color-bg-elevated); border:1px solid var(--color-border); border-radius:var(--radius-lg); padding:var(--space-4); cursor:pointer; transition:all .15s; text-align:center; }
.sc-cat-card:hover, .sc-cat-card.active { border-color:var(--color-primary); box-shadow:var(--shadow-sm); }
.sc-cat-card .count { font-size:var(--text-caption); color:var(--color-text-muted); margin-top:2px; }
.sc-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:var(--space-4); }
.sc-card { background:var(--color-bg-elevated); border:1px solid var(--color-border); border-radius:var(--radius-lg); padding:var(--space-4); cursor:pointer; transition:all .15s; display:flex; flex-direction:column; gap:var(--space-2); }
.sc-card:hover { border-color:var(--color-primary); transform:translateY(-2px); box-shadow:var(--shadow-sm); }
.sc-card__sku { font-family:var(--font-mono); font-size:var(--text-caption); color:var(--color-primary); font-weight:var(--font-weight-bold); }
.sc-card__name { font-weight:var(--font-weight-semibold); color:var(--color-text-primary); line-height:1.3; }
.sc-card__meta { font-size:var(--text-caption); color:var(--color-text-muted); margin-top:auto; }
.sc-card__badge { display:inline-block; padding:2px 8px; border-radius:var(--radius-full); background:var(--color-primary-muted); color:var(--color-primary); font-size:10px; font-weight:var(--font-weight-bold); text-transform:uppercase; }
.sc-empty { text-align:center; padding:var(--space-8); color:var(--color-text-muted); }
.sc-pagination { display:flex; justify-content:center; align-items:center; gap:var(--space-3); margin-top:var(--space-6); }
.sc-pagination button { padding:var(--space-2) var(--space-4); border:1px solid var(--color-border); border-radius:var(--radius-md); background:var(--color-bg-elevated); color:var(--color-text-primary); cursor:pointer; }
.sc-pagination button:disabled { opacity:.4; cursor:not-allowed; }
.sc-pagination span { font-size:var(--text-caption); color:var(--color-text-muted); }
/* Modal */
.sc-modal-overlay { position:fixed; inset:0; background:var(--overlay-backdrop); z-index:var(--z-modal); display:none; align-items:center; justify-content:center; padding:var(--space-4); }
.sc-modal-overlay.open { display:flex; }
.sc-modal { background:var(--color-bg-elevated); border:1px solid var(--color-border); border-radius:var(--radius-xl); width:100%; max-width:720px; max-height:90vh; overflow-y:auto; display:flex; flex-direction:column; }
.sc-modal__header { display:flex; align-items:center; justify-content:space-between; padding:var(--space-4) var(--space-5); border-bottom:1px solid var(--color-border); }
.sc-modal__body { padding:var(--space-5); display:flex; flex-direction:column; gap:var(--space-4); }
.sc-modal__section h4 { font-size:var(--text-body-sm); font-weight:var(--font-weight-bold); color:var(--color-text-secondary); margin-bottom:var(--space-2); text-transform:uppercase; letter-spacing:var(--tracking-wider); }
.sc-compat-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(180px, 1fr)); gap:var(--space-2); }
.sc-compat-item { background:var(--color-surface-1); border:1px solid var(--color-border); border-radius:var(--radius-md); padding:var(--space-2) var(--space-3); font-size:var(--text-caption); }
.sc-interchange-list { display:flex; flex-wrap:wrap; gap:var(--space-2); }
.sc-interchange-chip { background:var(--color-surface-2); border:1px solid var(--color-border); border-radius:var(--radius-full); padding:2px 10px; font-size:var(--text-caption); }
.sc-close { background:none; border:none; font-size:20px; color:var(--color-text-muted); cursor:pointer; }
</style>
</head>
<body>
<!-- Theme bar -->
<div class="theme-bar">
<span class="theme-bar__label">Tema:</span>
<button class="theme-btn" id="btn-industrial" onclick="setTheme('industrial')">Industrial</button>
<button class="theme-btn" id="btn-modern" onclick="setTheme('modern')">Moderno</button>
</div>
<div class="sidebar-overlay" id="sidebar-overlay" onclick="closeSidebar()"></div>
<div class="app-shell">
<nav class="sidebar themed-scrollbar" id="sidebar">
<div class="sidebar__logo">
<div class="sidebar__logo-text">Nexus</div>
<div class="sidebar__logo-sub">Autoparts POS</div>
</div>
<div class="sidebar__nav">
<div class="sidebar__section-label">Principal</div>
<a href="/pos/dashboard" class="nav-link"><span class="nav-link__icon">📊</span> Dashboard</a>
<a href="/pos/sale" class="nav-link"><span class="nav-link__icon">🛒</span> POS</a>
<a href="/pos/catalog" class="nav-link"><span class="nav-link__icon">📁</span> Catalogo</a>
<a href="/pos/supplier-catalog" class="nav-link active"><span class="nav-link__icon">🏭</span> Cat. Proveedores</a>
<a href="/pos/inventory" class="nav-link"><span class="nav-link__icon">📦</span> Inventario</a>
<a href="/pos/config" class="nav-link"><span class="nav-link__icon">⚙️</span> Configuracion</a>
</div>
</nav>
<div class="main">
<header class="header">
<div class="header__left">
<button class="hamburger-btn" onclick="toggleSidebar()"></button>
<div class="header__greeting">
<div class="header__title">Catalogo de Proveedores</div>
<div class="header__subtitle">Busca por vehiculo, SKU o nombre de parte</div>
</div>
</div>
</header>
<div class="supplier-catalog themed-scrollbar">
<div class="sc-header">
<div class="sc-search">
<input type="text" id="searchInput" placeholder="Buscar SKU, nombre o intercambio..." onkeydown="if(event.key==='Enter') doSearch()" />
<button class="btn btn--primary" onclick="doSearch()">Buscar</button>
<button class="btn btn--secondary" onclick="clearFilters()">Limpiar</button>
</div>
</div>
<div class="sc-filters">
<select id="filterMake" onchange="onMakeChange()"><option value="">Marca vehiculo</option></select>
<select id="filterModel" onchange="onModelChange()" disabled><option value="">Modelo</option></select>
<select id="filterYear" onchange="onYearChange()" disabled><option value="">Año</option></select>
<select id="filterEngine" onchange="doSearch()" disabled><option value="">Motorizacion</option></select>
</div>
<div class="sc-categories" id="categoriesGrid"></div>
<div id="resultsArea">
<div class="sc-grid" id="partsGrid"></div>
<div class="sc-pagination" id="pagination"></div>
</div>
</div>
</div>
</div>
<!-- Detail Modal -->
<div class="sc-modal-overlay" id="detailModal" onclick="closeModal(event)">
<div class="sc-modal" onclick="event.stopPropagation()">
<div class="sc-modal__header">
<h3 id="modalTitle">Detalle</h3>
<button class="sc-close" onclick="closeModal()">&times;</button>
</div>
<div class="sc-modal__body" id="modalBody"></div>
</div>
</div>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/supplier_catalog.js?v=2" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
</body>
</html>

View File

@@ -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()

274
scripts/clean_fake_models.py Executable file
View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

183
scripts/import_rached_excel.py Executable file
View File

@@ -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("========================================")

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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] <supplier_name|--all>
"""
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] <supplier_name|--all>')
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()

91
test_catalog.js Normal file
View File

@@ -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');
})();