feat(ml): upload images to ML hosting + show earnings estimate on validate

- Add upload_image() to MeliService using ML /pictures endpoint
- Add get_listing_price() to fetch exact ML fees
- Auto-upload inventory images to ML before validate/publish
- Show fee breakdown and net earnings in validate modal
- Fallback to approximate fees (13-18%) if ML API fails
This commit is contained in:
2026-06-11 18:35:25 +00:00
parent 041efd5c5c
commit 3d70c3fcc9
3 changed files with 186 additions and 5 deletions

View File

@@ -229,6 +229,68 @@ def build_item_payload(
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 = []
for url in image_urls:
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 check_meli_shipping_config(svc: MeliService, cfg: dict) -> dict:
"""Check if the user's ML account has the required shipping modes configured.
@@ -394,6 +456,17 @@ def validate_items(
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"])
@@ -401,7 +474,7 @@ def validate_items(
shipping_cost = custom_data.get("shipping_cost")
payload = build_item_payload(
inv, images, meli_category_id, price, item_stock,
inv, ml_images, meli_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,
@@ -411,7 +484,18 @@ def validate_items(
try:
validation = svc.validate_item(payload)
if validation.get("status") == "valid":
results["valid"].append({"inventory_id": inv_id, "validation": validation})
# Calculate estimated net earnings after ML fees
site_id = cfg.get("meli_site_id", "MLM")
fee_calc = _calculate_meli_net_price(svc, site_id, float(price), listing_type_id, meli_category_id)
results["valid"].append({
"inventory_id": inv_id,
"validation": validation,
"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"],
})
else:
errors = validation.get("validation_errors", [])
error_msgs = [f"{e.get('field', '')}: {e.get('message', '')}" for e in errors]
@@ -504,6 +588,17 @@ def publish_items(
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"])
@@ -511,7 +606,7 @@ def publish_items(
shipping_cost = custom_data.get("shipping_cost")
payload = build_item_payload(
inv, images, meli_category_id, price, item_stock,
inv, ml_images, meli_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,

View File

@@ -143,6 +143,66 @@ class MeliService:
)
return resp.json()
# ─── Images ──────────────────────────────────────────────────────────
def upload_image(self, image_path_or_url: str) -> dict:
"""Upload an image to MercadoLibre's image hosting.
Accepts either a local file path or a URL.
Returns the ML picture dict with 'id' and 'secure_url' / 'url' keys.
"""
import os
import requests as raw_requests
# If it's a URL, download it first
if image_path_or_url.startswith("http://") or image_path_or_url.startswith("https://"):
img_resp = raw_requests.get(image_path_or_url, timeout=30)
if not img_resp.ok:
raise MeliError(f"Failed to download image from {image_path_or_url}: {img_resp.status_code}")
file_bytes = img_resp.content
content_type = img_resp.headers.get("Content-Type", "image/jpeg")
filename = "image.jpg"
else:
if not os.path.exists(image_path_or_url):
raise MeliError(f"Image file not found: {image_path_or_url}")
with open(image_path_or_url, "rb") as f:
file_bytes = f.read()
content_type = "image/jpeg"
filename = os.path.basename(image_path_or_url)
upload_url = f"{BASE_URL}/pictures"
files = {"file": (filename, file_bytes, content_type)}
req_headers = {"Authorization": f"Bearer {self.access_token}"}
resp = raw_requests.post(upload_url, files=files, headers=req_headers, timeout=60)
if resp.status_code == 401 and self.refresh_token:
self._refresh_token()
req_headers["Authorization"] = f"Bearer {self.access_token}"
resp = raw_requests.post(upload_url, files=files, headers=req_headers, timeout=60)
if resp.status_code == 401:
raise MeliAuthError("Unauthorized. Token may be expired.", status_code=401, response_body=resp.text)
if not resp.ok:
raise MeliError(f"Image upload failed: {resp.status_code} {resp.text}", status_code=resp.status_code, response_body=resp.text)
return resp.json()
def get_listing_price(self, site_id: str, price: float, listing_type_id: str, category_id: str) -> dict:
"""Get the exact fee / net amount for a given price, listing type and category.
ML endpoint: GET /sites/{site_id}/listing_prices
Returns dict with sale_fee_amount, net_amount, etc.
"""
return self._request(
"GET",
f"/sites/{site_id}/listing_prices",
params={
"price": str(price),
"listing_type_id": listing_type_id,
"category_id": category_id,
},
)
# ─── Items (listings) ────────────────────────────────────────────────
def validate_item(self, payload: dict) -> dict: