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 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: def check_meli_shipping_config(svc: MeliService, cfg: dict) -> dict:
"""Check if the user's ML account has the required shipping modes configured. """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"}) results["invalid"].append({"inventory_id": inv_id, "error": "El producto no tiene imagen"})
continue 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)) title = (custom_data.get("titles") or {}).get(str(inv_id))
extra_attrs = (custom_data.get("attributes") 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"]) 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") shipping_cost = custom_data.get("shipping_cost")
payload = build_item_payload( 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, shipping_mode=shipping_mode, listing_type_id=listing_type_id,
custom_title=title, extra_attributes=extra_attrs, custom_title=title, extra_attributes=extra_attrs,
shipping_cost=shipping_cost, shipping_cost=shipping_cost,
@@ -411,7 +484,18 @@ def validate_items(
try: try:
validation = svc.validate_item(payload) validation = svc.validate_item(payload)
if validation.get("status") == "valid": 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: else:
errors = validation.get("validation_errors", []) errors = validation.get("validation_errors", [])
error_msgs = [f"{e.get('field', '')}: {e.get('message', '')}" for e in 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."}) results["failed"].append({"inventory_id": inv_id, "error": "El producto no tiene imagen. ML requiere imagen para publicar."})
continue 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)) title = (custom_data.get("titles") or {}).get(str(inv_id))
extra_attrs = (custom_data.get("attributes") 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"]) 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") shipping_cost = custom_data.get("shipping_cost")
payload = build_item_payload( 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, shipping_mode=shipping_mode, listing_type_id=listing_type_id,
custom_title=title, extra_attributes=extra_attrs, custom_title=title, extra_attributes=extra_attrs,
shipping_cost=shipping_cost, shipping_cost=shipping_cost,

View File

@@ -143,6 +143,66 @@ class MeliService:
) )
return resp.json() 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) ──────────────────────────────────────────────── # ─── Items (listings) ────────────────────────────────────────────────
def validate_item(self, payload: dict) -> dict: def validate_item(self, payload: dict) -> dict:

View File

@@ -1233,9 +1233,35 @@
.then(function(data) { .then(function(data) {
btn.disabled = false; btn.disabled = false;
if (data.error) { resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(data.error) + '</span>'; return; } if (data.error) { resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(data.error) + '</span>'; return; }
var valid = (data.valid || []).length; var valid = (data.valid || []);
var invalid = (data.invalid || []); var invalid = (data.invalid || []);
var html = '<div style="margin-bottom:var(--space-2);"><span style="color:var(--color-success);">✅ ' + valid + ' válido(s)</span> · <span style="color:var(--color-error);">❌ ' + invalid.length + ' inválido(s)</span></div>'; var html = '<div style="margin-bottom:var(--space-2);"><span style="color:var(--color-success);">✅ ' + valid.length + ' válido(s)</span> · <span style="color:var(--color-error);">❌ ' + invalid.length + ' inválido(s)</span></div>';
// Show earnings estimate table for valid items
if (valid.length > 0) {
html += '<div style="margin:var(--space-3) 0;border:1px solid var(--color-border);border-radius:var(--radius-md);overflow:hidden;">';
html += '<div style="background:var(--color-surface-2);padding:var(--space-2) var(--space-3);font-size:var(--text-caption);font-weight:600;color:var(--color-text-primary);">💰 Estimado de ganancia (después de comisiones ML)</div>';
html += '<table style="width:100%;border-collapse:collapse;font-size:var(--text-caption);">';
html += '<tr style="background:var(--color-surface-1);"><th style="padding:6px 8px;text-align:left;">Item</th><th style="padding:6px 8px;text-align:right;">Precio</th><th style="padding:6px 8px;text-align:right;">Comisión</th><th style="padding:6px 8px;text-align:right;">Recibes</th></tr>';
valid.forEach(function(v) {
var price = v.price || 0;
var fee = v.fee_amount || 0;
var net = v.net_amount || 0;
var feePct = v.fee_pct || 0;
html += '<tr style="border-top:1px solid var(--color-border);">';
html += '<td style="padding:6px 8px;">#' + esc(v.inventory_id) + '</td>';
html += '<td style="padding:6px 8px;text-align:right;">$' + price.toFixed(2) + '</td>';
html += '<td style="padding:6px 8px;text-align:right;color:var(--color-error);">-$' + fee.toFixed(2) + ' (' + feePct.toFixed(1) + '%)</td>';
html += '<td style="padding:6px 8px;text-align:right;color:var(--color-success);font-weight:600;">$' + net.toFixed(2) + '</td>';
html += '</tr>';
});
html += '</table>';
if (valid.some(function(v){ return v.fee_source === 'approximation'; })) {
html += '<div style="padding:var(--space-2) var(--space-3);font-size:10px;color:var(--color-text-muted);background:var(--color-surface-0);">* Comisión estimada. El monto real puede variar según ML.</div>';
}
html += '</div>';
}
if (invalid.length) { if (invalid.length) {
html += '<ul style="margin:0;padding-left:var(--space-4);font-size:var(--text-caption);color:var(--color-text-secondary);">'; html += '<ul style="margin:0;padding-left:var(--space-4);font-size:var(--text-caption);color:var(--color-text-secondary);">';
invalid.forEach(function(f) { invalid.forEach(function(f) {