Files
Autoparts-DB/pos/services/marketplace_external_service.py
consultoria-as a386ca5b4a
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
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
2026-10-12 00:57:31 +00:00

2953 lines
111 KiB
Python

"""Business logic for MercadoLibre external marketplace integration.
Depends on:
- meli_service.py (HTTP client)
- pos_engine.py (sale creation)
- inventory_engine.py (stock queries)
- image_service.py (image URLs)
"""
import json
import logging
import re
import time
import urllib.parse
from typing import Optional
from decimal import Decimal
from services.meli_service import MeliService, MeliError, MeliAuthError
from services.pos_engine import process_sale
from services.inventory_engine import get_stock, get_stock_bulk
logger = logging.getLogger(__name__)
def _resolve_image_urls(images: list[str], base_url: str | None = None) -> list[str]:
"""Convert relative image paths to absolute URLs."""
resolved = []
for url in images:
if not url:
continue
if base_url and url.startswith("/"):
resolved.append(urllib.parse.urljoin(base_url.rstrip("/") + "/", url.lstrip("/")))
else:
resolved.append(url)
return resolved
# ─── Used-parts helpers (Yonke M4) ──────────────────────────────────────────
# ML's item-level `condition` only accepts new/used/not_specified.
MELI_CONDITION_MAP = {"nuevo": "new", "usado": "used", "reconstruido": "used"}
MELI_MAX_PICTURES = 10
def _derive_meli_condition(condition) -> str:
"""Map inventory.condition (nuevo|usado|reconstruido) to ML's condition.
Missing/unknown values default to 'new', so the new-parts flow is unchanged.
"""
return MELI_CONDITION_MAP.get(str(condition or "").strip().lower(), "new")
# Used parts (Yonke) historically went out under the tenant's fixed
# `meli_default_category_id`, which points at a new-parts category: ML either
# rejects the used condition there or files the listing under the wrong
# taxonomy. When a used part has no explicit category we ask ML's own
# predictor (domain_discovery) for the best category from its name/marca/donor.
USED_CONDITIONS = ("usado", "reconstruido")
def _is_used_condition(condition) -> bool:
return str(condition or "").strip().lower() in USED_CONDITIONS
def _category_query(inventory_row: dict) -> str:
"""Build the free-text query fed to ML's category predictor."""
parts = (
inventory_row.get("name"),
inventory_row.get("brand"),
inventory_row.get("part_number"),
inventory_row.get("donor_make"),
inventory_row.get("donor_model"),
inventory_row.get("donor_year"),
)
return " ".join(str(p).strip() for p in parts if p and str(p).strip())
def _domain_discovery_categories(response) -> list[dict]:
"""Normalize a domain_discovery/search response into category candidates.
ML answers with a ranked list like
[{domain_id, domain_name, category_id, category_name, attributes}, ...].
Error payloads and unexpected shapes yield an empty list (degradation).
"""
if isinstance(response, dict):
response = [response] if response.get("category_id") else []
if not isinstance(response, list):
return []
return [
item for item in response
if isinstance(item, dict) and item.get("category_id")
]
def _meli_category_valid(svc, category_id: str, cache: dict) -> bool:
"""Confirm a predicted category exists (cached per publish batch)."""
if category_id in cache:
return cache[category_id]
valid = False
try:
cat = svc.get_category(category_id)
valid = bool(isinstance(cat, dict) and cat.get("id") == category_id)
except Exception as exc:
logger.warning("ML category validation failed for %s: %s", category_id, exc)
cache[category_id] = valid
return valid
def resolve_meli_category(
svc,
site_id: str,
inventory_row: dict,
provided_category_id,
tenant_default_category_id=None,
cache: dict = None,
) -> str:
"""Return the ML category to publish `inventory_row` under.
Used/rebuilt parts that carry no explicit category are routed through ML's
domain_discovery predictor (name + marca + part/donor data) and the
predicted category is validated with GET /categories/{id}. Any ML failure
degrades to the provided/default category so publishing keeps working when
ML is unreachable.
A category counts as explicit when it is neither empty nor the tenant's
fixed `meli_default_category_id` (the UI pre-fills that one for every
listing). New parts keep the caller's category untouched.
Known ML limitation: we cannot verify category-by-category whether a used
condition is accepted, nor reliably fill the category-specific attributes
ML may require for used items (domain_discovery returns `attributes`, but
their expected value_ids differ per category). Such rejects surface from
/items/validate and /items as a normal listing error.
"""
provided = str(provided_category_id or "").strip()
default = str(tenant_default_category_id or "").strip()
# Degrade to the tenant default when the caller passed no category at all.
fallback = provided or default
explicit = bool(provided) and (not default or provided != default)
if explicit or not _is_used_condition(inventory_row.get("condition")):
return fallback
query = _category_query(inventory_row)
if len(query) < 3:
return fallback
cache = cache if cache is not None else {}
try:
matches = _domain_discovery_categories(svc.search_categories(site_id, query))
except Exception as exc:
logger.warning(
"ML domain_discovery failed for '%s' (%s) — keeping category %s",
inventory_row.get("name"), exc, fallback or "(none)",
)
return fallback
for match in matches:
candidate = str(match.get("category_id") or "").strip()
if candidate and _meli_category_valid(svc, candidate, cache):
if candidate != provided:
logger.info(
"Used part %s: ML category resolved to %s (%s)",
inventory_row.get("id"), candidate, match.get("category_name"),
)
return candidate
return fallback
def _item_category_id(
svc, site_id, inventory_row, inventory_id,
provided_category_id, cfg, custom_data, cache,
) -> str:
"""Resolve the ML category for one item of a publish/validate batch.
An explicit per-item category (custom_data.categories[<inventory_id>])
always wins; otherwise used/rebuilt parts go through
`resolve_meli_category`, which degrades to the provided/default category.
"""
explicit = (custom_data.get("categories") or {}).get(str(inventory_id))
if explicit:
return str(explicit).strip()
return resolve_meli_category(
svc, site_id, inventory_row, provided_category_id,
tenant_default_category_id=(cfg or {}).get("meli_default_category_id"),
cache=cache,
)
def _merge_image_urls(inventory_images_urls, legacy_image_url) -> list[str]:
"""Merge inventory_images URLs (primary first) with the legacy image_url.
Preserves order, drops blanks/duplicates and caps at ML's 10-picture limit.
"""
candidates = list(inventory_images_urls or [])
if legacy_image_url:
candidates.append(legacy_image_url)
merged = []
for url in candidates:
if url and url not in merged:
merged.append(url)
return merged[:MELI_MAX_PICTURES]
def _fetch_inventory_image_urls(cur, inventory_ids: list[int]) -> dict:
"""Return {inventory_id: [url, ...]} ordered primary-first then sort_order."""
if not inventory_ids:
return {}
cur.execute(
"""
SELECT inventory_id, url
FROM inventory_images
WHERE inventory_id = ANY(%s) AND url IS NOT NULL AND url <> ''
ORDER BY inventory_id, is_primary DESC, sort_order ASC, id ASC
""",
(inventory_ids,),
)
out = {}
for inv_id, url in cur.fetchall():
out.setdefault(inv_id, []).append(url)
return out
# ═══════════════════════════════════════════════════════════════════════════
# CONFIG HELPERS
# ═══════════════════════════════════════════════════════════════════════════
MELI_CONFIG_KEYS = [
"meli_access_token",
"meli_refresh_token",
"meli_user_id",
"meli_site_id",
"meli_enabled",
"meli_auto_publish",
"meli_auto_answer_questions",
"meli_sync_interval_min",
"meli_order_sync_interval_min",
"meli_default_category_id",
"meli_shipping_mode",
"meli_client_id",
"meli_client_secret",
"meli_user_product_seller",
"meli_auto_sync_stock_price",
]
def _get_config_value(cur, key: str, default=None):
cur.execute("SELECT value FROM tenant_config WHERE key = %s", (key,))
row = cur.fetchone()
return row[0] if row else default
def get_meli_config(tenant_conn) -> dict:
"""Read ML config from tenant_config."""
cur = tenant_conn.cursor()
cfg = {}
for key in MELI_CONFIG_KEYS:
cfg[key] = _get_config_value(cur, key)
cur.close()
# Normalize booleans
cfg["meli_enabled"] = (cfg.get("meli_enabled") or "").lower() == "true"
cfg["meli_auto_publish"] = (cfg.get("meli_auto_publish") or "").lower() == "true"
cfg["meli_auto_answer_questions"] = (
cfg.get("meli_auto_answer_questions") or ""
).lower() == "true"
# Safety gate for the automatic stock/price push (see sync_meli_stock_price_task
# and process_meli_sync_queue): OFF unless the tenant explicitly enables it.
cfg["meli_auto_sync_stock_price"] = (
cfg.get("meli_auto_sync_stock_price") or ""
).lower() == "true"
cfg["meli_sync_interval_min"] = int(cfg.get("meli_sync_interval_min") or 15)
cfg["meli_order_sync_interval_min"] = int(cfg.get("meli_order_sync_interval_min") or 5)
return cfg
def save_meli_config(tenant_conn, updates: dict) -> None:
"""Upsert ML config keys into tenant_config."""
cur = tenant_conn.cursor()
for key, value in updates.items():
if value is None:
continue
cur.execute(
"""
INSERT INTO tenant_config (key, value, updated_at)
VALUES (%s, %s, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
""",
(key, str(value)),
)
tenant_conn.commit()
cur.close()
def delete_meli_config(tenant_conn) -> None:
"""Remove all ML config keys."""
cur = tenant_conn.cursor()
cur.execute(
"DELETE FROM tenant_config WHERE key LIKE 'meli_%'"
)
tenant_conn.commit()
cur.close()
def is_user_product_seller(tenant_conn, svc, cfg) -> bool:
"""Detect MercadoLibre "User Products" sellers (tag user_product_seller).
UP sellers must publish with family_name only — sending title is rejected
by the ML API. Result is cached in tenant_config to avoid calling
/users/me on every publish.
"""
cached = cfg.get("meli_user_product_seller")
if cached is not None and str(cached) != "":
return str(cached).lower() == "true"
try:
user = svc.get_user()
is_up = "user_product_seller" in (user.get("tags") or [])
except Exception:
is_up = False
save_meli_config(tenant_conn, {"meli_user_product_seller": "true" if is_up else "false"})
return is_up
def _get_meli_service(cfg: dict, tenant_conn=None) -> Optional[MeliService]:
"""Build MeliService from config dict.
When tenant_conn is provided, refreshed OAuth tokens are persisted back to
tenant_config so the connection survives token rotation and restarts.
"""
token = cfg.get("meli_access_token")
if not token:
return None
on_refresh = None
if tenant_conn is not None:
def on_refresh(data):
save_meli_config(tenant_conn, {
"meli_access_token": data.get("access_token"),
"meli_refresh_token": data.get("refresh_token"),
})
return MeliService(
access_token=token,
refresh_token=cfg.get("meli_refresh_token"),
client_id=cfg.get("meli_client_id"),
client_secret=cfg.get("meli_client_secret"),
on_refresh=on_refresh,
)
# ═══════════════════════════════════════════════════════════════════════════
# ITEM PAYLOAD BUILDER
# ═══════════════════════════════════════════════════════════════════════════
def _extract_meli_error(err: MeliError) -> str:
"""Extract the most useful error message from a MeliError response."""
base = str(err)
body = err.response_body
if not body:
return base
try:
data = json.loads(body)
msg = data.get("message") or data.get("error")
causes = data.get("cause", [])
if causes and isinstance(causes, list):
cause_msgs = [c.get("message") for c in causes if c.get("message")]
if cause_msgs:
msg = (msg + " | " if msg else "") + "; ".join(cause_msgs)
if msg:
# Translate common account-configuration errors to actionable messages
lowered = msg.lower()
if "me2 adoption is mandatory" in lowered:
return msg + " | Debes activar MercadoEnvíos (ME2) en tu cuenta de MercadoLibre. Ve a Configuración > Envíos en el panel de vendedor de ML."
if "user has not mode" in lowered:
return msg + " | Tu cuenta no tiene configurado este modo de envío. Ve a Configuración > Envíos en MercadoLibre y completa la configuración de MercadoEnvíos."
if "mandatory free shipping" in lowered:
return msg + " | MercadoLibre está forzando envío gratis en tu cuenta. Esto es normal si estás en el programa de envíos gratuito. El artículo se publicará con envío gratis."
return msg
except Exception:
pass
return base
def build_item_payload(
inventory_row: dict,
images: list[str],
meli_category_id: str,
price: float,
stock: int,
shipping_mode: str = "me2",
listing_type_id: str = "gold_special",
custom_title: str = None,
extra_attributes: list = None,
shipping_cost: float = None,
base_url: str = None,
description_text: str = None,
) -> dict:
"""Convert a Nexus inventory row into a MercadoLibre item payload.
When `description_text` is given it is attached as
payload["description"] = {"plain_text": ...}. ML keeps the description in
a separate sub-resource, so callers MUST pop this key before POSTing the
item and send it via MeliService.set_item_description().
"""
meli_condition = _derive_meli_condition(inventory_row.get("condition"))
grade = str(inventory_row.get("condition_grade") or "").strip().upper()
if custom_title:
title = custom_title.strip()
else:
title = f"{inventory_row['name']} {inventory_row['brand'] or ''} {inventory_row['part_number'] or ''}".strip()
# ML has no universal condition-grade attribute, so for used parts the A-D
# grade is appended to the title (kept inside ML's 60-char limit), unless
# the caller already included it.
if meli_condition == "used" and grade in ("A", "B", "C", "D") and f"grado {grade.lower()}" not in title.lower():
title_suffix = f" Grado {grade}"
# Reserve room for the grade marker so truncation never drops it.
max_base = 60 - len(title_suffix)
if len(title) > max_base:
title = title[:max(max_base - 3, 0)].rstrip() + "..."
title += title_suffix
# ML title limit is 60 chars; truncate smartly
if len(title) > 60:
title = title[:57] + "..."
shipping_payload = {"mode": shipping_mode}
if shipping_mode == "custom" and shipping_cost is not None:
shipping_payload["local_pick_up"] = False
shipping_payload["free_shipping"] = False
shipping_payload["methods"] = []
shipping_payload["costs"] = [{"description": "Envio", "cost": str(shipping_cost)}]
# family_name is required for ML's User Products model (enabled sellers).
# It's a generic product description shared across variations.
family_name = inventory_row.get("name") or title
part_number = inventory_row.get("part_number")
# Codeless unique pieces (yonke) still need a stable SKU for ML.
seller_custom_field = part_number or inventory_row.get("serial_number") or str(inventory_row.get("id"))
payload = {
"title": title,
"family_name": family_name,
"category_id": meli_category_id,
"price": round(float(price), 2),
"currency_id": "MXN",
"available_quantity": max(int(stock), 0),
"buying_mode": "buy_it_now",
"listing_type_id": listing_type_id,
"condition": meli_condition,
"pictures": [{"source": url} for url in _resolve_image_urls(images, base_url) if url][:MELI_MAX_PICTURES],
"shipping": shipping_payload,
"attributes": [],
"seller_custom_field": seller_custom_field,
}
# Description lives in its own ML sub-resource; callers pop it before the
# POST so it never reaches /items as an unexpected field.
if description_text:
payload["description"] = {"plain_text": description_text}
# Collect attribute IDs already covered (extra input + generated below)
extra_attr_ids = {a.get("id") for a in (extra_attributes or []) if a.get("id")}
attr_ids = set(extra_attr_ids)
if inventory_row.get("brand") and "BRAND" not in attr_ids:
payload["attributes"].append(
{"id": "BRAND", "value_name": inventory_row["brand"]}
)
attr_ids.add("BRAND")
# Prefer part_number; fall back to the piece serial for codeless used parts.
part_identifier = part_number or inventory_row.get("serial_number")
if part_identifier and "PART_NUMBER" not in attr_ids:
payload["attributes"].append(
{"id": "PART_NUMBER", "value_name": part_identifier}
)
attr_ids.add("PART_NUMBER")
# Vehicle compatibility as attributes (if available)
vehicle_compat = inventory_row.get("vehicle_compatibility")
if vehicle_compat:
if isinstance(vehicle_compat, str):
try:
vehicle_compat = json.loads(vehicle_compat)
except Exception:
vehicle_compat = None
if isinstance(vehicle_compat, list) and vehicle_compat:
first = vehicle_compat[0]
if isinstance(first, dict):
if first.get("brand") and "VEHICLE_MODEL" not in attr_ids:
payload["attributes"].append(
{"id": "VEHICLE_MODEL", "value_name": first["brand"]}
)
attr_ids.add("VEHICLE_MODEL")
if first.get("model") and "VEHICLE_MODEL_NAME" not in attr_ids:
payload["attributes"].append(
{"id": "VEHICLE_MODEL_NAME", "value_name": first["model"]}
)
attr_ids.add("VEHICLE_MODEL_NAME")
# Donor vehicle data for used parts (fills VEHICLE_* only when declared
# compatibility did not provide them). donor_vin has no universal ML
# attribute, so it is left for the description instead of being sent here.
if meli_condition == "used":
donor_attrs = (
("VEHICLE_MODEL", inventory_row.get("donor_make")),
("VEHICLE_MODEL_NAME", inventory_row.get("donor_model")),
("VEHICLE_YEAR", inventory_row.get("donor_year")),
)
for attr_id, value in donor_attrs:
if value and attr_id not in attr_ids:
payload["attributes"].append({"id": attr_id, "value_name": str(value)})
attr_ids.add(attr_id)
# Extra attributes from user input (category requirements)
if extra_attributes:
for attr in extra_attributes:
if attr.get("id") and attr.get("value_name"):
payload["attributes"].append(attr)
return payload
def _upload_images_to_meli(svc: MeliService, image_urls: list[str]) -> list[str]:
"""Upload images to MercadoLibre and return their secure URLs.
If an image is already a ML URL, it's passed through unchanged.
"""
results = []
# ML accepts at most 10 pictures per item.
for url in image_urls[:MELI_MAX_PICTURES]:
if not url:
continue
# Already a ML hosted image?
if "mercadolibre" in url or "mercadoli.com" in url:
results.append(url)
continue
try:
pic = svc.upload_image(url)
secure_url = pic.get("secure_url") or pic.get("url") or url
results.append(secure_url)
except Exception as e:
logger.warning("Failed to upload image %s to ML: %s", url, e)
# Fallback: try using the original URL anyway
results.append(url)
return results
def _calculate_meli_net_price(svc: MeliService, site_id: str, price: float, listing_type_id: str, category_id: str) -> dict:
"""Calculate net amount after ML commissions.
Returns dict with fee_amount, net_amount, fee_pct.
Falls back to approximate percentages if the API call fails.
"""
try:
fee_info = svc.get_listing_price(site_id, price, listing_type_id, category_id)
# Response structure varies; try common keys
sale_fee = fee_info.get("sale_fee_amount") or fee_info.get("fee_amount") or 0
net_amount = fee_info.get("net_amount") or fee_info.get("net_receive_amount")
if net_amount is None:
net_amount = price - sale_fee
return {
"fee_amount": float(sale_fee),
"net_amount": float(net_amount),
"fee_pct": round((sale_fee / price) * 100, 2) if price > 0 else 0,
"source": "ml_api",
}
except Exception as e:
logger.warning("Failed to fetch ML listing price for fee calc: %s", e)
# Fallback approximations for MLM
approx_fees = {
"gold_special": 0.13,
"gold_pro": 0.16,
"gold_premium": 0.18,
"free": 0.0,
}
fee_rate = approx_fees.get(listing_type_id, 0.13)
fee_amount = round(price * fee_rate, 2)
return {
"fee_amount": fee_amount,
"net_amount": round(price - fee_amount, 2),
"fee_pct": round(fee_rate * 100, 2),
"source": "approximation",
}
def compute_listing_economics(
svc: MeliService,
site_id: str,
price: float,
listing_type_id: str,
category_id: str,
cost=None,
) -> dict:
"""Fee + net + margin for one listing at a given price (persisted in v4.28).
`margin_pct` is measured against the net amount the seller actually
receives (net_price), not the sticker price, because that is the real
cash margin after ML's commission.
"""
fee = _calculate_meli_net_price(svc, site_id, price, listing_type_id, category_id)
net = round(float(fee["net_amount"]), 2)
margin_pct = None
if cost is not None:
try:
cost_f = float(cost)
if net > 0:
margin_pct = round((net - cost_f) / net * 100, 2)
except (TypeError, ValueError):
margin_pct = None
return {
"meli_fee": round(float(fee["fee_amount"]), 2),
"meli_fee_pct": fee["fee_pct"],
"meli_net_price": net,
"meli_fee_source": fee["source"],
"cost_at_publish": float(cost) if cost is not None else None,
"margin_pct": margin_pct,
}
def _persist_listing_economics(cur, listing_id: int, econ: dict) -> None:
"""Persist a compute_listing_economics() dict onto a marketplace_listing."""
cur.execute(
"""
UPDATE marketplace_listings
SET meli_fee = %s, meli_fee_pct = %s, meli_net_price = %s,
meli_fee_source = %s, cost_at_publish = %s, margin_pct = %s,
economics_updated_at = NOW()
WHERE id = %s
""",
(
econ.get("meli_fee"),
econ.get("meli_fee_pct"),
econ.get("meli_net_price"),
econ.get("meli_fee_source"),
econ.get("cost_at_publish"),
econ.get("margin_pct"),
listing_id,
),
)
# ═══════════════════════════════════════════════════════════════════════════
# ITEM DESCRIPTION
# ═══════════════════════════════════════════════════════════════════════════
#
# ML stores the description in its own sub-resource (PUT/POST
# /items/{id}/description), not in the item payload. build_item_payload()
# attaches it under payload["description"] and the publish/validate callers
# pop it and send it separately.
#
# The text is template-first (fast, deterministic, always available) and only
# gets an LLM-written commercial paragraph when use_llm=True. Any LLM failure
# (timeout, rate limit, outage) silently degrades to the template.
ML_DESCRIPTION_MAX = 4000
_LLM_DESCRIPTION_SYSTEM = (
"Eres un redactor de fichas de autopartes para MercadoLibre Mexico. "
"Escribes en espanol neutro, claro y comercial, sin exageraciones falsas "
"ni promesas de stock. Respondes SOLO con el texto de la descripcion, "
"sin titulos, sin markdown y sin HTML."
)
def _norm_sku(value) -> str:
"""Normalize a SKU/part number: uppercase alphanumerics only."""
if value is None:
return ""
return re.sub(r"[^A-Z0-9]", "", str(value).upper())
def _vehicle_compat_lines(vehicle_compat) -> list[str]:
"""Human-readable compatibility lines from inventory.vehicle_compatibility."""
if not vehicle_compat:
return []
if isinstance(vehicle_compat, str):
try:
vehicle_compat = json.loads(vehicle_compat)
except Exception:
return []
if not isinstance(vehicle_compat, list):
return []
lines = []
for entry in vehicle_compat:
if not isinstance(entry, dict):
continue
parts = [
str(entry.get(k)).strip()
for k in ("brand", "model", "year")
if entry.get(k) not in (None, "")
]
if parts:
text = " ".join(parts)
if text not in lines:
lines.append(text)
return lines
def _llm_description(part_number: str, name: str, brand: str, compat_lines: list[str],
category_name: str = None, timeout: float = 8) -> Optional[str]:
"""Best-effort LLM paragraph. Returns None on any failure (never raises)."""
try:
from services import ai_chat
if not getattr(ai_chat, "QWEN_ENABLED", False):
return None
prompt_lines = [
f"Redacta una descripcion de venta de 3 a 5 lineas (maximo 700 caracteres) para esta autoparte.",
f"Nombre: {name or part_number}",
]
if brand:
prompt_lines.append(f"Marca: {brand}")
if part_number:
prompt_lines.append(f"Numero de parte: {part_number}")
if category_name:
prompt_lines.append(f"Categoria: {category_name}")
if compat_lines:
prompt_lines.append("Compatibilidad: " + "; ".join(compat_lines[:6]))
prompt_lines.append(
"Incluye para que sirve la pieza y beneficios. No inventes numeros de parte "
"ni modelos de auto que no se te dieron. No prometas existencias."
)
result = ai_chat._post_chat_completion(
ai_chat.QWEN_CHAT_URL,
ai_chat.QWEN_API_KEY,
ai_chat.QWEN_MODEL,
[
{"role": "system", "content": _LLM_DESCRIPTION_SYSTEM},
{"role": "user", "content": "\n".join(prompt_lines)},
],
max_tokens=400,
temperature=0.4,
timeout=timeout,
)
if not result:
return None
text = (result.get("content") or "").strip()
return text or None
except Exception as e:
logger.warning("LLM description generation failed: %s", e)
return None
def build_item_description(
inventory_row: dict,
category_name: str = None,
use_llm: bool = False,
llm_timeout: float = 8,
) -> str:
"""Build a robust Spanish description for an ML item.
Always returns something usable: structured template data (part number,
condition, grade, compatibility, donor vehicle) plus the LLM paragraph
when it is available.
"""
name = str(inventory_row.get("name") or "").strip()
brand = str(inventory_row.get("brand") or "").strip()
part_number = str(
inventory_row.get("part_number") or inventory_row.get("serial_number") or ""
).strip()
condition = _derive_meli_condition(inventory_row.get("condition"))
grade = str(inventory_row.get("condition_grade") or "").strip().upper()
compat_lines = _vehicle_compat_lines(inventory_row.get("vehicle_compatibility"))
header = " ".join(p for p in (name, brand) if p).strip() or (name or part_number)
blocks = [header, ""]
if part_number:
blocks.append(f"Número de parte: {part_number}")
if condition == "used":
blocks.append(f"Condición: Usado (grado {grade})" if grade in ("A", "B", "C", "D")
else "Condición: Usado")
elif condition == "reconstruido":
blocks.append("Condición: Reconstruido")
else:
blocks.append("Condición: Nuevo")
if condition == "used":
donor = " ".join(
str(p).strip() for p in (
inventory_row.get("donor_make"),
inventory_row.get("donor_model"),
inventory_row.get("donor_year"),
) if p
).strip()
if donor:
blocks.append(f"Vehículo donador: {donor}")
if compat_lines:
blocks.append("Compatibilidad: " + "; ".join(compat_lines[:8]))
if category_name:
blocks.append(f"Categoría: {category_name}")
llm_text = None
if use_llm:
llm_text = _llm_description(part_number, name, brand, compat_lines,
category_name=category_name, timeout=llm_timeout)
blocks.append("")
if llm_text:
blocks.append(llm_text)
else:
blocks.append(
"Producto disponible para entrega. Verifica la compatibilidad con tu "
"vehículo (marca, modelo y año) antes de comprar. Si tienes dudas, "
"pregunta en la sección de preguntas y con gusto te asesoramos."
)
blocks.append(
"Enviamos con factura y garantía. Escríbenos si necesitas otra pieza, "
"contamos con amplio surtido de autopartes."
)
text = "\n".join(blocks).strip()
return text[:ML_DESCRIPTION_MAX]
def check_meli_shipping_config(svc: MeliService, cfg: dict) -> dict:
"""Check if the user's ML account has the required shipping modes configured.
Returns {"ok": True} or {"ok": False, "error": "...", "available_modes": [...]}.
"""
user_id = cfg.get("meli_user_id")
if not user_id:
return {"ok": False, "error": "Usuario de ML no configurado"}
try:
prefs = svc.get_shipping_preferences(str(user_id))
modes = prefs.get("modes", [])
mandatory = prefs.get("mandatory_mode_for_user", [])
if mandatory and not any(m in modes for m in mandatory):
return {
"ok": False,
"error": f"Tu cuenta requiere obligatoriamente los modos de envío: {', '.join(mandatory)}. Actualmente solo tienes: {', '.join(modes)}. Configúralos en el panel de vendedor de MercadoLibre.",
"available_modes": modes,
"mandatory_modes": mandatory,
}
return {"ok": True, "available_modes": modes, "mandatory_modes": mandatory}
except MeliError as e:
logger.warning("Failed to fetch shipping preferences: %s", e)
return {"ok": True} # Don't block on preference fetch failure
except Exception as e:
logger.warning("Failed to fetch shipping preferences: %s", e)
return {"ok": True}
# ═══════════════════════════════════════════════════════════════════════════
# LISTINGS CRUD
# ═══════════════════════════════════════════════════════════════════════════
def check_inventory_ml_status(tenant_conn, inventory_ids: list[int], base_url: str = None) -> dict:
"""Check local pre-flight status for ML publishing.
Returns per-item dict with checks: has_image, has_stock, has_price,
already_published, and the generated title.
"""
cur = tenant_conn.cursor()
cur.execute(
"""
SELECT id, part_number, name, brand, price_1, image_url,
condition, condition_grade
FROM inventory
WHERE id = ANY(%s)
""",
(inventory_ids,),
)
rows = {r[0]: r for r in cur.fetchall()}
images_map = _fetch_inventory_image_urls(cur, inventory_ids)
stock_map = get_stock_bulk(tenant_conn, branch_id=None)
# Check existing active listings
cur.execute(
"""
SELECT inventory_id, external_item_id, external_status, external_permalink
FROM marketplace_listings
WHERE inventory_id = ANY(%s) AND channel = 'mercadolibre' AND is_active = true
""",
(inventory_ids,),
)
listings = {r[0]: {"external_item_id": r[1], "status": r[2], "permalink": r[3]} for r in cur.fetchall()}
cur.close()
results = []
for inv_id in inventory_ids:
row = rows.get(inv_id)
if not row:
results.append({"inventory_id": inv_id, "exists": False})
continue
price = float(row[4]) if row[4] else 0
stock = stock_map.get(inv_id, 0)
image_url = row[5]
images = _resolve_image_urls(
_merge_image_urls(images_map.get(inv_id), image_url), base_url
)
title = f"{row[2]} {row[3] or ''} {row[1] or ''}".strip()
if len(title) > 60:
title = title[:57] + "..."
existing = listings.get(inv_id)
results.append({
"inventory_id": inv_id,
"exists": True,
"title": title,
"has_image": bool(images),
"has_stock": stock > 0,
"has_price": price > 0,
"price": price,
"stock": stock,
"image_url": images[0] if images else None,
"images_count": len(images),
"condition": row[6],
"condition_grade": row[7],
"already_published": existing is not None,
"existing_listing": existing,
})
return {"items": results}
def validate_items(
tenant_conn,
inventory_ids: list[int],
meli_category_id: str,
listing_type_id: str = "gold_special",
shipping_mode: str = "me2",
custom_data: dict = None,
base_url: str = None,
use_llm_description: bool = False,
) -> dict:
"""Validate items against ML /items/validate without creating them.
Also returns the generated description (as it will be published) and the
estimated fee / net / margin per item.
Returns per-item validation results with ML error details if any.
"""
cfg = get_meli_config(tenant_conn)
svc = _get_meli_service(cfg, tenant_conn)
if not svc:
raise ValueError("MercadoLibre not configured")
shipping_check = check_meli_shipping_config(svc, cfg)
if not shipping_check["ok"]:
return {"valid": [], "invalid": [{"inventory_id": "config", "error": shipping_check["error"]}]}
up_seller = is_user_product_seller(tenant_conn, svc, cfg)
cur = tenant_conn.cursor()
cur.execute(
"""
SELECT id, part_number, name, brand, price_1, vehicle_compatibility,
image_url, unit, is_active,
condition, condition_grade, is_unique_piece, serial_number,
donor_vin, donor_make, donor_model, donor_year
FROM inventory
WHERE id = ANY(%s) AND is_active = true
""",
(inventory_ids,),
)
rows = {r[0]: r for r in cur.fetchall()}
images_map = _fetch_inventory_image_urls(cur, inventory_ids)
stock_map = get_stock_bulk(tenant_conn, branch_id=None)
custom_data = custom_data or {}
results = {"valid": [], "invalid": []}
# Used parts may need their category resolved via domain_discovery; the
# validity cache avoids re-checking the same predicted category per item.
site_id = cfg.get("meli_site_id", "MLM")
category_cache = {}
for inv_id in inventory_ids:
row = rows.get(inv_id)
if not row:
results["invalid"].append({"inventory_id": inv_id, "error": "No encontrado o inactivo"})
continue
inv = {
"id": row[0],
"part_number": row[1],
"name": row[2],
"brand": row[3],
"price_1": float(row[4]) if row[4] else 0,
"vehicle_compatibility": row[5],
"image_url": row[6],
"unit": row[7],
"condition": row[9],
"condition_grade": row[10],
"is_unique_piece": row[11],
"serial_number": row[12],
"donor_vin": row[13],
"donor_make": row[14],
"donor_model": row[15],
"donor_year": row[16],
}
stock = stock_map.get(inv_id, 0)
if stock <= 0:
results["invalid"].append({"inventory_id": inv_id, "error": "Sin stock disponible"})
continue
if inv["price_1"] <= 0:
results["invalid"].append({"inventory_id": inv_id, "error": "El precio debe ser mayor a 0"})
continue
# All inventory_images photos (primary first) + legacy image_url.
images = _resolve_image_urls(
_merge_image_urls(images_map.get(inv_id), inv.get("image_url")), base_url
)
if not images:
results["invalid"].append({"inventory_id": inv_id, "error": "El producto no tiene imagen"})
continue
# Upload images to ML hosting so they are publicly accessible
try:
ml_images = _upload_images_to_meli(svc, images)
except Exception as e:
results["invalid"].append({"inventory_id": inv_id, "error": f"Error subiendo imagen a ML: {e}"})
continue
if not ml_images:
results["invalid"].append({"inventory_id": inv_id, "error": "No se pudo subir la imagen a MercadoLibre"})
continue
title = (custom_data.get("titles") or {}).get(str(inv_id))
extra_attrs = (custom_data.get("attributes") or {}).get(str(inv_id))
price = (custom_data.get("prices") or {}).get(str(inv_id), inv["price_1"])
item_stock = (custom_data.get("stocks") or {}).get(str(inv_id), stock)
shipping_cost = custom_data.get("shipping_cost")
item_category_id = _item_category_id(
svc, site_id, inv, inv_id, meli_category_id, cfg, custom_data, category_cache
)
payload = build_item_payload(
inv, ml_images, item_category_id, price, item_stock,
shipping_mode=shipping_mode, listing_type_id=listing_type_id,
custom_title=title, extra_attributes=extra_attrs,
shipping_cost=shipping_cost,
base_url=base_url,
description_text=build_item_description(inv, use_llm=use_llm_description),
)
# Description is not part of /items/validate (separate sub-resource).
description = (payload.pop("description", {}) or {}).get("plain_text")
if up_seller:
# User Products sellers: ML rejects 'title' (derived from family_name)
payload.pop("title", None)
try:
validation = svc.validate_item(payload)
if validation.get("status") == "valid":
# Calculate estimated net earnings after ML fees
fee_calc = _calculate_meli_net_price(svc, site_id, float(price), listing_type_id, item_category_id)
results["valid"].append({
"inventory_id": inv_id,
"validation": validation,
"meli_category_id": item_category_id,
"price": float(price),
"fee_amount": fee_calc["fee_amount"],
"net_amount": fee_calc["net_amount"],
"fee_pct": fee_calc["fee_pct"],
"fee_source": fee_calc["source"],
"description": description,
})
else:
errors = validation.get("validation_errors", [])
error_msgs = [f"{e.get('field', '')}: {e.get('message', '')}" for e in errors]
results["invalid"].append({"inventory_id": inv_id, "error": " | ".join(error_msgs) or "Validación fallida", "validation": validation})
except MeliError as e:
# ML returns 400 even when all causes are non-blocking warnings
# (e.g. shipping adoption notices). Treat warnings-only as valid.
causes = []
try:
causes = (json.loads(e.response_body or "{}").get("cause") or [])
except Exception:
pass
if e.status_code == 400 and causes and all(c.get("type") == "warning" for c in causes):
fee_calc = _calculate_meli_net_price(svc, site_id, float(price), listing_type_id, item_category_id)
results["valid"].append({
"inventory_id": inv_id,
"validation": {"warnings": [c.get("message") for c in causes if c.get("message")]},
"price": float(price),
"fee_amount": fee_calc["fee_amount"],
"net_amount": fee_calc["net_amount"],
"fee_pct": fee_calc["fee_pct"],
"fee_source": fee_calc["source"],
"description": description,
})
else:
err_msg = _extract_meli_error(e)
results["invalid"].append({"inventory_id": inv_id, "error": err_msg})
except Exception as e:
results["invalid"].append({"inventory_id": inv_id, "error": str(e)})
cur.close()
return results
def publish_items(
tenant_conn,
inventory_ids: list[int],
meli_category_id: str,
listing_type_id: str = "gold_special",
shipping_mode: str = "me2",
custom_data: dict = None,
base_url: str = None,
use_llm_description: bool = False,
chunk_size: int = 0,
pace_seconds: float = 1.0,
) -> dict:
"""Publish one or more inventory items to MercadoLibre.
Each item is published with a generated description (see
build_item_description), and its commission / net price / margin are
persisted on marketplace_listings (v4.28 columns).
chunk_size > 0 paces the batch: after every `chunk_size` items it sleeps
`pace_seconds` to stay under ML's rate limit instead of hammering the API.
Returns summary dict with success/failure per item.
"""
cfg = get_meli_config(tenant_conn)
svc = _get_meli_service(cfg, tenant_conn)
if not svc:
raise ValueError("MercadoLibre not configured")
shipping_check = check_meli_shipping_config(svc, cfg)
if not shipping_check["ok"]:
return {"success": [], "failed": [{"inventory_id": "config", "error": shipping_check["error"]}]}
up_seller = is_user_product_seller(tenant_conn, svc, cfg)
site_id = cfg.get("meli_site_id", "MLM")
cur = tenant_conn.cursor()
# Batch fetch inventory rows
cur.execute(
"""
SELECT id, part_number, name, brand, price_1, vehicle_compatibility,
image_url, unit, is_active,
condition, condition_grade, is_unique_piece, serial_number,
donor_vin, donor_make, donor_model, donor_year, cost
FROM inventory
WHERE id = ANY(%s) AND is_active = true
""",
(inventory_ids,),
)
rows = {r[0]: r for r in cur.fetchall()}
# Batch fetch all photos per item (inventory_images: primary first)
images_map = _fetch_inventory_image_urls(cur, inventory_ids)
# Batch fetch stock
stock_map = get_stock_bulk(tenant_conn, branch_id=None)
custom_data = custom_data or {}
results = {"success": [], "failed": []}
# Used parts may need their category resolved via domain_discovery; the
# validity cache avoids re-checking the same predicted category per item.
category_cache = {}
for idx, inv_id in enumerate(inventory_ids):
# Rate-limit pacing between chunks.
if chunk_size and idx > 0 and idx % chunk_size == 0 and pace_seconds > 0:
time.sleep(pace_seconds)
row = rows.get(inv_id)
if not row:
results["failed"].append({"inventory_id": inv_id, "error": "Not found or inactive"})
continue
inv = {
"id": row[0],
"part_number": row[1],
"name": row[2],
"brand": row[3],
"price_1": float(row[4]) if row[4] else 0,
"vehicle_compatibility": row[5],
"image_url": row[6],
"unit": row[7],
"condition": row[9],
"condition_grade": row[10],
"is_unique_piece": row[11],
"serial_number": row[12],
"donor_vin": row[13],
"donor_make": row[14],
"donor_model": row[15],
"donor_year": row[16],
}
unit_cost = float(row[17]) if row[17] is not None else None
stock = stock_map.get(inv_id, 0)
if stock <= 0:
results["failed"].append({"inventory_id": inv_id, "error": "Sin stock disponible"})
continue
if inv["price_1"] <= 0:
results["failed"].append({"inventory_id": inv_id, "error": "El precio debe ser mayor a 0"})
continue
# Build image list: all inventory_images photos + legacy image_url.
images = _resolve_image_urls(
_merge_image_urls(images_map.get(inv_id), inv.get("image_url")), base_url
)
if not images:
results["failed"].append({"inventory_id": inv_id, "error": "El producto no tiene imagen. ML requiere imagen para publicar."})
continue
# Upload images to ML hosting so they are publicly accessible
try:
ml_images = _upload_images_to_meli(svc, images)
except Exception as e:
results["failed"].append({"inventory_id": inv_id, "error": f"Error subiendo imagen a ML: {e}"})
continue
if not ml_images:
results["failed"].append({"inventory_id": inv_id, "error": "No se pudo subir la imagen a MercadoLibre"})
continue
title = (custom_data.get("titles") or {}).get(str(inv_id))
extra_attrs = (custom_data.get("attributes") or {}).get(str(inv_id))
price = (custom_data.get("prices") or {}).get(str(inv_id), inv["price_1"])
item_stock = (custom_data.get("stocks") or {}).get(str(inv_id), stock)
shipping_cost = custom_data.get("shipping_cost")
description = build_item_description(inv, use_llm=use_llm_description)
item_category_id = _item_category_id(
svc, site_id, inv, inv_id, meli_category_id, cfg, custom_data, category_cache
)
payload = build_item_payload(
inv, ml_images, item_category_id, price, item_stock,
shipping_mode=shipping_mode, listing_type_id=listing_type_id,
custom_title=title, extra_attributes=extra_attrs,
shipping_cost=shipping_cost,
base_url=base_url,
description_text=description,
)
# ML description is a sub-resource: send it separately after create.
payload.pop("description", None)
if up_seller:
# User Products sellers: ML rejects 'title' (derived from family_name)
payload.pop("title", None)
try:
ml_item = svc.create_item(payload)
external_item_id = ml_item.get("id")
permalink = ml_item.get("permalink")
# Best-effort: attach the description. A failure here never blocks
# the publication (the item is already live), only the text.
if external_item_id:
try:
svc.set_item_description(external_item_id, description, create=True)
except Exception as desc_err:
logger.warning(
"Could not set ML description for %s: %s", external_item_id, desc_err
)
# Persist listing + economics (fee / net / margin, v4.28).
# Upsert explicitly by (inventory_id, channel): v4.28 dropped the
# unique index so an imported catalog can hold several listings per
# product, but a publish should still refresh the existing row for
# that product instead of piling up duplicates.
econ = compute_listing_economics(
svc, site_id, float(price) if price else inv["price_1"],
listing_type_id, item_category_id, cost=unit_cost,
)
publish_price = float(price) if price else inv["price_1"]
listing_title = payload.get("title") or payload.get("family_name") or inv["name"]
cur.execute(
"""
SELECT id FROM marketplace_listings
WHERE inventory_id = %s AND channel = 'mercadolibre' AND is_active = true
ORDER BY id LIMIT 1
""",
(inv_id,),
)
existing = cur.fetchone()
if existing:
cur.execute(
"""
UPDATE marketplace_listings
SET external_item_id = %s, external_status = 'active',
external_permalink = %s, title = %s, meli_category_id = %s,
publish_price = %s, listing_type_id = %s, meli_description = %s,
meli_fee = %s, meli_fee_pct = %s, meli_net_price = %s,
meli_fee_source = %s, cost_at_publish = %s, margin_pct = %s,
economics_updated_at = NOW(), last_sync_at = NOW(),
sync_errors = NULL, is_active = true
WHERE id = %s
""",
(
external_item_id, permalink, listing_title, item_category_id,
publish_price, listing_type_id, description,
econ["meli_fee"], econ["meli_fee_pct"], econ["meli_net_price"],
econ["meli_fee_source"], econ["cost_at_publish"], econ["margin_pct"],
existing[0],
),
)
else:
cur.execute(
"""
INSERT INTO marketplace_listings
(inventory_id, channel, external_item_id, external_status,
external_permalink, title, meli_category_id, publish_price,
listing_type_id, meli_description,
meli_fee, meli_fee_pct, meli_net_price, meli_fee_source,
cost_at_publish, margin_pct, economics_updated_at,
inventory_matched_at, last_sync_at, sync_errors, is_active)
VALUES (%s, 'mercadolibre', %s, 'active', %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW(), NOW(), NULL, true)
""",
(
inv_id, external_item_id, permalink, listing_title,
item_category_id, publish_price, listing_type_id, description,
econ["meli_fee"], econ["meli_fee_pct"], econ["meli_net_price"],
econ["meli_fee_source"], econ["cost_at_publish"], econ["margin_pct"],
),
)
tenant_conn.commit()
results["success"].append(
{
"inventory_id": inv_id,
"external_item_id": external_item_id,
"permalink": permalink,
"meli_category_id": item_category_id,
"description_chars": len(description or ""),
"meli_fee": econ["meli_fee"],
"meli_net_price": econ["meli_net_price"],
"margin_pct": econ["margin_pct"],
}
)
except MeliError as e:
tenant_conn.rollback()
err_msg = _extract_meli_error(e)
logger.warning("ML publish failed for inventory_id=%s: %s | payload=%s | response=%s", inv_id, err_msg, json.dumps(payload), e.response_body)
results["failed"].append({"inventory_id": inv_id, "error": err_msg})
except Exception as e:
tenant_conn.rollback()
logger.exception("Unexpected error publishing inventory_id=%s", inv_id)
results["failed"].append({"inventory_id": inv_id, "error": str(e)})
cur.close()
return results
def get_listings(tenant_conn, page: int = 1, per_page: int = 50, status: str = None):
cur = tenant_conn.cursor()
where = ["1=1"]
params = []
if status:
where.append("external_status = %s")
params.append(status)
count_sql = f"SELECT COUNT(*) FROM marketplace_listings WHERE {' AND '.join(where)}"
cur.execute(count_sql, params)
total = cur.fetchone()[0]
sql = f"""
SELECT l.id, l.inventory_id, l.external_item_id, l.external_status,
l.external_permalink, l.title, l.meli_category_id, l.publish_price,
l.last_sync_at, l.sync_errors, l.is_active, l.created_at,
i.part_number, i.name, i.price_1, i.brand
FROM marketplace_listings l
LEFT JOIN inventory i ON i.id = l.inventory_id
WHERE {' AND '.join(where)}
ORDER BY l.created_at DESC
LIMIT %s OFFSET %s
"""
cur.execute(sql, params + [per_page, (page - 1) * per_page])
rows = cur.fetchall()
cur.close()
items = []
for r in rows:
items.append({
"id": r[0],
"inventory_id": r[1],
"external_item_id": r[2],
"external_status": r[3],
"external_permalink": r[4],
"title": r[5],
"meli_category_id": r[6],
"publish_price": float(r[7]) if r[7] else None,
"last_sync_at": str(r[8]) if r[8] else None,
"sync_errors": r[9],
"is_active": r[10],
"created_at": str(r[11]),
"part_number": r[12],
"inventory_name": r[13],
"current_price": float(r[14]) if r[14] else None,
"brand": r[15],
})
return {"items": items, "total": total, "page": page, "per_page": per_page}
def sync_listing(tenant_conn, listing_id: int) -> dict:
"""Force a manual sync of stock/price for a single listing."""
cfg = get_meli_config(tenant_conn)
svc = _get_meli_service(cfg, tenant_conn)
if not svc:
raise ValueError("MercadoLibre not configured")
cur = tenant_conn.cursor()
cur.execute(
"""
SELECT id, inventory_id, external_item_id, meli_category_id, listing_type_id
FROM marketplace_listings WHERE id = %s AND is_active = true
""",
(listing_id,),
)
row = cur.fetchone()
if not row:
cur.close()
raise ValueError("Listing not found")
listing = {
"id": row[0],
"inventory_id": row[1],
"external_item_id": row[2],
"meli_category_id": row[3],
"listing_type_id": row[4],
}
# Get current stock/price/cost
cur.execute(
"SELECT price_1, cost FROM inventory WHERE id = %s",
(listing["inventory_id"],),
)
price_row = cur.fetchone()
current_price = float(price_row[0]) if price_row and price_row[0] else 0
unit_cost = float(price_row[1]) if price_row and price_row[1] is not None else None
stock_map = get_stock_bulk(tenant_conn, branch_id=None)
current_stock = stock_map.get(listing["inventory_id"], 0)
# Never push a zero/unknown price: ML rejects it and it would blank out a
# live listing. This also covers listings not linked to a local product.
if current_price <= 0:
cur.close()
raise ValueError(
"No se puede sincronizar: el producto no tiene precio (>0) o la publicación "
"no está vinculada a un producto del inventario."
)
try:
svc.update_item(
listing["external_item_id"],
{"price": round(current_price, 2), "available_quantity": max(int(current_stock or 0), 0)},
)
cur.execute(
"""
UPDATE marketplace_listings
SET last_sync_at = NOW(), sync_errors = NULL,
publish_price = COALESCE(NULLIF(%s, 0), publish_price)
WHERE id = %s
""",
(current_price, listing_id),
)
# Refresh the stored economics for the price we just pushed.
econ = compute_listing_economics(
svc, cfg.get("meli_site_id", "MLM"), current_price,
listing["listing_type_id"] or "gold_special",
listing["meli_category_id"], cost=unit_cost,
)
_persist_listing_economics(cur, listing_id, econ)
tenant_conn.commit()
cur.close()
return {"ok": True, "price": current_price, "stock": current_stock,
"meli_fee": econ["meli_fee"], "meli_net_price": econ["meli_net_price"],
"margin_pct": econ["margin_pct"]}
except MeliError as e:
tenant_conn.rollback()
cur.execute(
"UPDATE marketplace_listings SET sync_errors = %s WHERE id = %s",
(str(e)[:500], listing_id),
)
tenant_conn.commit()
cur.close()
raise
def pause_listing(tenant_conn, listing_id: int) -> dict:
cfg = get_meli_config(tenant_conn)
svc = _get_meli_service(cfg, tenant_conn)
if not svc:
raise ValueError("MercadoLibre not configured")
cur = tenant_conn.cursor()
cur.execute(
"SELECT external_item_id FROM marketplace_listings WHERE id = %s",
(listing_id,),
)
row = cur.fetchone()
if not row:
cur.close()
raise ValueError("Listing not found")
svc.pause_item(row[0])
cur.execute(
"UPDATE marketplace_listings SET external_status = 'paused' WHERE id = %s",
(listing_id,),
)
tenant_conn.commit()
cur.close()
return {"ok": True, "status": "paused"}
def activate_listing(tenant_conn, listing_id: int) -> dict:
cfg = get_meli_config(tenant_conn)
svc = _get_meli_service(cfg, tenant_conn)
if not svc:
raise ValueError("MercadoLibre not configured")
cur = tenant_conn.cursor()
cur.execute(
"SELECT external_item_id FROM marketplace_listings WHERE id = %s",
(listing_id,),
)
row = cur.fetchone()
if not row:
cur.close()
raise ValueError("Listing not found")
svc.activate_item(row[0])
cur.execute(
"UPDATE marketplace_listings SET external_status = 'active' WHERE id = %s",
(listing_id,),
)
tenant_conn.commit()
cur.close()
return {"ok": True, "status": "active"}
def close_listing(tenant_conn, listing_id: int) -> dict:
cfg = get_meli_config(tenant_conn)
svc = _get_meli_service(cfg, tenant_conn)
if not svc:
raise ValueError("MercadoLibre not configured")
cur = tenant_conn.cursor()
cur.execute(
"SELECT external_item_id FROM marketplace_listings WHERE id = %s",
(listing_id,),
)
row = cur.fetchone()
if not row:
cur.close()
raise ValueError("Listing not found")
svc.close_item(row[0])
cur.execute(
"UPDATE marketplace_listings SET external_status = 'closed', is_active = false WHERE id = %s",
(listing_id,),
)
tenant_conn.commit()
cur.close()
return {"ok": True, "status": "closed"}
def delete_listing_permanently(tenant_conn, listing_id: int) -> dict:
"""Hard-delete a closed listing from the local DB.
Sets listing_id = NULL on marketplace_order_items to avoid FK errors,
then deletes the marketplace_listings row.
"""
cur = tenant_conn.cursor()
cur.execute(
"SELECT id, external_status FROM marketplace_listings WHERE id = %s",
(listing_id,),
)
row = cur.fetchone()
if not row:
cur.close()
raise ValueError("Listing not found")
# Clear FK references so we can delete safely
cur.execute(
"UPDATE marketplace_order_items SET listing_id = NULL WHERE listing_id = %s",
(listing_id,),
)
cur.execute(
"DELETE FROM marketplace_listings WHERE id = %s",
(listing_id,),
)
tenant_conn.commit()
cur.close()
return {"ok": True, "deleted": True}
# ═══════════════════════════════════════════════════════════════════════════
# QUESTIONS & ANSWERS
# ═══════════════════════════════════════════════════════════════════════════
def _upsert_question(cur, q_data: dict, listing_id_map: dict):
"""Upsert a single question from ML API into marketplace_questions."""
external_qid = str(q_data.get("id"))
external_item_id = str(q_data.get("item_id"))
text = q_data.get("text", "")
status = (q_data.get("status") or "unanswered")
# ML returns UNANSWERED/ANSWERED; store lowercase so the auto-answer and
# SLA queries (which compare lowercase) see every question.
if isinstance(status, str):
status = status.lower()
answer = q_data.get("answer", {})
answer_text = answer.get("text") if answer else None
answer_date = None
if answer and answer.get("date_created"):
answer_date = answer["date_created"]
from_user = q_data.get("from", {})
buyer_id = str(from_user.get("id")) if from_user else None
buyer_nickname = from_user.get("nickname") if from_user else None
question_date = q_data.get("date_created")
listing_id = listing_id_map.get(external_item_id)
cur.execute(
"""
INSERT INTO marketplace_questions
(listing_id, external_question_id, external_item_id, question_text,
answer_text, status, buyer_id, buyer_nickname, question_date,
answer_date, raw_json, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
ON CONFLICT (external_question_id)
DO UPDATE SET
question_text = EXCLUDED.question_text,
answer_text = EXCLUDED.answer_text,
status = EXCLUDED.status,
buyer_id = EXCLUDED.buyer_id,
buyer_nickname = EXCLUDED.buyer_nickname,
question_date = EXCLUDED.question_date,
answer_date = EXCLUDED.answer_date,
raw_json = EXCLUDED.raw_json,
updated_at = NOW()
""",
(
listing_id,
external_qid,
external_item_id,
text,
answer_text,
status,
buyer_id,
buyer_nickname,
question_date,
answer_date,
json.dumps(q_data),
),
)
def sync_questions(tenant_conn) -> dict:
"""Fetch questions from ML for all active listings and upsert locally.
Returns {"synced": N, "items": [...]}.
"""
cfg = get_meli_config(tenant_conn)
svc = _get_meli_service(cfg, tenant_conn)
if not svc:
raise ValueError("MercadoLibre not configured")
cur = tenant_conn.cursor()
cur.execute(
"""
SELECT id, external_item_id
FROM marketplace_listings
WHERE channel = 'mercadolibre' AND is_active = true
"""
)
listings = {r[1]: r[0] for r in cur.fetchall()}
cur.close()
if not listings:
return {"synced": 0, "items": []}
total_synced = 0
for item_id in listings:
try:
resp = svc.get_questions(item_id, limit=50)
questions = resp.get("questions", [])
if not questions:
continue
cur = tenant_conn.cursor()
for q in questions:
_upsert_question(cur, q, listings)
tenant_conn.commit()
cur.close()
total_synced += len(questions)
except Exception as e:
logger.warning("Failed to sync questions for item %s: %s", item_id, e)
summary = {"synced": total_synced}
# Optional auto-answer after sync — opt-in via tenant_config, never forced.
try:
if get_meli_config(tenant_conn).get("meli_auto_answer_questions"):
summary["auto_answer"] = auto_answer_questions(tenant_conn)
except Exception as e:
logger.warning("auto-answer after sync failed: %s", e)
return summary
def fetch_question_from_ml(tenant_conn, external_question_id: str) -> dict:
"""Fetch a single question from ML API and upsert locally."""
cfg = get_meli_config(tenant_conn)
svc = _get_meli_service(cfg, tenant_conn)
if not svc:
raise ValueError("MercadoLibre not configured")
q_data = svc.get_question(external_question_id)
cur = tenant_conn.cursor()
cur.execute(
"SELECT id, external_item_id FROM marketplace_listings WHERE external_item_id = %s",
(str(q_data.get("item_id")),),
)
row = cur.fetchone()
listing_id_map = {row[1]: row[0]} if row else {}
_upsert_question(cur, q_data, listing_id_map)
tenant_conn.commit()
cur.close()
return q_data
def answer_question(tenant_conn, local_question_id: int, text: str) -> dict:
"""Answer a question via ML API and update local status."""
cfg = get_meli_config(tenant_conn)
svc = _get_meli_service(cfg, tenant_conn)
if not svc:
raise ValueError("MercadoLibre not configured")
cur = tenant_conn.cursor()
cur.execute(
"SELECT external_question_id FROM marketplace_questions WHERE id = %s",
(local_question_id,),
)
row = cur.fetchone()
if not row:
cur.close()
raise ValueError("Question not found")
external_qid = row[0]
cur.close()
resp = svc.answer_question(external_qid, text)
cur = tenant_conn.cursor()
cur.execute(
"""
UPDATE marketplace_questions
SET answer_text = %s, status = 'answered', answer_date = NOW(), updated_at = NOW()
WHERE id = %s
""",
(text, local_question_id),
)
tenant_conn.commit()
cur.close()
return {"ok": True, "ml_response": resp}
# ─── AI question suggestions (A7) ───────────────────────────────────────────
#
# Auto-answering is OPT-IN: it only runs when the tenant sets
# `meli_auto_answer_questions = true`, and only for high-confidence answers
# built from templates/data (never free-text LLM output).
AUTO_ANSWER_MIN_CONFIDENCE = 0.8
def suggest_question_answer(tenant_conn, local_question_id: int, use_llm: bool = True) -> dict:
"""Classify a local question and return a suggested answer.
Delegates to services.question_ai. Raises ValueError when the question
does not exist.
"""
from services import question_ai
return question_ai.get_question_suggestion(
tenant_conn, local_question_id, use_llm=use_llm
)
def auto_answer_questions(tenant_conn, limit: int = 20) -> dict:
"""Answer unanswered questions with high-confidence template suggestions.
No-op unless `meli_auto_answer_questions` is enabled for the tenant.
Returns a summary with the questions answered and skipped (with reasons).
"""
cfg = get_meli_config(tenant_conn)
if not cfg.get("meli_auto_answer_questions"):
return {"enabled": False, "answered": 0, "skipped": 0,
"results": [], "reason": "meli_auto_answer_questions disabled"}
cur = tenant_conn.cursor()
cur.execute("""
SELECT id FROM marketplace_questions
WHERE LOWER(COALESCE(status, 'unanswered')) = 'unanswered'
AND external_question_id IS NOT NULL
ORDER BY question_date ASC NULLS LAST
LIMIT %s
""", (limit,))
question_ids = [r[0] for r in cur.fetchall()]
cur.close()
answered, skipped = [], []
for qid in question_ids:
try:
suggestion = suggest_question_answer(tenant_conn, qid)
except Exception as e:
# A failing query inside the suggestion can leave the connection's
# transaction aborted; roll back so the next question still works.
try:
tenant_conn.rollback()
except Exception:
pass
skipped.append({"question_id": qid, "reason": f"suggest_error: {e}"})
continue
text = (suggestion.get("suggested_answer") or "").strip()
if (suggestion.get("auto_ok") and text
and suggestion.get("confidence", 0) >= AUTO_ANSWER_MIN_CONFIDENCE):
try:
answer_question(tenant_conn, qid, text)
answered.append({
"question_id": qid,
"intent": suggestion.get("intent"),
"source": suggestion.get("source"),
"confidence": suggestion.get("confidence"),
})
except Exception as e:
try:
tenant_conn.rollback()
except Exception:
pass
skipped.append({"question_id": qid, "reason": f"answer_error: {e}"})
else:
skipped.append({
"question_id": qid,
"intent": suggestion.get("intent"),
"confidence": suggestion.get("confidence"),
"source": suggestion.get("source"),
"reason": "needs_review",
})
return {"enabled": True, "answered": len(answered), "skipped": len(skipped),
"results": answered, "details": skipped}
def list_local_questions(tenant_conn, status: str = None) -> list:
"""Return questions from local DB, optionally filtered by status.
Status comparison is case-insensitive (ML returns UNANSWERED/ANSWERED,
older local rows may still carry the uppercase form).
"""
cur = tenant_conn.cursor()
if status:
cur.execute(
"""
SELECT q.id, q.external_question_id, q.external_item_id, q.question_text,
q.answer_text, q.status, q.buyer_nickname, q.question_date,
q.answer_date, q.created_at, l.title, l.external_permalink
FROM marketplace_questions q
LEFT JOIN marketplace_listings l ON l.id = q.listing_id
WHERE LOWER(COALESCE(q.status, '')) = LOWER(%s)
ORDER BY q.question_date DESC
""",
(status,),
)
else:
cur.execute(
"""
SELECT q.id, q.external_question_id, q.external_item_id, q.question_text,
q.answer_text, q.status, q.buyer_nickname, q.question_date,
q.answer_date, q.created_at, l.title, l.external_permalink
FROM marketplace_questions q
LEFT JOIN marketplace_listings l ON l.id = q.listing_id
ORDER BY q.question_date DESC
"""
)
rows = cur.fetchall()
cur.close()
results = []
for r in rows:
results.append({
"id": r[0],
"external_question_id": r[1],
"external_item_id": r[2],
"question_text": r[3],
"answer_text": r[4],
"status": r[5],
"buyer_nickname": r[6],
"question_date": r[7],
"answer_date": r[8],
"created_at": r[9],
"listing_title": r[10],
"listing_permalink": r[11],
})
return results
def list_stale_questions(tenant_conn, max_age_hours: float = 24) -> list:
"""Unanswered questions older than `max_age_hours` (the SLA breach list).
Oldest first, so the most overdue question is at the top. Case-insensitive
on status, same as auto_answer_questions.
"""
cur = tenant_conn.cursor()
cur.execute(
"""
SELECT q.id, q.external_question_id, q.external_item_id, q.question_text,
q.buyer_nickname, q.question_date,
EXTRACT(EPOCH FROM (NOW() - COALESCE(q.question_date, q.created_at))) / 3600.0
AS age_hours,
l.title, l.external_permalink
FROM marketplace_questions q
LEFT JOIN marketplace_listings l ON l.id = q.listing_id
WHERE LOWER(COALESCE(q.status, 'unanswered')) = 'unanswered'
AND COALESCE(q.question_date, q.created_at)
< NOW() - (%s || ' hours')::interval
ORDER BY COALESCE(q.question_date, q.created_at) ASC
""",
(max_age_hours,),
)
rows = cur.fetchall()
cur.close()
return [
{
"id": r[0],
"external_question_id": r[1],
"external_item_id": r[2],
"question_text": r[3],
"buyer_nickname": r[4],
"question_date": r[5],
"age_hours": round(float(r[6]), 1) if r[6] is not None else None,
"listing_title": r[7],
"listing_permalink": r[8],
}
for r in rows
]
# ═══════════════════════════════════════════════════════════════════════════
# ORDERS
# ═══════════════════════════════════════════════════════════════════════════
# ML only accepts these values in the order.status search filter. Other
# statuses (refunded, partially_paid, ...) are rejected with 400, so we cover
# every order with this set.
ORDER_SYNC_STATUSES = ["paid", "payment_required", "payment_in_process", "confirmed", "cancelled", "invalid"]
def _save_order(cur, full: dict) -> Optional[int]:
"""Upsert a full ML order payload into marketplace_orders + items.
Returns the local marketplace_orders.id, or None when the payload has no id.
Does not commit — the caller owns the transaction.
"""
order_id = full.get("id")
if not order_id:
return None
buyer = full.get("buyer") or {}
shipping = full.get("shipping") or {}
order_items = full.get("order_items") or []
shipping_address = None
if shipping:
shipping_address = {
"id": shipping.get("id"),
"status": shipping.get("status"),
"tracking_number": shipping.get("tracking_number"),
"shipping_method": (shipping.get("shipping_option") or {}).get("name"),
}
buyer_name = f"{buyer.get('first_name') or ''} {buyer.get('last_name') or ''}".strip()
cur.execute(
"""
INSERT INTO marketplace_orders
(channel, external_order_id, external_status, buyer_name,
buyer_email, buyer_phone, buyer_nickname, shipping_address,
total_amount, shipping_cost, meli_shipping_id, raw_json, updated_at)
VALUES ('mercadolibre', %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
ON CONFLICT (external_order_id) DO UPDATE SET
external_status = EXCLUDED.external_status,
buyer_name = EXCLUDED.buyer_name,
buyer_email = EXCLUDED.buyer_email,
buyer_phone = EXCLUDED.buyer_phone,
buyer_nickname = EXCLUDED.buyer_nickname,
shipping_address = EXCLUDED.shipping_address,
total_amount = EXCLUDED.total_amount,
shipping_cost = EXCLUDED.shipping_cost,
meli_shipping_id = EXCLUDED.meli_shipping_id,
raw_json = EXCLUDED.raw_json,
updated_at = NOW()
RETURNING id
""",
(
str(order_id),
full.get("status"),
buyer_name,
buyer.get("email"),
(buyer.get("phone") or {}).get("number"),
# ML exposes the buyer handle as buyer.nickname (there is no
# `from.nickname` in the order payload).
buyer.get("nickname"),
json.dumps(shipping_address) if shipping_address else None,
full.get("total_amount"),
shipping.get("cost") if shipping else None,
str(shipping.get("id")) if shipping else None,
json.dumps(full),
),
)
row = cur.fetchone()
mpo_id = row[0] if row else None
if mpo_id and order_items:
# Clear old items and re-insert
cur.execute(
"DELETE FROM marketplace_order_items WHERE marketplace_order_id = %s",
(mpo_id,),
)
for it in order_items:
item_data = it.get("item") or {}
cur.execute(
"""
INSERT INTO marketplace_order_items
(marketplace_order_id, external_item_id, title,
quantity, unit_price, total_price)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(
mpo_id,
item_data.get("id"),
item_data.get("title"),
it.get("quantity"),
it.get("unit_price"),
it.get("full_unit_price"),
),
)
return mpo_id
def fetch_and_save_orders(tenant_conn, date_from: Optional[str] = None) -> dict:
"""Pull orders from ML in every relevant status and upsert them locally."""
cfg = get_meli_config(tenant_conn)
svc = _get_meli_service(cfg, tenant_conn)
user_id = cfg.get("meli_user_id")
if not svc or not user_id:
raise ValueError("MercadoLibre not configured")
cur = tenant_conn.cursor()
updated = 0
seen = set()
for status in ORDER_SYNC_STATUSES:
try:
ml_resp = svc.get_orders(user_id, status=status, date_from=date_from)
except MeliError as e:
logger.warning("Failed to fetch ML orders (status=%s): %s", status, e)
continue
for order_summary in ml_resp.get("results", []):
order_id = order_summary.get("id")
if not order_id or str(order_id) in seen:
continue
seen.add(str(order_id))
# Fetch full order detail
try:
full = svc.get_order(str(order_id))
except MeliError:
continue
if _save_order(cur, full):
updated += 1
tenant_conn.commit()
cur.close()
return {"processed": len(seen), "updated": updated}
def fetch_and_save_order(tenant_conn, order_id) -> dict:
"""Fetch a single ML order by id and upsert it (webhook processing)."""
cfg = get_meli_config(tenant_conn)
svc = _get_meli_service(cfg, tenant_conn)
if not svc:
raise ValueError("MercadoLibre not configured")
full = svc.get_order(str(order_id))
cur = tenant_conn.cursor()
mpo_id = _save_order(cur, full)
tenant_conn.commit()
cur.close()
return {"marketplace_order_id": mpo_id, "external_order_id": str(order_id), "status": full.get("status")}
def get_orders(tenant_conn, page=1, per_page=50, status=None):
cur = tenant_conn.cursor()
where = ["1=1"]
params = []
if status:
where.append("status = %s")
params.append(status)
cur.execute(
f"SELECT COUNT(*) FROM marketplace_orders WHERE {' AND '.join(where)}",
params,
)
total = cur.fetchone()[0]
cur.execute(
f"""
SELECT o.id, o.external_order_id, o.external_status, o.buyer_name,
o.buyer_nickname, o.total_amount, o.status, o.created_at,
o.nexus_sale_id
FROM marketplace_orders o
WHERE {' AND '.join(where)}
ORDER BY o.created_at DESC
LIMIT %s OFFSET %s
""",
params + [per_page, (page - 1) * per_page],
)
rows = cur.fetchall()
cur.close()
items = []
for r in rows:
items.append({
"id": r[0],
"external_order_id": r[1],
"external_status": r[2],
"buyer_name": r[3],
"buyer_nickname": r[4],
"total_amount": float(r[5]) if r[5] else None,
"status": r[6],
"created_at": str(r[7]),
"nexus_sale_id": r[8],
})
return {"items": items, "total": total, "page": page, "per_page": per_page}
def get_order_detail(tenant_conn, order_id: int) -> dict:
cur = tenant_conn.cursor()
cur.execute(
"""
SELECT id, external_order_id, external_status, buyer_name, buyer_email,
buyer_phone, buyer_nickname, shipping_address, total_amount,
shipping_cost, meli_shipping_id, nexus_sale_id, status, notes,
raw_json, created_at, updated_at
FROM marketplace_orders WHERE id = %s
""",
(order_id,),
)
row = cur.fetchone()
if not row:
cur.close()
raise ValueError("Order not found")
order = {
"id": row[0],
"external_order_id": row[1],
"external_status": row[2],
"buyer_name": row[3],
"buyer_email": row[4],
"buyer_phone": row[5],
"buyer_nickname": row[6],
"shipping_address": row[7],
"total_amount": float(row[8]) if row[8] else None,
"shipping_cost": float(row[9]) if row[9] else None,
"meli_shipping_id": row[10],
"nexus_sale_id": row[11],
"status": row[12],
"notes": row[13],
"raw_json": row[14],
"created_at": str(row[15]),
"updated_at": str(row[16]),
}
cur.execute(
"""
SELECT id, inventory_id, external_item_id, title, quantity,
unit_price, total_price
FROM marketplace_order_items WHERE marketplace_order_id = %s
""",
(order_id,),
)
items = []
for r in cur.fetchall():
items.append({
"id": r[0],
"inventory_id": r[1],
"external_item_id": r[2],
"title": r[3],
"quantity": r[4],
"unit_price": float(r[5]) if r[5] else None,
"total_price": float(r[6]) if r[6] else None,
})
order["items"] = items
cur.close()
return order
def update_order_status(tenant_conn, order_id: int, new_status: str) -> dict:
valid = {"pending", "confirmed", "packed", "shipped", "delivered", "cancelled", "rejected"}
if new_status not in valid:
raise ValueError(f"Invalid status. Allowed: {valid}")
cur = tenant_conn.cursor()
cur.execute(
"UPDATE marketplace_orders SET status = %s, updated_at = NOW() WHERE id = %s RETURNING external_order_id",
(new_status, order_id),
)
row = cur.fetchone()
tenant_conn.commit()
cur.close()
if not row:
raise ValueError("Order not found")
return {"ok": True, "status": new_status, "external_order_id": row[0]}
# ═══════════════════════════════════════════════════════════════════════════
# CONVERT ORDER → SALE
# ═══════════════════════════════════════════════════════════════════════════
def _tenant_auto_invoice_enabled(tenant_conn) -> bool:
"""Tenant opt-in (tenant_config.meli_auto_invoice) to invoice MeLi sales.
Off by default: an operator must explicitly enable it per tenant. The flag
is read, never written, by this module.
"""
cur = tenant_conn.cursor()
try:
cur.execute("SELECT value FROM tenant_config WHERE key = 'meli_auto_invoice'")
row = cur.fetchone()
return bool(row) and str(row[0]).strip().lower() in ("true", "1", "yes", "on")
except Exception:
return False
finally:
cur.close()
def _enqueue_order_cfdi(tenant_conn, sale_id: int) -> Optional[dict]:
"""Enqueue a sale invoice (CFDI ingreso) for a marketplace sale.
Marketplace buyers rarely provide fiscal data, so the invoice is issued to
"PUBLICO EN GENERAL" (SAT RFC XAXX010101000); build_ingreso_payload falls
back to that when no customer RFC is available.
No-op (returns None) when a CFDI already exists for the sale (e.g. the
pos_engine hook already enqueued one because the customer has an RFC) or
when Facturapi is not configured for the tenant.
"""
cur = tenant_conn.cursor()
try:
cur.execute(
"SELECT 1 FROM cfdi_queue WHERE sale_id = %s AND type = 'ingreso' "
"AND status <> 'cancelled' LIMIT 1",
(sale_id,),
)
if cur.fetchone():
return None
# Reuse the invoicing module's issuer config so the CFDI carries the
# same RFC / CP / serie as a counter sale (imported lazily to avoid a
# module-level blueprint -> service dependency).
from blueprints.invoicing_bp import _get_issuer_config
tcfg = _get_issuer_config(cur)
if not tcfg.get("facturapi_key"):
logger.info("MeLi sale #%s: Facturapi not configured, skipping CFDI", sale_id)
return None
cur.execute(
"SELECT part_number, name, quantity, unit_price, discount_pct, tax_rate "
"FROM sale_items WHERE sale_id = %s ORDER BY id",
(sale_id,),
)
items = []
for part_number, name, quantity, unit_price, discount_pct, tax_rate in cur.fetchall():
price = float(unit_price or 0)
qty = int(quantity or 0)
disc_pct = float(discount_pct or 0)
items.append({
"part_number": part_number,
"name": name or part_number,
"quantity": qty,
"unit_price": price,
"discount_amount": round(price * disc_pct / 100 * qty, 2),
"tax_rate": float(tax_rate if tax_rate is not None else 0.16),
})
from services.cfdi_facturapi_builder import build_ingreso_payload
from services.cfdi_queue import enqueue_cfdi
payload = build_ingreso_payload(
{
"id": sale_id,
"items": items,
"payment_method": "transferencia",
"sale_type": "cash",
"currency": "MXN",
},
tcfg,
None,
)
return enqueue_cfdi(tenant_conn, sale_id, "ingreso", payload)
except Exception as e:
logger.warning("Could not enqueue MeLi CFDI for sale %s: %s", sale_id, e)
return None
finally:
cur.close()
def convert_order_to_sale(
tenant_conn, marketplace_order_id: int, employee_id: int = None,
register_id: int = None, generate_cfdi: bool = False,
) -> dict:
"""Convert a marketplace order into a Nexus sale.
1. Look up marketplace_order + items
2. Map items to inventory_id (order item link, then marketplace_listings)
3. Run the canonical pos_engine.process_sale() — so the sale gets the same
accounting / savings / loyalty / webhook / CFDI hooks as a counter sale
(process_sale uses _safe_g(), so it is safe outside a request context)
4. Stamp source='mercadolibre' + external_order_id on the sale (process_sale
does not know about the marketplace origin)
5. Enqueue the CFDI when the tenant opted in (meli_auto_invoice) or the
caller asked for it (generate_cfdi)
6. Link the sale back to the marketplace order
`generate_cfdi` mirrors pos_engine's counter-sale checkbox; without it the
CFDI is only enqueued when tenant_config.meli_auto_invoice = true.
"""
order = get_order_detail(tenant_conn, marketplace_order_id)
if order.get("nexus_sale_id"):
raise ValueError("Order already converted to sale")
cur = tenant_conn.cursor()
# Build sale items
sale_items = []
for it in order["items"]:
inventory_id = it.get("inventory_id")
if not inventory_id:
# Map external_item_id -> inventory_id via marketplace_listings
cur.execute(
"""
SELECT inventory_id FROM marketplace_listings
WHERE external_item_id = %s AND channel = 'mercadolibre'
ORDER BY is_active DESC, id DESC
LIMIT 1
""",
(it["external_item_id"],),
)
inv_row = cur.fetchone()
inventory_id = inv_row[0] if inv_row else None
if not inventory_id:
cur.close()
raise ValueError(f"Could not map item {it['external_item_id']} to inventory")
sale_items.append({
"inventory_id": inventory_id,
"quantity": it["quantity"],
"unit_price": float(it["unit_price"] or 0),
"discount_pct": 0,
"tax_rate": 0.16,
})
# Find or create generic "MercadoLibre" customer
cur.execute(
"SELECT id FROM customers WHERE name = 'MercadoLibre' LIMIT 1"
)
cust = cur.fetchone()
if cust:
customer_id = cust[0]
else:
cur.execute(
"""
INSERT INTO customers (name, email, phone, is_active, price_tier)
VALUES ('MercadoLibre', 'marketplace@mercadolibre.com', '', true, 1)
RETURNING id
"""
)
customer_id = cur.fetchone()[0]
# Marketplace sales are not tied to a cashier branch: use the main branch,
# falling back to the lowest-id active branch.
cur.execute(
"SELECT id FROM branches WHERE is_main = true AND is_active = true ORDER BY id LIMIT 1"
)
brow = cur.fetchone()
if not brow:
cur.execute("SELECT id FROM branches WHERE is_active = true ORDER BY id LIMIT 1")
brow = cur.fetchone()
branch_id = brow[0] if brow else None
sale_data = {
"items": sale_items,
"customer_id": customer_id,
"payment_method": "transferencia",
"sale_type": "cash",
"register_id": register_id,
"amount_paid": float(order["total_amount"] or 0),
"notes": f"MercadoLibre order #{order['external_order_id']}",
"branch_id": branch_id,
"currency": "MXN",
"generate_cfdi": bool(generate_cfdi),
}
# Canonical sale path (accounting, savings, loyalty, dropshipping webhooks
# and the auto-invoice hook all run inside process_sale).
sale = process_sale(tenant_conn, sale_data)
sale_id = sale["id"]
# process_sale's non-blocking hooks swallow their own errors without a
# rollback, so an optional hook that fails (e.g. wa_learning on tenants
# that never created the WhatsApp tables) leaves the connection in an
# aborted transaction. record_sale_savings() has already committed the
# sale by then; normalize the connection state and verify the sale landed
# before doing our own writes.
try:
tenant_conn.commit()
except Exception:
tenant_conn.rollback()
cur = tenant_conn.cursor()
cur.execute("SELECT 1 FROM sales WHERE id = %s", (sale_id,))
if not cur.fetchone():
cur.close()
raise ValueError(
"La venta de la orden no quedó registrada: un hook de pos_engine "
"revirtió la transacción. Revisa los hooks no bloqueantes de la venta."
)
# process_sale has no notion of the marketplace origin.
cur.execute(
"UPDATE sales SET source = 'mercadolibre', external_order_id = %s WHERE id = %s",
(order["external_order_id"], sale_id),
)
# Invoice the marketplace sale when requested. The generic MercadoLibre
# customer has no RFC, so process_sale's own CFDI hook will not have fired;
# _enqueue_order_cfdi is idempotent and skips when a CFDI already exists.
cfdi = None
if generate_cfdi or _tenant_auto_invoice_enabled(tenant_conn):
cfdi = _enqueue_order_cfdi(tenant_conn, sale_id)
# Link order to sale
cur.execute(
"UPDATE marketplace_orders SET nexus_sale_id = %s, status = 'confirmed', updated_at = NOW() WHERE id = %s",
(sale_id, marketplace_order_id),
)
tenant_conn.commit()
cur.close()
result = {"sale_id": sale_id, "marketplace_order_id": marketplace_order_id}
if cfdi:
result["cfdi_queue_id"] = cfdi.get("id")
return result
# ═══════════════════════════════════════════════════════════════════════════
# IMPORT EXISTING LISTINGS FROM ML
# ═══════════════════════════════════════════════════════════════════════════
def process_meli_sync_queue(tenant_conn, batch_size: int = 50, force: bool = False) -> dict:
"""Process pending meli_sync_queue items and update stock on MercadoLibre.
Automatic runs (cron / stock-sync task) pass force=False and are gated by
tenant_config.meli_auto_sync_stock_price (default: disabled). Pushing the
local stock to ML zeroes and pauses any listing whose local product shows 0
— destructive when a tenant's inventory is not loaded. Manual runs from the
UI pass force=True to bypass the gate.
"""
cfg = get_meli_config(tenant_conn)
if not force and not cfg.get("meli_auto_sync_stock_price"):
return {"processed": 0, "failed": 0, "total": 0, "skipped": True,
"reason": "meli_auto_sync_stock_price disabled"}
svc = _get_meli_service(cfg, tenant_conn)
if not svc:
raise ValueError("MercadoLibre not configured")
cur = tenant_conn.cursor()
cur.execute("""
SELECT id, inventory_id FROM meli_sync_queue
WHERE status = 'pending'
ORDER BY created_at ASC
LIMIT %s
""", (batch_size,))
items = cur.fetchall()
processed = 0
failed = 0
for queue_id, inventory_id in items:
# Get current stock
from services.inventory_engine import get_stock
stock = get_stock(tenant_conn, inventory_id, branch_id=None)
# All active ML listings for this product. Several listings may share
# one internal part number, so stock must be pushed to every one of
# them (the v4.28 index no longer forces a single active listing).
cur.execute("""
SELECT external_item_id, external_status FROM marketplace_listings
WHERE inventory_id = %s AND channel = 'mercadolibre' AND is_active = true
ORDER BY id
""", (inventory_id,))
listings = cur.fetchall()
if not listings:
# No active listing, mark as done
cur.execute("""
UPDATE meli_sync_queue SET status = 'done', processed_at = NOW()
WHERE id = %s
""", (queue_id,))
tenant_conn.commit()
processed += 1
continue
stock_qty = max(0, int(stock or 0))
last_status = None
try:
for external_item_id, external_status in listings:
new_status = external_status
if stock_qty <= 0:
# Out of stock: pause the publication so it can't be bought.
if external_status != 'paused':
svc.pause_item(external_item_id)
new_status = 'paused'
else:
# Stock back: reactivate a paused publication before syncing qty.
if external_status == 'paused':
svc.activate_item(external_item_id)
new_status = 'active'
svc.update_item(external_item_id, {"available_quantity": stock_qty})
last_status = new_status
cur.execute("""
UPDATE marketplace_listings
SET external_status = %s, last_sync_at = NOW(), sync_errors = NULL
WHERE inventory_id = %s AND channel = 'mercadolibre' AND is_active = true
""", (last_status, inventory_id))
cur.execute("""
UPDATE meli_sync_queue SET status = 'done', processed_at = NOW()
WHERE id = %s
""", (queue_id,))
tenant_conn.commit()
processed += 1
except MeliError as e:
tenant_conn.rollback()
err_msg = _extract_meli_error(e)
cur.execute("""
UPDATE meli_sync_queue
SET status = 'failed', retry_count = retry_count + 1,
error_message = %s, processed_at = NOW()
WHERE id = %s
""", (err_msg[:500], queue_id))
tenant_conn.commit()
failed += 1
except Exception:
tenant_conn.rollback()
cur.execute("""
UPDATE meli_sync_queue
SET status = 'failed', retry_count = retry_count + 1,
error_message = 'Unexpected error', processed_at = NOW()
WHERE id = %s
""", (queue_id,))
tenant_conn.commit()
failed += 1
cur.close()
return {"processed": processed, "failed": failed, "total": len(items)}
def sync_listing_from_meli(tenant_conn, external_item_id: str) -> dict:
"""Refresh a single local listing from ML (used by the items webhook)."""
cfg = get_meli_config(tenant_conn)
svc = _get_meli_service(cfg, tenant_conn)
if not svc:
raise ValueError("MercadoLibre not configured")
item = svc.get_item(str(external_item_id))
cur = tenant_conn.cursor()
# Local cost of the matched product (if any) so we can refresh the margin.
cur.execute(
"""
SELECT l.id, i.cost
FROM marketplace_listings l
LEFT JOIN inventory i ON i.id = l.inventory_id
WHERE l.external_item_id = %s AND l.channel = 'mercadolibre'
ORDER BY l.is_active DESC, l.id DESC
LIMIT 1
""",
(str(external_item_id),),
)
row = cur.fetchone()
listing_id = row[0] if row else None
unit_cost = float(row[1]) if row and row[1] is not None else None
cur.execute(
"""
UPDATE marketplace_listings
SET external_status = %s, title = %s, meli_category_id = %s,
publish_price = %s, listing_type_id = COALESCE(%s, listing_type_id),
last_sync_at = NOW(), sync_errors = NULL
WHERE external_item_id = %s AND channel = 'mercadolibre'
""",
(
item.get("status"),
item.get("title"),
item.get("category_id"),
item.get("price"),
item.get("listing_type_id"),
str(external_item_id),
),
)
# Refresh economics from the ML item's live price/listing type.
if listing_id and item.get("price"):
econ = compute_listing_economics(
svc, cfg.get("meli_site_id", "MLM"), float(item["price"]),
item.get("listing_type_id") or "gold_special",
item.get("category_id"), cost=unit_cost,
)
_persist_listing_economics(cur, listing_id, econ)
tenant_conn.commit()
cur.close()
return {"external_item_id": str(external_item_id), "status": item.get("status")}
def _extract_item_part_numbers(item: dict) -> list:
"""Collect candidate SKU/part numbers from an ML item payload.
ML no longer populates `seller_custom_field` for many sellers; the part
number and seller SKU live in the item attributes instead — including the
attributes nested per variation.
"""
candidates = []
def _add(value):
if value:
text = str(value).strip()
if text and text not in candidates:
candidates.append(text)
for key in ("seller_custom_field", "seller_sku"):
_add(item.get(key))
attr_sources = [item.get("attributes") or []]
for variation in item.get("variations") or []:
attr_sources.append(variation.get("attributes") or [])
for attrs in attr_sources:
for attr in attrs:
if attr.get("id") not in ("SELLER_SKU", "PART_NUMBER", "SELLER_PART_NUMBER"):
continue
_add(attr.get("value_name"))
for value in attr.get("values") or []:
if isinstance(value, dict):
_add(value.get("name"))
return candidates
def _build_inventory_lookup(cur) -> dict:
"""Build the maps used to link ML items to local inventory.
Keys are trimmed/uppercase; the ``*_norm`` variants strip spaces, dashes
and dots so an ML ``8505-D2094`` also matches a local ``8505D2094``.
"""
lookup = {"part": {}, "sku": {}, "part_norm": {}, "sku_norm": {}}
cur.execute("SELECT id, part_number FROM inventory WHERE is_active = true")
for inv_id, pn in cur.fetchall():
key = (pn or "").strip().upper()
if not key:
continue
lookup["part"].setdefault(key, inv_id)
norm = _norm_sku(key)
if norm:
lookup["part_norm"].setdefault(norm, inv_id)
cur.execute("SELECT inventory_id, sku FROM inventory_sku_aliases WHERE is_active = true")
for inv_id, sku in cur.fetchall():
key = (sku or "").strip().upper()
if not key:
continue
lookup["sku"].setdefault(key, inv_id)
norm = _norm_sku(key)
if norm:
lookup["sku_norm"].setdefault(norm, inv_id)
return lookup
def _match_inventory_id(candidates: list, lookup: dict) -> tuple:
"""Return (inventory_id, match_source) for the first candidate that hits.
Match order: exact part number, exact alias, normalized part number,
normalized alias. (None, None) when nothing matches.
"""
for raw in candidates:
key = (raw or "").strip().upper()
if not key:
continue
if key in lookup["part"]:
return lookup["part"][key], "part_number"
if key in lookup["sku"]:
return lookup["sku"][key], "alias"
for raw in candidates:
norm = _norm_sku(raw)
if not norm:
continue
if norm in lookup["part_norm"]:
return lookup["part_norm"][norm], "part_number_norm"
if norm in lookup["sku_norm"]:
return lookup["sku_norm"][norm], "alias_norm"
return None, None
def import_existing_listings(tenant_conn, page_limit: int = 50, limit_items: int = None) -> dict:
"""Import all existing MercadoLibre listings for the connected seller.
For each ML item, tries to match it to a local inventory product by:
1. seller_custom_field / seller_sku / SELLER_SKU / PART_NUMBER attributes
(item-level and per variation), normalized (spaces/dashes stripped)
2. inventory.part_number matching that value
3. inventory_sku_aliases.sku matching that value or the ML item id
`limit_items` caps how many items are processed (useful for a dry probe).
Returns summary of imported / unmatched items.
"""
cfg = get_meli_config(tenant_conn)
svc = _get_meli_service(cfg, tenant_conn)
user_id = cfg.get("meli_user_id")
if not svc or not user_id:
raise ValueError("MercadoLibre not configured")
cur = tenant_conn.cursor()
lookup = _build_inventory_lookup(cur)
imported = 0
unmatched = 0
errors = 0
processed = 0
offset = 0
while True:
try:
resp = svc.get_user_items(str(user_id), limit=page_limit, offset=offset)
except MeliError as e:
cur.close()
raise ValueError(f"Failed to fetch items: {e}")
results = resp.get("results", [])
if not results:
break
for item_id in results:
if limit_items and processed >= limit_items:
break
processed += 1
try:
item = svc.get_item(item_id)
except MeliError:
errors += 1
continue
title = item.get("title", "")
price = item.get("price", 0)
status = item.get("status", "active")
permalink = item.get("permalink", "")
category_id = item.get("category_id", "")
listing_type_id = item.get("listing_type_id")
# Match to local inventory: attribute SKUs first, then the ML
# item id itself (some tenants store it as an alias).
candidates = _extract_item_part_numbers(item)
candidates.append(str(item_id))
inventory_id, match_source = _match_inventory_id(candidates, lookup)
# Upsert listing. When the item has no local match, inventory_id is
# NULL and the partial unique index (inventory_id, channel) cannot
# deduplicate it — look it up by external_item_id so repeated
# imports update the same row instead of creating duplicates.
try:
cur.execute(
"""
SELECT id FROM marketplace_listings
WHERE external_item_id = %s AND channel = 'mercadolibre'
ORDER BY is_active DESC, id DESC
LIMIT 1
""",
(str(item_id),),
)
existing = cur.fetchone()
if existing:
cur.execute(
"""
UPDATE marketplace_listings
SET inventory_id = COALESCE(%s, inventory_id),
inventory_match_source = COALESCE(%s, inventory_match_source),
inventory_matched_at = CASE WHEN %s::int IS NOT NULL
THEN NOW() ELSE inventory_matched_at END,
external_status = %s,
external_permalink = %s,
title = %s,
meli_category_id = %s,
listing_type_id = COALESCE(%s, listing_type_id),
publish_price = %s,
last_sync_at = NOW(),
sync_errors = NULL,
is_active = true
WHERE id = %s
""",
(
inventory_id, match_source, inventory_id,
status, permalink, title, category_id,
listing_type_id, price, existing[0],
),
)
else:
cur.execute(
"""
INSERT INTO marketplace_listings
(inventory_id, channel, external_item_id, external_status,
external_permalink, title, meli_category_id, publish_price,
listing_type_id, inventory_match_source, inventory_matched_at,
last_sync_at, sync_errors, is_active)
VALUES (%s, 'mercadolibre', %s, %s, %s, %s, %s, %s, %s, %s,
CASE WHEN %s::int IS NOT NULL THEN NOW() ELSE NULL END,
NOW(), NULL, true)
""",
(
inventory_id,
str(item_id),
status,
permalink,
title,
category_id,
price,
listing_type_id,
match_source,
inventory_id,
),
)
tenant_conn.commit()
if inventory_id:
imported += 1
else:
unmatched += 1
except Exception:
tenant_conn.rollback()
errors += 1
if limit_items and processed >= limit_items:
break
if len(results) < page_limit:
break
offset += page_limit
cur.close()
return {
"imported": imported,
"unmatched": unmatched,
"errors": errors,
"total_processed": imported + unmatched + errors,
}
def backfill_listing_inventory(
tenant_conn,
limit: int = None,
only_unmapped: bool = True,
dry_run: bool = True,
) -> dict:
"""Populate marketplace_listings.inventory_id for already-imported listings.
Unlike import_existing_listings (which re-pages the whole ML catalog), this
walks the listings that already exist locally, fetches each ML item once,
matches it against inventory (part number / alias / normalized) and stores
the result plus the match source.
`only_unmapped=True` (default) only touches listings with inventory_id
NULL, so a manual mapping is never overwritten. `dry_run=True` reports what
it would do without writing.
Returns {scanned, matched, unmatched, errors, dry_run, details}.
"""
cfg = get_meli_config(tenant_conn)
svc = _get_meli_service(cfg, tenant_conn)
if not svc:
raise ValueError("MercadoLibre not configured")
cur = tenant_conn.cursor()
lookup = _build_inventory_lookup(cur)
where = ["channel = 'mercadolibre'"]
if only_unmapped:
where.append("inventory_id IS NULL")
sql = f"SELECT id, external_item_id FROM marketplace_listings WHERE {' AND '.join(where)} ORDER BY id"
if limit:
sql += f" LIMIT {int(limit)}"
cur.execute(sql)
listings = cur.fetchall()
summary = {
"scanned": 0, "matched": 0, "unmatched": 0, "errors": 0,
"dry_run": dry_run, "details": [],
}
for listing_id, external_item_id in listings:
summary["scanned"] += 1
try:
item = svc.get_item(str(external_item_id))
except MeliError as e:
summary["errors"] += 1
summary["details"].append({
"listing_id": listing_id, "external_item_id": str(external_item_id),
"status": "error", "error": _extract_meli_error(e),
})
continue
candidates = _extract_item_part_numbers(item)
candidates.append(str(external_item_id))
inventory_id, match_source = _match_inventory_id(candidates, lookup)
if inventory_id is None:
summary["unmatched"] += 1
summary["details"].append({
"listing_id": listing_id, "external_item_id": str(external_item_id),
"status": "unmatched", "title": item.get("title"),
"candidates": candidates,
})
continue
summary["matched"] += 1
if not dry_run:
cur.execute(
"""
UPDATE marketplace_listings
SET inventory_id = %s,
inventory_match_source = %s,
inventory_matched_at = NOW()
WHERE id = %s AND inventory_id IS NULL
""",
(inventory_id, match_source, listing_id),
)
tenant_conn.commit()
summary["details"].append({
"listing_id": listing_id, "external_item_id": str(external_item_id),
"status": "matched", "inventory_id": inventory_id,
"match_source": match_source,
})
cur.close()
return summary