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:
@@ -10,11 +10,19 @@ Routes:
|
||||
Listings
|
||||
GET /pos/api/marketplace-ext/listings
|
||||
POST /pos/api/marketplace-ext/listings
|
||||
POST /pos/api/marketplace-ext/listings/import-existing
|
||||
POST /pos/api/marketplace-ext/listings/backfill-inventory
|
||||
POST /pos/api/marketplace-ext/listings/<id>/sync
|
||||
POST /pos/api/marketplace-ext/listings/<id>/pause
|
||||
POST /pos/api/marketplace-ext/listings/<id>/activate
|
||||
DELETE /pos/api/marketplace-ext/listings/<id>
|
||||
|
||||
Questions
|
||||
GET /pos/api/marketplace-ext/questions
|
||||
POST /pos/api/marketplace-ext/questions/sync
|
||||
POST /pos/api/marketplace-ext/questions/<id>/answer
|
||||
GET /pos/api/marketplace-ext/questions/<id>/suggestion (AI, A7)
|
||||
|
||||
Orders
|
||||
GET /pos/api/marketplace-ext/orders
|
||||
GET /pos/api/marketplace-ext/orders/<id>
|
||||
@@ -62,10 +70,84 @@ def _require_meli_manage():
|
||||
return None
|
||||
|
||||
|
||||
def _safe_int(value, default):
|
||||
"""Parse an int from a query param, falling back to a default instead of 500."""
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _get_meli_config(tenant_id):
|
||||
"""Return the tenant's ML config dict, or None if the tenant is unknown."""
|
||||
try:
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
return meli_svc.get_meli_config(conn)
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _resolve_tenant_by_meli_user(meli_user_id):
|
||||
"""Find the tenant whose tenant_config.meli_user_id matches.
|
||||
|
||||
tenant_config lives in each tenant DB (not in master), so we iterate the
|
||||
active tenants from master and probe each one.
|
||||
"""
|
||||
if not meli_user_id:
|
||||
return None
|
||||
mconn = get_master_conn()
|
||||
try:
|
||||
mcur = mconn.cursor()
|
||||
mcur.execute("SELECT id FROM tenants WHERE is_active = true ORDER BY id")
|
||||
tenant_ids = [r[0] for r in mcur.fetchall()]
|
||||
mcur.close()
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
mconn.close()
|
||||
|
||||
target = str(meli_user_id)
|
||||
for tid in tenant_ids:
|
||||
try:
|
||||
conn = get_tenant_conn(tid)
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT 1 FROM tenant_config WHERE key = 'meli_user_id' AND value = %s LIMIT 1",
|
||||
(target,),
|
||||
)
|
||||
found = cur.fetchone() is not None
|
||||
cur.close()
|
||||
if found:
|
||||
return tid
|
||||
except Exception:
|
||||
continue
|
||||
finally:
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# CONFIG
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _meli_auth_error(e):
|
||||
"""Respuesta 401 para MeliAuthError: propaga el detalle accionable
|
||||
(p. ej. credenciales de app rechazadas) en vez del mensaje genérico."""
|
||||
detail = str(e)
|
||||
if ('credenciales' in detail.lower() or 'refresh failed' in detail.lower()
|
||||
or 'invalid_client' in detail.lower()):
|
||||
return jsonify({'error': detail}), 401
|
||||
return jsonify({'error': 'La sesión de MercadoLibre expiró. Reconecta la cuenta en la pestaña de configuración.'}), 401
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/config", methods=["GET"])
|
||||
@require_auth()
|
||||
def get_config():
|
||||
@@ -78,6 +160,7 @@ def get_config():
|
||||
if k not in ("meli_access_token", "meli_refresh_token", "meli_client_secret")
|
||||
}
|
||||
safe["connected"] = bool(cfg.get("meli_access_token"))
|
||||
safe["has_refresh_token"] = bool(cfg.get("meli_refresh_token"))
|
||||
return jsonify(safe)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -178,12 +261,17 @@ def connect_meli():
|
||||
"meli_client_id": client_id,
|
||||
"meli_client_secret": client_secret,
|
||||
})
|
||||
return jsonify({
|
||||
resp = {
|
||||
"ok": True,
|
||||
"user_id": user_id or user.get("id"),
|
||||
"nickname": user.get("nickname"),
|
||||
"site_id": user.get("site_id"),
|
||||
})
|
||||
}
|
||||
if not refresh_token:
|
||||
# The ML app lacks offline_access scope: the connection will die
|
||||
# in ~6h and cannot be auto-renewed. Warn so it can be fixed.
|
||||
resp["warning"] = "no_refresh_token"
|
||||
return jsonify(resp)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -214,13 +302,13 @@ def search_categories():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cfg = meli_svc.get_meli_config(conn)
|
||||
svc = meli_svc._get_meli_service(cfg)
|
||||
svc = meli_svc._get_meli_service(cfg, conn)
|
||||
if not svc:
|
||||
return jsonify({"error": "MercadoLibre not connected"}), 400
|
||||
return jsonify({"error": "MercadoLibre no está conectado. Conecta la cuenta en la pestaña de configuración."}), 400
|
||||
result = svc.search_categories(site_id, q)
|
||||
return jsonify({"categories": result})
|
||||
except MeliAuthError:
|
||||
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
|
||||
except MeliAuthError as e:
|
||||
return _meli_auth_error(e)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
@@ -234,16 +322,16 @@ def search_categories():
|
||||
@marketplace_ext_bp.route("/listings", methods=["GET"])
|
||||
@require_auth()
|
||||
def list_listings():
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = min(int(request.args.get("per_page", 50)), 200)
|
||||
page = max(1, _safe_int(request.args.get("page"), 1))
|
||||
per_page = min(max(1, _safe_int(request.args.get("per_page"), 50)), 200)
|
||||
status = request.args.get("status")
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
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 MeliAuthError as e:
|
||||
return _meli_auth_error(e)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
@@ -279,6 +367,7 @@ def create_listings():
|
||||
shipping_mode=shipping_mode,
|
||||
custom_data=custom_data,
|
||||
base_url=_get_public_base_url(),
|
||||
use_llm_description=bool(data.get("use_llm_description", False)),
|
||||
)
|
||||
return jsonify(result), 201
|
||||
except ValueError as e:
|
||||
@@ -309,6 +398,36 @@ def import_existing_listings():
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/listings/backfill-inventory", methods=["POST"])
|
||||
@require_auth()
|
||||
def backfill_listing_inventory():
|
||||
"""Map already-imported listings to local inventory (populate inventory_id).
|
||||
|
||||
Body: {"dry_run": bool (default false), "limit": int|null,
|
||||
"only_unmapped": bool (default true)}.
|
||||
"""
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.backfill_listing_inventory(
|
||||
conn,
|
||||
limit=_safe_int(data.get("limit"), None) if data.get("limit") else None,
|
||||
only_unmapped=bool(data.get("only_unmapped", True)),
|
||||
dry_run=bool(data.get("dry_run", False)),
|
||||
)
|
||||
return jsonify(result), 200
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/inventory-check", methods=["POST"])
|
||||
@require_auth()
|
||||
def inventory_check():
|
||||
@@ -338,7 +457,8 @@ def sync_stock_to_meli():
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.process_meli_sync_queue(conn)
|
||||
# Manual run from the UI: bypass the meli_auto_sync_stock_price gate.
|
||||
result = meli_svc.process_meli_sync_queue(conn, force=True)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
@@ -355,15 +475,41 @@ def category_attributes(category_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cfg = meli_svc.get_meli_config(conn)
|
||||
svc = meli_svc._get_meli_service(cfg)
|
||||
svc = meli_svc._get_meli_service(cfg, conn)
|
||||
if not svc:
|
||||
return jsonify({"error": "MercadoLibre not connected"}), 400
|
||||
return jsonify({"error": "MercadoLibre no está conectado. Conecta la cuenta en la pestaña de configuración."}), 400
|
||||
attrs = svc.get_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 MeliAuthError as e:
|
||||
return _meli_auth_error(e)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/listing-prices", methods=["GET"])
|
||||
@require_auth()
|
||||
def listing_prices():
|
||||
"""Fee estimator: return MeLi fees per listing type for a price + category."""
|
||||
price = request.args.get("price", type=float)
|
||||
category_id = request.args.get("category_id", "")
|
||||
site_id = request.args.get("site_id", "MLM")
|
||||
if not price or price <= 0 or not category_id:
|
||||
return jsonify({"error": "price y category_id son requeridos"}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cfg = meli_svc.get_meli_config(conn)
|
||||
svc = meli_svc._get_meli_service(cfg, conn)
|
||||
if not svc:
|
||||
return jsonify({"error": "MercadoLibre no está conectado. Conecta la cuenta en la pestaña de configuración."}), 400
|
||||
result = svc.get_listing_prices(site_id, price, category_id)
|
||||
return jsonify({"listing_prices": result})
|
||||
except MeliAuthError as e:
|
||||
return _meli_auth_error(e)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
@@ -400,6 +546,7 @@ def validate_listings():
|
||||
shipping_mode=shipping_mode,
|
||||
custom_data=custom_data,
|
||||
base_url=_get_public_base_url(),
|
||||
use_llm_description=bool(data.get("use_llm_description", False)),
|
||||
)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
@@ -440,6 +587,8 @@ def create_listings_async():
|
||||
shipping_mode=shipping_mode,
|
||||
custom_data=custom_data,
|
||||
base_url=_get_public_base_url(),
|
||||
chunk_size=_safe_int(data.get("chunk_size"), 10),
|
||||
use_llm_description=bool(data.get("use_llm_description", False)),
|
||||
)
|
||||
return jsonify({"task_id": task.id, "status": "queued"}), 202
|
||||
except Exception as e:
|
||||
@@ -452,7 +601,7 @@ def get_async_listing_status(task_id):
|
||||
"""Get status of an async ML publishing task."""
|
||||
try:
|
||||
from celery.result import AsyncResult
|
||||
from app import celery as celery_app
|
||||
from celery_app import celery as celery_app
|
||||
result = AsyncResult(task_id, app=celery_app)
|
||||
if result.ready():
|
||||
return jsonify({"status": "done", "result": result.result or {}})
|
||||
@@ -616,6 +765,34 @@ def answer_question(question_id):
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/questions/<int:question_id>/suggestion", methods=["GET"])
|
||||
@require_auth()
|
||||
def question_suggestion(question_id):
|
||||
"""AI intent classification + suggested answer for a buyer question.
|
||||
|
||||
Query param: ?use_llm=0 to skip the LLM fallback and use templates only.
|
||||
The answer is only a suggestion; auto-answering is opt-in per tenant via
|
||||
tenant_config `meli_auto_answer_questions`.
|
||||
"""
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
use_llm = request.args.get("use_llm", "1").lower() not in ("0", "false", "no")
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.suggest_question_answer(conn, question_id, use_llm=use_llm)
|
||||
result["auto_answer_enabled"] = bool(
|
||||
meli_svc.get_meli_config(conn).get("meli_auto_answer_questions")
|
||||
)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 404
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# ORDERS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -643,8 +820,8 @@ def sync_orders():
|
||||
@marketplace_ext_bp.route("/orders", methods=["GET"])
|
||||
@require_auth()
|
||||
def list_orders():
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = min(int(request.args.get("per_page", 50)), 200)
|
||||
page = max(1, _safe_int(request.args.get("page"), 1))
|
||||
per_page = min(max(1, _safe_int(request.args.get("per_page"), 50)), 200)
|
||||
status = request.args.get("status")
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -673,11 +850,15 @@ def get_order(order_id):
|
||||
def convert_order(order_id):
|
||||
data = request.get_json() or {}
|
||||
register_id = data.get("register_id")
|
||||
# Mirrors the counter-sale "Facturar CFDI" checkbox; when omitted the sale
|
||||
# is only invoiced if the tenant has meli_auto_invoice = true.
|
||||
generate_cfdi = bool(data.get("generate_cfdi", False))
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.convert_order_to_sale(
|
||||
conn, order_id, employee_id=g.employee_id, register_id=register_id
|
||||
conn, order_id, employee_id=g.employee_id, register_id=register_id,
|
||||
generate_cfdi=generate_cfdi,
|
||||
)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
@@ -716,38 +897,35 @@ def update_order_status_route(order_id):
|
||||
def meli_webhook():
|
||||
"""Receive MercadoLibre notifications.
|
||||
|
||||
ML sends a lightweight payload with topic + resource URL.
|
||||
We ack immediately and enqueue Celery for async processing.
|
||||
ML sends a lightweight payload with topic + resource URL. We resolve the
|
||||
tenant from ML's user_id, validate the X-Signature header when present, ack
|
||||
immediately and enqueue Celery to process the specific resource.
|
||||
"""
|
||||
raw_body = request.get_data()
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
topic = data.get("topic", "")
|
||||
resource = data.get("resource", "")
|
||||
user_id = data.get("user_id")
|
||||
|
||||
# Resolve tenant by meli_user_id
|
||||
tenant_id = None
|
||||
if user_id:
|
||||
try:
|
||||
mconn = get_master_conn()
|
||||
mcur = mconn.cursor()
|
||||
mcur.execute(
|
||||
"""
|
||||
SELECT t.id FROM tenants t
|
||||
JOIN tenant_config c ON c.key = 'meli_user_id' AND c.value = %s
|
||||
WHERE t.is_active = true
|
||||
LIMIT 1
|
||||
""",
|
||||
(str(user_id),),
|
||||
)
|
||||
row = mcur.fetchone()
|
||||
if row:
|
||||
tenant_id = row[0]
|
||||
mcur.close()
|
||||
mconn.close()
|
||||
except Exception:
|
||||
pass
|
||||
# tenant_config lives in each tenant DB, not in master
|
||||
tenant_id = _resolve_tenant_by_meli_user(user_id)
|
||||
if not tenant_id:
|
||||
print(f"[ML Webhook] No tenant found for meli_user_id={user_id}")
|
||||
return jsonify({"error": "unknown tenant"}), 403
|
||||
|
||||
if tenant_id and topic:
|
||||
cfg = _get_meli_config(tenant_id) or {}
|
||||
secret = cfg.get("meli_client_secret")
|
||||
signature = request.headers.get("X-Signature")
|
||||
if signature:
|
||||
if not secret or not MeliService.validate_webhook_signature(secret, raw_body, signature):
|
||||
print(f"[ML Webhook] Invalid X-Signature for tenant {tenant_id}")
|
||||
return jsonify({"error": "invalid signature"}), 401
|
||||
else:
|
||||
# ML only signs notifications when signature validation is enabled on
|
||||
# the app; without the header there is nothing to verify.
|
||||
print(f"[ML Webhook] Notification without X-Signature (tenant {tenant_id}, topic={topic})")
|
||||
|
||||
if topic:
|
||||
try:
|
||||
from tasks import process_meli_webhook_task
|
||||
process_meli_webhook_task.delay(tenant_id, topic, resource)
|
||||
|
||||
Reference in New Issue
Block a user