- Add get_shipping_preferences to meli_service.py - Add check_meli_shipping_config to validate ME2 adoption before publishing - Include local_pick_up and free_shipping in item payload - Translate ME2/mode errors to actionable Spanish messages - Check shipping config in both validate_items and publish_items
1192 lines
42 KiB
Python
1192 lines
42 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
|
|
from typing import Optional
|
|
from decimal import Decimal
|
|
|
|
from services.meli_service import MeliService, MeliError, MeliAuthError
|
|
from services.pos_engine import process_sale, calculate_totals
|
|
from services.inventory_engine import get_stock, get_stock_bulk
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# CONFIG HELPERS
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
MELI_CONFIG_KEYS = [
|
|
"meli_access_token",
|
|
"meli_refresh_token",
|
|
"meli_user_id",
|
|
"meli_site_id",
|
|
"meli_enabled",
|
|
"meli_auto_publish",
|
|
"meli_sync_interval_min",
|
|
"meli_order_sync_interval_min",
|
|
"meli_default_category_id",
|
|
"meli_shipping_mode",
|
|
"meli_client_id",
|
|
"meli_client_secret",
|
|
]
|
|
|
|
|
|
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_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 _get_meli_service(cfg: dict) -> Optional[MeliService]:
|
|
"""Build MeliService from config dict."""
|
|
token = cfg.get("meli_access_token")
|
|
if not token:
|
|
return None
|
|
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"),
|
|
)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# 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. Configura tus métodos de envío en MercadoLibre."
|
|
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,
|
|
) -> dict:
|
|
"""Convert a Nexus inventory row into a MercadoLibre item payload."""
|
|
title = custom_title or f"{inventory_row['name']} {inventory_row['brand'] or ''} {inventory_row['part_number'] or ''}".strip()
|
|
# ML title limit is 60 chars; truncate smartly
|
|
if len(title) > 60:
|
|
title = title[:57] + "..."
|
|
|
|
payload = {
|
|
"title": title,
|
|
"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": "new",
|
|
"pictures": [{"source": url} for url in images if url],
|
|
"shipping": {"mode": shipping_mode, "local_pick_up": False, "free_shipping": False},
|
|
"attributes": [],
|
|
}
|
|
|
|
if inventory_row.get("brand"):
|
|
payload["attributes"].append(
|
|
{"id": "BRAND", "value_name": inventory_row["brand"]}
|
|
)
|
|
|
|
if inventory_row.get("part_number"):
|
|
payload["attributes"].append(
|
|
{"id": "PART_NUMBER", "value_name": inventory_row["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"):
|
|
payload["attributes"].append(
|
|
{"id": "VEHICLE_MODEL", "value_name": first["brand"]}
|
|
)
|
|
if first.get("model"):
|
|
payload["attributes"].append(
|
|
{"id": "VEHICLE_MODEL_NAME", "value_name": first["model"]}
|
|
)
|
|
|
|
# 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 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]) -> 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
|
|
FROM inventory
|
|
WHERE id = ANY(%s)
|
|
""",
|
|
(inventory_ids,),
|
|
)
|
|
rows = {r[0]: r for r in cur.fetchall()}
|
|
|
|
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]
|
|
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(image_url),
|
|
"has_stock": stock > 0,
|
|
"has_price": price > 0,
|
|
"price": price,
|
|
"stock": stock,
|
|
"image_url": image_url,
|
|
"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,
|
|
) -> dict:
|
|
"""Validate items against ML /items/validate without creating them.
|
|
|
|
Returns per-item validation results with ML error details if any.
|
|
"""
|
|
cfg = get_meli_config(tenant_conn)
|
|
svc = _get_meli_service(cfg)
|
|
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"]}]}
|
|
|
|
cur = tenant_conn.cursor()
|
|
cur.execute(
|
|
"""
|
|
SELECT id, part_number, name, brand, price_1, vehicle_compatibility,
|
|
image_url, unit, is_active
|
|
FROM inventory
|
|
WHERE id = ANY(%s) AND is_active = true
|
|
""",
|
|
(inventory_ids,),
|
|
)
|
|
rows = {r[0]: r for r in cur.fetchall()}
|
|
stock_map = get_stock_bulk(tenant_conn, branch_id=None)
|
|
|
|
custom_data = custom_data or {}
|
|
results = {"valid": [], "invalid": []}
|
|
|
|
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],
|
|
}
|
|
|
|
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
|
|
|
|
images = []
|
|
if inv.get("image_url"):
|
|
images.append(inv["image_url"])
|
|
if not images:
|
|
results["invalid"].append({"inventory_id": inv_id, "error": "El producto no tiene imagen"})
|
|
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)
|
|
|
|
payload = build_item_payload(
|
|
inv, images, meli_category_id, price, item_stock,
|
|
shipping_mode=shipping_mode, listing_type_id=listing_type_id,
|
|
custom_title=title, extra_attributes=extra_attrs,
|
|
)
|
|
|
|
try:
|
|
validation = svc.validate_item(payload)
|
|
if validation.get("status") == "valid":
|
|
results["valid"].append({"inventory_id": inv_id, "validation": validation})
|
|
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:
|
|
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,
|
|
) -> dict:
|
|
"""Publish one or more inventory items to MercadoLibre.
|
|
|
|
Returns summary dict with success/failure per item.
|
|
"""
|
|
cfg = get_meli_config(tenant_conn)
|
|
svc = _get_meli_service(cfg)
|
|
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"]}]}
|
|
|
|
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
|
|
FROM inventory
|
|
WHERE id = ANY(%s) AND is_active = true
|
|
""",
|
|
(inventory_ids,),
|
|
)
|
|
rows = {r[0]: r for r in cur.fetchall()}
|
|
|
|
# Batch fetch stock
|
|
stock_map = get_stock_bulk(tenant_conn, branch_id=None)
|
|
|
|
custom_data = custom_data or {}
|
|
results = {"success": [], "failed": []}
|
|
|
|
for inv_id in inventory_ids:
|
|
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],
|
|
}
|
|
|
|
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
|
|
images = []
|
|
if inv.get("image_url"):
|
|
images.append(inv["image_url"])
|
|
|
|
if not images:
|
|
results["failed"].append({"inventory_id": inv_id, "error": "El producto no tiene imagen. ML requiere imagen para publicar."})
|
|
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)
|
|
|
|
payload = build_item_payload(
|
|
inv, images, meli_category_id, price, item_stock,
|
|
shipping_mode=shipping_mode, listing_type_id=listing_type_id,
|
|
custom_title=title, extra_attributes=extra_attrs,
|
|
)
|
|
|
|
try:
|
|
ml_item = svc.create_item(payload)
|
|
external_item_id = ml_item.get("id")
|
|
permalink = ml_item.get("permalink")
|
|
|
|
# Persist listing
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO marketplace_listings
|
|
(inventory_id, channel, external_item_id, external_status,
|
|
external_permalink, title, meli_category_id, publish_price,
|
|
last_sync_at, sync_errors, is_active)
|
|
VALUES (%s, 'mercadolibre', %s, 'active', %s, %s, %s, %s, NOW(), NULL, true)
|
|
ON CONFLICT (inventory_id, channel) WHERE is_active = true
|
|
DO UPDATE SET
|
|
external_item_id = EXCLUDED.external_item_id,
|
|
external_status = EXCLUDED.external_status,
|
|
external_permalink = EXCLUDED.external_permalink,
|
|
title = EXCLUDED.title,
|
|
meli_category_id = EXCLUDED.meli_category_id,
|
|
publish_price = EXCLUDED.publish_price,
|
|
last_sync_at = NOW(),
|
|
sync_errors = NULL,
|
|
is_active = true
|
|
""",
|
|
(
|
|
inv_id,
|
|
external_item_id,
|
|
permalink,
|
|
payload["title"],
|
|
meli_category_id,
|
|
inv["price_1"],
|
|
),
|
|
)
|
|
tenant_conn.commit()
|
|
results["success"].append(
|
|
{"inventory_id": inv_id, "external_item_id": external_item_id, "permalink": permalink}
|
|
)
|
|
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
|
|
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)
|
|
if not svc:
|
|
raise ValueError("MercadoLibre not configured")
|
|
|
|
cur = tenant_conn.cursor()
|
|
cur.execute(
|
|
"""
|
|
SELECT id, inventory_id, external_item_id, meli_category_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],
|
|
}
|
|
|
|
# Get current stock/price
|
|
cur.execute(
|
|
"SELECT price_1 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
|
|
|
|
stock_map = get_stock_bulk(tenant_conn, branch_id=None)
|
|
current_stock = stock_map.get(listing["inventory_id"], 0)
|
|
|
|
try:
|
|
svc.update_item(
|
|
listing["external_item_id"],
|
|
{"price": round(current_price, 2), "available_quantity": max(current_stock, 0)},
|
|
)
|
|
cur.execute(
|
|
"""
|
|
UPDATE marketplace_listings
|
|
SET last_sync_at = NOW(), sync_errors = NULL, publish_price = %s
|
|
WHERE id = %s
|
|
""",
|
|
(current_price, listing_id),
|
|
)
|
|
tenant_conn.commit()
|
|
cur.close()
|
|
return {"ok": True, "price": current_price, "stock": current_stock}
|
|
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)
|
|
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)
|
|
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)
|
|
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"}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# ORDERS
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
def fetch_and_save_orders(tenant_conn, date_from: Optional[str] = None) -> dict:
|
|
"""Pull orders from ML and upsert into marketplace_orders."""
|
|
cfg = get_meli_config(tenant_conn)
|
|
svc = _get_meli_service(cfg)
|
|
user_id = cfg.get("meli_user_id")
|
|
if not svc or not user_id:
|
|
raise ValueError("MercadoLibre not configured")
|
|
|
|
ml_resp = svc.get_orders(user_id, status="paid", date_from=date_from)
|
|
orders = ml_resp.get("results", [])
|
|
|
|
cur = tenant_conn.cursor()
|
|
created = 0
|
|
updated = 0
|
|
|
|
for order_summary in orders:
|
|
order_id = order_summary.get("id")
|
|
if not order_id:
|
|
continue
|
|
|
|
# Fetch full order detail
|
|
try:
|
|
full = svc.get_order(str(order_id))
|
|
except MeliError:
|
|
continue
|
|
|
|
external_status = full.get("status")
|
|
buyer = full.get("buyer", {})
|
|
shipping = full.get("shipping", {})
|
|
order_items = full.get("order_items", [])
|
|
|
|
# Build shipping address JSON
|
|
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", {}).get("name"),
|
|
}
|
|
|
|
total_amount = full.get("total_amount")
|
|
shipping_cost = shipping.get("cost") if shipping else None
|
|
|
|
# Upsert order
|
|
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,
|
|
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),
|
|
external_status,
|
|
buyer.get("first_name", "") + " " + buyer.get("last_name", ""),
|
|
buyer.get("email"),
|
|
buyer.get("phone", {}).get("number"),
|
|
buyer.get("nickname"),
|
|
json.dumps(shipping_address) if shipping_address else None,
|
|
total_amount,
|
|
shipping_cost,
|
|
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:
|
|
# Check if this was insert or update
|
|
cur.execute(
|
|
"SELECT created_at FROM marketplace_orders WHERE id = %s",
|
|
(mpo_id,),
|
|
)
|
|
created_at = cur.fetchone()[0]
|
|
# Simple heuristic: if updated_at == created_at (within 1s), it's new
|
|
is_new = True # ON CONFLICT always returns id; we count as updated for simplicity
|
|
updated += 1
|
|
|
|
# Upsert order items
|
|
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", {})
|
|
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"),
|
|
),
|
|
)
|
|
|
|
tenant_conn.commit()
|
|
cur.close()
|
|
return {"processed": len(orders), "updated": updated}
|
|
|
|
|
|
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 convert_order_to_sale(
|
|
tenant_conn, marketplace_order_id: int, employee_id: int = None, register_id: int = None
|
|
) -> dict:
|
|
"""Convert a marketplace order into a Nexus sale.
|
|
|
|
1. Look up marketplace_order + items
|
|
2. Map items to inventory_id (via marketplace_listings external_item_id)
|
|
3. Build sale_data compatible with process_sale()
|
|
4. Call process_sale()
|
|
5. Link sale back to marketplace_order
|
|
"""
|
|
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"]:
|
|
# Map external_item_id -> inventory_id via marketplace_listings
|
|
cur.execute(
|
|
"SELECT inventory_id FROM marketplace_listings WHERE external_item_id = %s LIMIT 1",
|
|
(it["external_item_id"],),
|
|
)
|
|
inv_row = cur.fetchone()
|
|
inventory_id = inv_row[0] if inv_row else None
|
|
|
|
if not inventory_id:
|
|
# Try to match by title fuzzy? Skip for now.
|
|
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]
|
|
|
|
# Build sale_data
|
|
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']}",
|
|
}
|
|
|
|
# We need to run process_sale inside the same connection.
|
|
# process_sale expects the caller to commit. We'll do that.
|
|
# However, process_sale uses flask.g for employee_id and branch_id.
|
|
# We need to set those or pass them explicitly.
|
|
# For now, we'll create the sale manually to avoid flask.g dependency.
|
|
|
|
sale = _create_sale_manual(tenant_conn, sale_data, employee_id=employee_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()
|
|
return {"sale_id": sale["id"], "marketplace_order_id": marketplace_order_id}
|
|
|
|
|
|
def _create_sale_manual(tenant_conn, sale_data: dict, employee_id: int = None) -> dict:
|
|
"""Create a sale record without relying on flask.g.
|
|
|
|
Simplified version of process_sale for background / external use.
|
|
"""
|
|
from services.inventory_engine import record_sale as inventory_record_sale
|
|
from decimal import Decimal, ROUND_HALF_UP
|
|
|
|
cur = tenant_conn.cursor()
|
|
items = sale_data.get("items", [])
|
|
customer_id = sale_data.get("customer_id")
|
|
payment_method = sale_data.get("payment_method", "efectivo")
|
|
sale_type = sale_data.get("sale_type", "cash")
|
|
register_id = sale_data.get("register_id")
|
|
amount_paid = float(sale_data.get("amount_paid", 0))
|
|
notes = sale_data.get("notes")
|
|
|
|
if not items:
|
|
raise ValueError("No items in sale")
|
|
|
|
# Enrich items
|
|
inv_ids = [it["inventory_id"] for it in items]
|
|
cur.execute(
|
|
"""
|
|
SELECT id, part_number, name, cost, price_1, tax_rate
|
|
FROM inventory WHERE id = ANY(%s) AND is_active = true
|
|
ORDER BY id FOR UPDATE
|
|
""",
|
|
(inv_ids,),
|
|
)
|
|
inv_map = {r[0]: r for r in cur.fetchall()}
|
|
|
|
enriched = []
|
|
for it in items:
|
|
inv_id = it["inventory_id"]
|
|
inv = inv_map.get(inv_id)
|
|
if not inv:
|
|
raise ValueError(f"Inventory item {inv_id} not found")
|
|
qty = int(it.get("quantity", 1))
|
|
unit_price = float(it.get("unit_price", inv[4] or 0))
|
|
discount_pct = float(it.get("discount_pct", 0))
|
|
tax_rate = float(it.get("tax_rate", inv[5] or 0.16))
|
|
unit_cost = float(inv[3]) if inv[3] else 0
|
|
|
|
enriched.append({
|
|
"inventory_id": inv_id,
|
|
"part_number": inv[1],
|
|
"name": inv[2],
|
|
"quantity": qty,
|
|
"unit_price": unit_price,
|
|
"unit_cost": unit_cost,
|
|
"discount_pct": discount_pct,
|
|
"tax_rate": tax_rate,
|
|
})
|
|
|
|
totals = calculate_totals(enriched)
|
|
|
|
# Insert sale
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO sales
|
|
(customer_id, employee_id, register_id, sale_type, payment_method,
|
|
subtotal, discount_total, tax_total, total, amount_paid, change_given,
|
|
status, notes, source, external_order_id)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'completed', %s, 'mercadolibre', %s)
|
|
RETURNING id
|
|
""",
|
|
(
|
|
customer_id,
|
|
employee_id,
|
|
register_id,
|
|
sale_type,
|
|
payment_method,
|
|
totals["subtotal"],
|
|
totals["discount_total"],
|
|
totals["tax_total"],
|
|
totals["total"],
|
|
amount_paid,
|
|
0,
|
|
notes,
|
|
sale_data.get("external_order_id"),
|
|
),
|
|
)
|
|
sale_id = cur.fetchone()[0]
|
|
|
|
# Insert sale_items
|
|
for it in totals["items"]:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO sale_items
|
|
(sale_id, inventory_id, part_number, name, quantity, unit_price,
|
|
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount, subtotal)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
""",
|
|
(
|
|
sale_id,
|
|
it["inventory_id"],
|
|
it["part_number"],
|
|
it["name"],
|
|
it["quantity"],
|
|
it["unit_price"],
|
|
it.get("unit_cost", 0),
|
|
it.get("discount_pct", 0),
|
|
it.get("discount_amount", 0),
|
|
it.get("tax_rate", 0.16),
|
|
it.get("tax_amount", 0),
|
|
it["subtotal"],
|
|
),
|
|
)
|
|
|
|
# Deduct inventory
|
|
for it in enriched:
|
|
inventory_record_sale(
|
|
tenant_conn, it["inventory_id"], it["quantity"], reference=f"ML sale {sale_id}"
|
|
)
|
|
|
|
tenant_conn.commit()
|
|
cur.close()
|
|
return {"id": sale_id, **totals}
|