diff --git a/pos/services/marketplace_external_service.py b/pos/services/marketplace_external_service.py index fe4d69d..c4f43da 100644 --- a/pos/services/marketplace_external_service.py +++ b/pos/services/marketplace_external_service.py @@ -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, diff --git a/pos/services/meli_service.py b/pos/services/meli_service.py index 3ac3c5e..15b4fee 100644 --- a/pos/services/meli_service.py +++ b/pos/services/meli_service.py @@ -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: diff --git a/pos/static/js/inventory.js b/pos/static/js/inventory.js index 9167154..75b66bb 100644 --- a/pos/static/js/inventory.js +++ b/pos/static/js/inventory.js @@ -1233,9 +1233,35 @@ .then(function(data) { btn.disabled = false; if (data.error) { resultEl.innerHTML = 'Error: ' + esc(data.error) + ''; return; } - var valid = (data.valid || []).length; + var valid = (data.valid || []); var invalid = (data.invalid || []); - var html = '