- 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
2953 lines
111 KiB
Python
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
|