Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- 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:
86
pos/tasks.py
86
pos/tasks.py
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user