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:
2026-05-26 04:24:07 +00:00
parent 50c0dbe7d4
commit a236187f3a
66 changed files with 7335 additions and 498 deletions

View File

@@ -98,6 +98,133 @@ def generate_report_task(self, report_type, params, tenant_id):
}
# ─── MercadoLibre Tasks ───────────────────────────────────────────────────
@celery.task(bind=True, max_retries=3)
def sync_meli_stock_price_task(self, tenant_id: int):
"""Sync stock and price for all active ML listings."""
from services import marketplace_external_service as meli_svc
from tenant_db import get_tenant_conn
conn = get_tenant_conn(tenant_id)
try:
cfg = meli_svc.get_meli_config(conn)
svc = meli_svc._get_meli_service(cfg)
if not svc:
return {'error': 'MercadoLibre not configured'}
cur = conn.cursor()
cur.execute(
"""
SELECT id, inventory_id, external_item_id, publish_price
FROM marketplace_listings
WHERE is_active = true AND channel = 'mercadolibre'
"""
)
listings = cur.fetchall()
cur.close()
from services.inventory_engine import get_stock_bulk
stock_map = get_stock_bulk(conn, branch_id=None)
updated = 0
failed = 0
for row in listings:
listing_id, inv_id, ext_id, last_price = row
cur = conn.cursor()
cur.execute("SELECT price_1 FROM inventory WHERE id = %s", (inv_id,))
price_row = cur.fetchone()
cur.close()
current_price = float(price_row[0]) if price_row and price_row[0] else 0
current_stock = stock_map.get(inv_id, 0)
try:
svc.update_item(
ext_id,
{"price": round(current_price, 2), "available_quantity": max(current_stock, 0)},
)
cur = conn.cursor()
cur.execute(
"""
UPDATE marketplace_listings
SET last_sync_at = NOW(), sync_errors = NULL, publish_price = %s
WHERE id = %s
""",
(current_price, listing_id),
)
conn.commit()
cur.close()
updated += 1
except Exception as e:
conn.rollback()
cur = conn.cursor()
cur.execute(
"UPDATE marketplace_listings SET sync_errors = %s WHERE id = %s",
(str(e)[:500], listing_id),
)
conn.commit()
cur.close()
failed += 1
return {'updated': updated, 'failed': failed, 'total': len(listings)}
finally:
conn.close()
@celery.task(bind=True, max_retries=3)
def sync_meli_orders_task(self, tenant_id: int):
"""Fetch new orders from MercadoLibre."""
from services import marketplace_external_service as meli_svc
from tenant_db import get_tenant_conn
conn = get_tenant_conn(tenant_id)
try:
# Determine last check date from most recent order
cur = conn.cursor()
cur.execute(
"SELECT MAX(created_at) FROM marketplace_orders WHERE channel = 'mercadolibre'"
)
row = cur.fetchone()
last_check = row[0]
cur.close()
date_from = None
if last_check:
date_from = last_check.strftime('%Y-%m-%dT%H:%M:%S.000-00:00')
result = meli_svc.fetch_and_save_orders(conn, date_from=date_from)
return result
except Exception as e:
return {'error': str(e)}
finally:
conn.close()
@celery.task(bind=True, max_retries=3)
def process_meli_webhook_task(self, tenant_id: int, topic: str, resource: str):
"""Process incoming MercadoLibre webhook asynchronously."""
from services import marketplace_external_service as meli_svc
from tenant_db import get_tenant_conn
conn = get_tenant_conn(tenant_id)
try:
if topic.startswith("orders"):
# Fetch full order and upsert
cfg = meli_svc.get_meli_config(conn)
svc = meli_svc._get_meli_service(cfg)
if svc and resource:
order_id = resource.split("/")[-1]
full = svc.get_order(order_id)
# 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)
return {'ok': True, 'topic': topic}
except Exception as e:
return {'error': str(e)}
finally:
conn.close()
@celery.task(bind=True, max_retries=2)
def sync_vehicle_compatibility_task(self, tenant_id, item_id, part_number, name, brand, compat_source):
"""Fetch AI/TecDoc vehicle compatibility in background after item creation."""