Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos
- F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado
- F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment
- F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings)
- F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant
- Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas
- Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant)
- Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant
- Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
This commit is contained in:
2026-10-12 00:57:31 +00:00
parent f2d4558b80
commit a386ca5b4a
165 changed files with 31313 additions and 2203 deletions

View File

@@ -102,14 +102,24 @@ def generate_report_task(self, report_type, params, tenant_id):
@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."""
"""Sync stock and price for all active ML listings.
Opt-in per tenant via tenant_config.meli_auto_sync_stock_price. Without it
the task is a no-op: pushing the local POS price/quantity to ML zeroes and
pauses any listing whose local product has no stock, which destroys sales
for tenants whose inventory is not (yet) loaded. Enable the flag only once
the tenant's stock is trustworthy.
"""
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 cfg.get('meli_auto_sync_stock_price'):
return {'skipped': True, 'reason': 'meli_auto_sync_stock_price disabled'}
# Pass conn so rotated OAuth tokens are persisted back to tenant_config
svc = meli_svc._get_meli_service(cfg, conn)
if not svc:
return {'error': 'MercadoLibre not configured'}
@@ -129,6 +139,7 @@ def sync_meli_stock_price_task(self, tenant_id: int):
updated = 0
failed = 0
skipped = 0
for row in listings:
listing_id, inv_id, ext_id, last_price = row
cur = conn.cursor()
@@ -138,10 +149,18 @@ def sync_meli_stock_price_task(self, tenant_id: int):
current_price = float(price_row[0]) if price_row and price_row[0] else 0
current_stock = stock_map.get(inv_id, 0)
# Never push price <= 0: ML rejects it and it would blank out the
# live publication. This also covers listings not linked to any
# local inventory (inventory_id NULL), where there is no reliable
# stock either — skip them instead of pushing quantity 0.
if current_price <= 0:
skipped += 1
continue
try:
svc.update_item(
ext_id,
{"price": round(current_price, 2), "available_quantity": max(current_stock, 0)},
{"price": round(current_price, 2), "available_quantity": max(int(current_stock or 0), 0)},
)
cur = conn.cursor()
cur.execute(
@@ -166,7 +185,20 @@ def sync_meli_stock_price_task(self, tenant_id: int):
cur.close()
failed += 1
return {'updated': updated, 'failed': failed, 'total': len(listings)}
# Drain the stock-sync queue so pause/reactivate-by-stock takes effect.
# record_operation() enqueues here whenever a listed product's stock
# moves; process_meli_sync_queue pauses items at 0 stock and reactivates
# them once stock returns. Best-effort: never fail the main sync.
queued = 0
try:
qres = meli_svc.process_meli_sync_queue(conn, batch_size=100)
if isinstance(qres, dict):
queued = qres.get('processed', 0)
except Exception:
pass
return {'updated': updated, 'failed': failed, 'skipped': skipped,
'total': len(listings), 'queued': queued}
finally:
conn.close()
@@ -202,32 +234,31 @@ def sync_meli_orders_task(self, tenant_id: int):
@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."""
"""Process a single MercadoLibre webhook notification.
Handles the concrete resource notified by ML (order / question / item)
instead of triggering a full sync.
"""
from services import marketplace_external_service as meli_svc
from tenant_db import get_tenant_conn
conn = get_tenant_conn(tenant_id)
try:
resource_id = (resource or "").rstrip("/").split("/")[-1]
if not resource_id:
return {'ok': True, 'topic': topic, 'skipped': 'no resource'}
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)
result = meli_svc.fetch_and_save_order(conn, resource_id)
return {'ok': True, 'topic': topic, **result}
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)}
meli_svc.fetch_question_from_ml(conn, resource_id)
return {'ok': True, 'topic': topic, 'question_id': resource_id}
if topic.startswith("items"):
result = meli_svc.sync_listing_from_meli(conn, resource_id)
return {'ok': True, 'topic': topic, **result}
return {'ok': True, 'topic': topic}
except Exception as e:
@@ -281,8 +312,13 @@ 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, base_url: str = None):
"""Publish inventory items to MercadoLibre asynchronously."""
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, chunk_size: int = 10, use_llm_description: bool = False):
"""Publish inventory items to MercadoLibre asynchronously.
Publishes in chunks of `chunk_size` with a short pause between them to
stay clear of ML's rate limit (429) instead of firing the whole batch at
once. Set chunk_size=0 to disable pacing.
"""
from services import marketplace_external_service as meli_svc
from tenant_db import get_tenant_conn
@@ -296,6 +332,8 @@ def publish_meli_items_task(self, tenant_id: int, inventory_ids: list, category_
shipping_mode=shipping_mode,
custom_data=custom_data or {},
base_url=base_url,
use_llm_description=use_llm_description,
chunk_size=chunk_size,
)
return result
except Exception as e: