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:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1233,9 +1233,35 @@
|
||||
.then(function(data) {
|
||||
btn.disabled = false;
|
||||
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 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) {
|
||||
html += '<ul style="margin:0;padding-left:var(--space-4);font-size:var(--text-caption);color:var(--color-text-secondary);">';
|
||||
invalid.forEach(function(f) {
|
||||
|
||||
Reference in New Issue
Block a user