feat: MercadoLibre integration + inventory bulk publish + WhatsApp bridge fixes
- Add MercadoLibre OAuth, listings, orders, webhooks and category search - New marketplace_external_bp.py, meli_service.py, marketplace_external_service.py - New marketplace_external.html/js with ML management UI - Inventory: bulk publish to ML with category autocomplete, listing type and shipping selectors - Inventory: new .btn--meli styles, select/label CSS fixes - WhatsApp bridge: rate limiting, 440/515/408 error handling, stale watchdog - DB migration v3.4_meli_integration.sql for marketplace_listings, orders, sync_queue - Add Celery tasks for ML sync and webhook processing - Sidebar: MercadoLibre navigation link
This commit is contained in:
@@ -35,6 +35,25 @@ def _oem_blocked():
|
||||
return None
|
||||
|
||||
|
||||
def _get_allowed_brands(tenant_conn):
|
||||
"""Read allowed part brands from tenant_config. Returns list or None."""
|
||||
import json
|
||||
cur = tenant_conn.cursor()
|
||||
try:
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'allowed_part_brands'")
|
||||
row = cur.fetchone()
|
||||
if row and row[0]:
|
||||
try:
|
||||
brands = json.loads(row[0])
|
||||
if isinstance(brands, list) and brands:
|
||||
return brands
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
finally:
|
||||
cur.close()
|
||||
return None
|
||||
|
||||
|
||||
def _with_conns(fn):
|
||||
"""Helper: open master + tenant connections, call fn, close both.
|
||||
fn receives (master_conn, tenant_conn, branch_id).
|
||||
@@ -71,6 +90,32 @@ def _master_only(fn):
|
||||
except: pass
|
||||
|
||||
|
||||
def _filter_parts_by_allowed_brands(master_conn, parts_data, allowed_brands):
|
||||
"""Filter a list of part dicts to only include those with aftermarket equivalents
|
||||
from allowed brands. parts_data items must have 'id_part' or 'id' key."""
|
||||
if not allowed_brands or not parts_data:
|
||||
return parts_data
|
||||
part_ids = []
|
||||
for p in parts_data:
|
||||
pid = p.get('id_part') or p.get('id')
|
||||
if pid is not None:
|
||||
part_ids.append(pid)
|
||||
if not part_ids:
|
||||
return parts_data
|
||||
cur = master_conn.cursor()
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT DISTINCT ap.oem_part_id
|
||||
FROM aftermarket_parts ap
|
||||
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||
WHERE ap.oem_part_id = ANY(%s) AND UPPER(m.name_manufacture) = ANY(%s)
|
||||
""", (part_ids, allowed_brands))
|
||||
allowed_ids = {r[0] for r in cur.fetchall()}
|
||||
finally:
|
||||
cur.close()
|
||||
return [p for p in parts_data if (p.get('id_part') or p.get('id')) in allowed_ids]
|
||||
|
||||
|
||||
# ─── Hierarchy navigation (master DB only) ───
|
||||
|
||||
@catalog_bp.route('/brands', methods=['GET'])
|
||||
@@ -150,13 +195,14 @@ def categories():
|
||||
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):
|
||||
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||
if mode == 'local':
|
||||
data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id)
|
||||
else:
|
||||
data = catalog_service.get_categories(master, mye_id)
|
||||
return jsonify({'data': data, 'mode': mode})
|
||||
return _master_only(_do)
|
||||
data = catalog_service.get_categories(master, mye_id, allowed_brands)
|
||||
return jsonify({'data': data, 'mode': mode, 'allowed_brands': allowed_brands or []})
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
@catalog_bp.route('/groups', methods=['GET'])
|
||||
@@ -317,6 +363,7 @@ def parts():
|
||||
return blocked
|
||||
|
||||
def _do(master, tenant, branch_id):
|
||||
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||
if use_nexpart_nav:
|
||||
result = catalog_service.get_parts_for_nexpart_triple(
|
||||
master, mye_id, nexpart_group, nexpart_subgroup, nexpart_part_type,
|
||||
@@ -330,6 +377,9 @@ def parts():
|
||||
result = catalog_service.get_parts(
|
||||
master, mye_id, group_id, tenant, branch_id, page, per_page, part_type=part_type,
|
||||
)
|
||||
if allowed_brands:
|
||||
result['data'] = _filter_parts_by_allowed_brands(master, result.get('data', []), allowed_brands)
|
||||
result['allowed_brands'] = allowed_brands or []
|
||||
return jsonify(result)
|
||||
return _with_conns(_do)
|
||||
|
||||
@@ -358,8 +408,11 @@ def search():
|
||||
limit = request.args.get('limit', 50, type=int)
|
||||
mye_id = request.args.get('mye_id', type=int)
|
||||
def _do(master, tenant, branch_id):
|
||||
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||
data = catalog_service.smart_search(master, q, tenant, branch_id, limit, mye_id)
|
||||
return jsonify({'data': data})
|
||||
if allowed_brands:
|
||||
data = _filter_parts_by_allowed_brands(master, data, allowed_brands)
|
||||
return jsonify({'data': data, 'allowed_brands': allowed_brands or []})
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
@@ -635,10 +688,20 @@ def brand_categories():
|
||||
if not brand:
|
||||
return jsonify({'error': 'brand parameter required'}), 400
|
||||
|
||||
def _query(master):
|
||||
def _query(master, tenant, branch_id):
|
||||
cur = master.cursor()
|
||||
try:
|
||||
cur.execute("""
|
||||
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||
brand_filter = ""
|
||||
params = [brand]
|
||||
if allowed_brands:
|
||||
brand_filter = """AND EXISTS (
|
||||
SELECT 1 FROM aftermarket_parts ap2
|
||||
JOIN manufacturers m2 ON m2.id_manufacture = ap2.manufacturer_id
|
||||
WHERE ap2.oem_part_id = p.id_part AND UPPER(m2.name_manufacture) = ANY(%s)
|
||||
)"""
|
||||
params.append(allowed_brands)
|
||||
cur.execute(f"""
|
||||
SELECT pc.id_part_category,
|
||||
COALESCE(NULLIF(pc.name_es, ''), pc.name_part_category) as name,
|
||||
pc.slug,
|
||||
@@ -648,20 +711,22 @@ def brand_categories():
|
||||
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||
WHERE pvp.name_brand = %s
|
||||
{brand_filter}
|
||||
GROUP BY pc.id_part_category, pc.name_part_category, pc.name_es, pc.slug
|
||||
ORDER BY part_count DESC
|
||||
""", (brand,))
|
||||
""", params)
|
||||
rows = cur.fetchall()
|
||||
return jsonify({
|
||||
'brand': brand,
|
||||
'categories': [
|
||||
{'id': r[0], 'name': r[1], 'slug': r[2], 'part_count': r[3]}
|
||||
for r in rows
|
||||
]
|
||||
],
|
||||
'allowed_brands': allowed_brands or []
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
return _master_only(_query)
|
||||
return _with_conns(_query)
|
||||
|
||||
|
||||
@catalog_bp.route('/brand-parts', methods=['GET'])
|
||||
@@ -680,21 +745,110 @@ def brand_parts():
|
||||
def _query(master, tenant, branch_id):
|
||||
cur = master.cursor()
|
||||
try:
|
||||
# Build dynamic filters
|
||||
params = [brand]
|
||||
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||
|
||||
cat_filter = ""
|
||||
search_filter = ""
|
||||
params = [brand]
|
||||
|
||||
if category_id:
|
||||
cat_filter = "AND pc.id_part_category = %s"
|
||||
params.append(category_id)
|
||||
|
||||
# --- Brand-filtered mode: return aftermarket parts directly ---
|
||||
if allowed_brands:
|
||||
am_search = ""
|
||||
am_params = list(params)
|
||||
if search:
|
||||
am_search = "AND (ap.part_number ILIKE %s OR COALESCE(NULLIF(ap.name_aftermarket_parts, ''), p.name_part) ILIKE %s)"
|
||||
like_term = f"%{search}%"
|
||||
am_params.extend([like_term, like_term])
|
||||
|
||||
query_params = list(am_params)
|
||||
cur.execute(f"""
|
||||
SELECT DISTINCT ap.id_aftermarket_parts,
|
||||
ap.part_number,
|
||||
COALESCE(NULLIF(ap.name_aftermarket_parts, ''), p.name_part) as name,
|
||||
m.name_manufacture,
|
||||
ap.price_usd,
|
||||
p.id_part,
|
||||
pg.id_part_group, pg.name_part_group,
|
||||
pc.id_part_category, pc.name_part_category
|
||||
FROM part_vehicle_preview pvp
|
||||
JOIN parts p ON p.id_part = pvp.part_id
|
||||
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
|
||||
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||
WHERE pvp.name_brand = %s
|
||||
{cat_filter}
|
||||
{am_search}
|
||||
AND UPPER(m.name_manufacture) = ANY(%s)
|
||||
ORDER BY m.name_manufacture, ap.part_number
|
||||
LIMIT %s OFFSET %s
|
||||
""", query_params + [allowed_brands, limit, offset])
|
||||
|
||||
part_rows = cur.fetchall()
|
||||
oem_ids = [r[5] for r in part_rows]
|
||||
|
||||
count_params = list(am_params)
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(DISTINCT ap.id_aftermarket_parts)
|
||||
FROM part_vehicle_preview pvp
|
||||
JOIN parts p ON p.id_part = pvp.part_id
|
||||
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
|
||||
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||
WHERE pvp.name_brand = %s
|
||||
{cat_filter}
|
||||
{am_search}
|
||||
AND UPPER(m.name_manufacture) = ANY(%s)
|
||||
""", count_params + [allowed_brands])
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
local_stock = {}
|
||||
if tenant and oem_ids:
|
||||
try:
|
||||
from services.catalog_service import _get_local_stock_bulk
|
||||
local_stock = _get_local_stock_bulk(tenant, oem_ids)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
items = []
|
||||
for r in part_rows:
|
||||
oem_id = r[5]
|
||||
stock_info = local_stock.get(oem_id, {})
|
||||
items.append({
|
||||
'id': r[0],
|
||||
'oem_part_number': r[1],
|
||||
'name': r[2],
|
||||
'manufacturer': r[3],
|
||||
'price_usd': float(r[4]) if r[4] is not None else None,
|
||||
'oem_id': oem_id,
|
||||
'group': {'id': r[6], 'name': r[7]},
|
||||
'category': {'id': r[8], 'name': r[9]},
|
||||
'local_stock': stock_info.get('stock', 0),
|
||||
'local_price': stock_info.get('price', None),
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'brand': brand,
|
||||
'category_id': category_id,
|
||||
'search': search,
|
||||
'items': items,
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'allowed_brands': allowed_brands
|
||||
})
|
||||
|
||||
# --- Normal mode: return OEM parts ---
|
||||
if search:
|
||||
search_filter = "AND (p.oem_part_number ILIKE %s OR COALESCE(NULLIF(p.name_es, ''), p.name_part) ILIKE %s)"
|
||||
like_term = f"%{search}%"
|
||||
params.extend([like_term, like_term])
|
||||
|
||||
# Get parts from the brand catalog
|
||||
query_params = list(params)
|
||||
cur.execute(f"""
|
||||
SELECT DISTINCT p.id_part, p.oem_part_number,
|
||||
@@ -715,7 +869,7 @@ def brand_parts():
|
||||
part_rows = cur.fetchall()
|
||||
part_ids = [r[0] for r in part_rows]
|
||||
|
||||
# Count total
|
||||
count_params = list(params)
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(DISTINCT p.id_part)
|
||||
FROM part_vehicle_preview pvp
|
||||
@@ -725,10 +879,9 @@ def brand_parts():
|
||||
WHERE pvp.name_brand = %s
|
||||
{cat_filter}
|
||||
{search_filter}
|
||||
""", params)
|
||||
""", count_params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
# Enrich with local stock if available
|
||||
local_stock = {}
|
||||
if tenant and part_ids:
|
||||
try:
|
||||
@@ -759,6 +912,7 @@ def brand_parts():
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'allowed_brands': []
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
@@ -784,7 +938,8 @@ def mye_parts():
|
||||
def _query(master, tenant, branch_id):
|
||||
cur = master.cursor()
|
||||
try:
|
||||
# Build dynamic filters
|
||||
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||
|
||||
cat_filter = ""
|
||||
search_filter = ""
|
||||
params = [mye_id]
|
||||
@@ -793,12 +948,103 @@ def mye_parts():
|
||||
cat_filter = "AND pc.id_part_category = %s"
|
||||
params.append(category_id)
|
||||
|
||||
# --- Brand-filtered mode: return aftermarket parts directly ---
|
||||
if allowed_brands:
|
||||
am_search = ""
|
||||
am_params = list(params)
|
||||
if search:
|
||||
am_search = "AND (ap.part_number ILIKE %s OR COALESCE(NULLIF(ap.name_aftermarket_parts, ''), p.name_part) ILIKE %s)"
|
||||
like_term = f"%{search}%"
|
||||
am_params.extend([like_term, like_term])
|
||||
|
||||
# Get aftermarket parts
|
||||
query_params = list(am_params)
|
||||
cur.execute(f"""
|
||||
SELECT DISTINCT ap.id_aftermarket_parts,
|
||||
ap.part_number,
|
||||
COALESCE(NULLIF(ap.name_aftermarket_parts, ''), p.name_part) as name,
|
||||
m.name_manufacture,
|
||||
ap.price_usd,
|
||||
p.id_part,
|
||||
pg.id_part_group, pg.name_part_group,
|
||||
pc.id_part_category, pc.name_part_category
|
||||
FROM vehicle_parts vp
|
||||
JOIN parts p ON p.id_part = vp.part_id
|
||||
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
|
||||
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||
WHERE vp.model_year_engine_id = %s
|
||||
{cat_filter}
|
||||
{am_search}
|
||||
AND UPPER(m.name_manufacture) = ANY(%s)
|
||||
ORDER BY m.name_manufacture, ap.part_number
|
||||
LIMIT %s OFFSET %s
|
||||
""", query_params + [allowed_brands, limit, offset])
|
||||
|
||||
part_rows = cur.fetchall()
|
||||
oem_ids = [r[5] for r in part_rows]
|
||||
|
||||
# Count total
|
||||
count_params = list(am_params)
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(DISTINCT ap.id_aftermarket_parts)
|
||||
FROM vehicle_parts vp
|
||||
JOIN parts p ON p.id_part = vp.part_id
|
||||
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
|
||||
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||
WHERE vp.model_year_engine_id = %s
|
||||
{cat_filter}
|
||||
{am_search}
|
||||
AND UPPER(m.name_manufacture) = ANY(%s)
|
||||
""", count_params + [allowed_brands])
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
# Local stock keyed by OEM part id
|
||||
local_stock = {}
|
||||
if tenant and oem_ids:
|
||||
try:
|
||||
from services.catalog_service import _get_local_stock_bulk
|
||||
local_stock = _get_local_stock_bulk(tenant, oem_ids)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
items = []
|
||||
for r in part_rows:
|
||||
oem_id = r[5]
|
||||
stock_info = local_stock.get(oem_id, {})
|
||||
items.append({
|
||||
'id': r[0],
|
||||
'oem_part_number': r[1],
|
||||
'name': r[2],
|
||||
'manufacturer': r[3],
|
||||
'price_usd': float(r[4]) if r[4] is not None else None,
|
||||
'oem_id': oem_id,
|
||||
'group': {'id': r[6], 'name': r[7]},
|
||||
'category': {'id': r[8], 'name': r[9]},
|
||||
'local_stock': stock_info.get('stock', 0),
|
||||
'local_price': stock_info.get('price', None),
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'mye_id': mye_id,
|
||||
'category_id': category_id,
|
||||
'search': search,
|
||||
'items': items,
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'allowed_brands': allowed_brands
|
||||
})
|
||||
|
||||
# --- Normal mode: return OEM parts ---
|
||||
if search:
|
||||
search_filter = "AND (p.oem_part_number ILIKE %s OR COALESCE(NULLIF(p.name_es, ''), p.name_part) ILIKE %s)"
|
||||
like_term = f"%{search}%"
|
||||
params.extend([like_term, like_term])
|
||||
|
||||
# Get parts
|
||||
query_params = list(params)
|
||||
cur.execute(f"""
|
||||
SELECT DISTINCT p.id_part, p.oem_part_number,
|
||||
@@ -819,7 +1065,7 @@ def mye_parts():
|
||||
part_rows = cur.fetchall()
|
||||
part_ids = [r[0] for r in part_rows]
|
||||
|
||||
# Count total
|
||||
count_params = list(params)
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(DISTINCT p.id_part)
|
||||
FROM vehicle_parts vp
|
||||
@@ -829,10 +1075,9 @@ def mye_parts():
|
||||
WHERE vp.model_year_engine_id = %s
|
||||
{cat_filter}
|
||||
{search_filter}
|
||||
""", params)
|
||||
""", count_params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
# Enrich with local stock if available
|
||||
local_stock = {}
|
||||
if tenant and part_ids:
|
||||
try:
|
||||
@@ -863,6 +1108,7 @@ def mye_parts():
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'allowed_brands': []
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
Reference in New Issue
Block a user