"""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[]) 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