feat: robust ML publish with pre-flight, preview, validation, async
- Add /inventory-check endpoint for local pre-flight validation - Add /listings/validate endpoint using ML /items/validate API - Add /categories/<id>/attributes endpoint for required attrs - Add /listings/async + polling for background publishing via Celery - Editable preview: title (0/60 counter), price, stock per item - Pre-flight checks: image, stock, price, duplicate detection - Image upload directly from publish modal (uses existing /items/<id>/image) - Dynamic required attributes form based on selected ML category - Frontend: validate button, async polling with progress, detailed error display - Backend: build_item_payload supports custom_title, extra_attributes
This commit is contained in:
@@ -183,6 +183,7 @@ def create_listings():
|
||||
category_id = data.get("category_id")
|
||||
listing_type = data.get("listing_type", "gold_special")
|
||||
shipping_mode = data.get("shipping_mode", "me2")
|
||||
custom_data = data.get("custom_data", {})
|
||||
|
||||
if not inventory_ids:
|
||||
return jsonify({"error": "inventory_ids required"}), 400
|
||||
@@ -197,6 +198,7 @@ def create_listings():
|
||||
meli_category_id=category_id,
|
||||
listing_type_id=listing_type,
|
||||
shipping_mode=shipping_mode,
|
||||
custom_data=custom_data,
|
||||
)
|
||||
return jsonify(result), 201
|
||||
except ValueError as e:
|
||||
@@ -207,6 +209,134 @@ def create_listings():
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/inventory-check", methods=["POST"])
|
||||
@require_auth()
|
||||
def inventory_check():
|
||||
"""Check local pre-flight status for ML publishing (duplicates, stock, price, image)."""
|
||||
data = request.get_json() or {}
|
||||
inventory_ids = data.get("inventory_ids", [])
|
||||
if not inventory_ids:
|
||||
return jsonify({"error": "inventory_ids required"}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.check_inventory_ml_status(conn, inventory_ids)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/categories/<category_id>/attributes", methods=["GET"])
|
||||
@require_auth()
|
||||
def category_attributes(category_id):
|
||||
"""Get required attributes for a MercadoLibre category."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cfg = meli_svc.get_meli_config(conn)
|
||||
svc = meli_svc._get_meli_service(cfg)
|
||||
if not svc:
|
||||
return jsonify({"error": "MercadoLibre not connected"}), 400
|
||||
attrs = svc.get_category_attributes(category_id)
|
||||
# Filter to required attributes only for the UI
|
||||
required = [a for a in attrs if a.get("tags", {}).get("required")]
|
||||
return jsonify({"attributes": required, "all": attrs})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/listings/validate", methods=["POST"])
|
||||
@require_auth()
|
||||
def validate_listings():
|
||||
"""Validate items payload against ML /items/validate without creating them."""
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json() or {}
|
||||
inventory_ids = data.get("inventory_ids", [])
|
||||
category_id = data.get("category_id")
|
||||
listing_type = data.get("listing_type", "gold_special")
|
||||
shipping_mode = data.get("shipping_mode", "me2")
|
||||
custom_data = data.get("custom_data", {})
|
||||
|
||||
if not inventory_ids:
|
||||
return jsonify({"error": "inventory_ids required"}), 400
|
||||
if not category_id:
|
||||
return jsonify({"error": "category_id required"}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.validate_items(
|
||||
conn,
|
||||
inventory_ids=inventory_ids,
|
||||
meli_category_id=category_id,
|
||||
listing_type_id=listing_type,
|
||||
shipping_mode=shipping_mode,
|
||||
custom_data=custom_data,
|
||||
)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/listings/async", methods=["POST"])
|
||||
@require_auth()
|
||||
def create_listings_async():
|
||||
"""Enqueue ML publishing as a Celery background task."""
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json() or {}
|
||||
inventory_ids = data.get("inventory_ids", [])
|
||||
category_id = data.get("category_id")
|
||||
listing_type = data.get("listing_type", "gold_special")
|
||||
shipping_mode = data.get("shipping_mode", "me2")
|
||||
custom_data = data.get("custom_data", {})
|
||||
|
||||
if not inventory_ids:
|
||||
return jsonify({"error": "inventory_ids required"}), 400
|
||||
if not category_id:
|
||||
return jsonify({"error": "category_id required"}), 400
|
||||
|
||||
try:
|
||||
from tasks import publish_meli_items_task
|
||||
task = publish_meli_items_task.delay(
|
||||
g.tenant_id,
|
||||
inventory_ids=inventory_ids,
|
||||
category_id=category_id,
|
||||
listing_type=listing_type,
|
||||
shipping_mode=shipping_mode,
|
||||
custom_data=custom_data,
|
||||
)
|
||||
return jsonify({"task_id": task.id, "status": "queued"}), 202
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/listings/async/<task_id>", methods=["GET"])
|
||||
@require_auth()
|
||||
def get_async_listing_status(task_id):
|
||||
"""Get status of an async ML publishing task."""
|
||||
try:
|
||||
from celery.result import AsyncResult
|
||||
from app import celery as celery_app
|
||||
result = AsyncResult(task_id, app=celery_app)
|
||||
if result.ready():
|
||||
return jsonify({"status": "done", "result": result.result or {}})
|
||||
return jsonify({"status": "pending"})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/listings/<int:listing_id>/sync", methods=["POST"])
|
||||
@require_auth()
|
||||
def sync_listing(listing_id):
|
||||
|
||||
@@ -134,9 +134,11 @@ def build_item_payload(
|
||||
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 = f"{inventory_row['name']} {inventory_row['brand'] or ''} {inventory_row['part_number'] or ''}".strip()
|
||||
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] + "..."
|
||||
@@ -185,6 +187,12 @@ def build_item_payload(
|
||||
{"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
|
||||
|
||||
|
||||
@@ -192,12 +200,169 @@ def build_item_payload(
|
||||
# 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")
|
||||
|
||||
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.
|
||||
|
||||
@@ -225,6 +390,7 @@ def publish_items(
|
||||
# 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:
|
||||
@@ -257,15 +423,20 @@ def publish_items(
|
||||
images = []
|
||||
if inv.get("image_url"):
|
||||
images.append(inv["image_url"])
|
||||
# TODO: fetch additional images from a separate table if we have gallery support
|
||||
|
||||
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, inv["price_1"], stock,
|
||||
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:
|
||||
|
||||
@@ -145,6 +145,10 @@ class MeliService:
|
||||
|
||||
# ─── Items (listings) ────────────────────────────────────────────────
|
||||
|
||||
def validate_item(self, payload: dict) -> dict:
|
||||
"""Validate an item payload without creating it."""
|
||||
return self._request("POST", "/items/validate", json_payload=payload)
|
||||
|
||||
def create_item(self, payload: dict) -> dict:
|
||||
return self._request("POST", "/items", json_payload=payload)
|
||||
|
||||
|
||||
@@ -1333,6 +1333,73 @@
|
||||
/* History table inside modal */
|
||||
.inv-modal .data-table { width: 100%; }
|
||||
|
||||
/* ─── MercadoLibre Publish Modal Enhancements ────────────────────────── */
|
||||
.meli-preview-card {
|
||||
display: grid;
|
||||
grid-template-columns: 56px 1fr auto auto auto;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-2);
|
||||
background: var(--color-surface-1);
|
||||
}
|
||||
.meli-preview-card img {
|
||||
width: 56px; height: 56px; object-fit: cover; border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
.meli-preview-card .meli-title-input {
|
||||
width: 100%;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-primary);
|
||||
padding: 4px 8px;
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
.meli-preview-card .meli-num-input {
|
||||
width: 80px;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-primary);
|
||||
padding: 4px 8px;
|
||||
font-size: var(--text-caption);
|
||||
text-align: right;
|
||||
}
|
||||
.meli-check { font-size: var(--text-caption); display: flex; align-items: center; gap: 4px; }
|
||||
.meli-check.ok { color: var(--color-success); }
|
||||
.meli-check.fail { color: var(--color-error); }
|
||||
.meli-checks-row {
|
||||
display: flex; gap: var(--space-3); flex-wrap: wrap; margin-top: var(--space-1);
|
||||
}
|
||||
.meli-attrs-section {
|
||||
margin-top: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-1);
|
||||
}
|
||||
.meli-attrs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.meli-img-upload {
|
||||
border: 2px dashed var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
.meli-img-upload:hover { border-color: var(--color-primary); }
|
||||
.meli-img-upload input { display: none; }
|
||||
|
||||
/* ─── MercadoLibre Category Autocomplete ─────────────────────────────── */
|
||||
.meli-cat-dropdown {
|
||||
position: absolute;
|
||||
|
||||
@@ -784,6 +784,9 @@
|
||||
|
||||
// ─── MercadoLibre Bulk Publish Modal ───────────────────────────────────
|
||||
|
||||
var meliPreviewData = {};
|
||||
var meliCategoryAttrs = [];
|
||||
|
||||
window.openMeliPublishModal = function() {
|
||||
if (selectedItems.size === 0) { showToast('Selecciona al menos un producto', 'warn'); return; }
|
||||
document.getElementById('meliPublishModal').classList.add('is-open');
|
||||
@@ -791,6 +794,9 @@
|
||||
document.getElementById('meliCategoryId').value = '';
|
||||
document.getElementById('meliCategorySearch').value = '';
|
||||
document.getElementById('meliCategoryResults').innerHTML = '';
|
||||
document.getElementById('meliAttrsSection').style.display = 'none';
|
||||
document.getElementById('meliAttrsGrid').innerHTML = '';
|
||||
meliCategoryAttrs = [];
|
||||
refreshMeliPublishPreview();
|
||||
};
|
||||
|
||||
@@ -802,17 +808,80 @@
|
||||
var container = document.getElementById('meliPublishItemsPreview');
|
||||
var countEl = document.getElementById('meliPublishSelectedCount');
|
||||
countEl.textContent = selectedItems.size + ' producto(s) seleccionado(s)';
|
||||
if (!inventoryVS || !inventoryVS.data) { container.innerHTML = '<p style="color:var(--color-text-muted);">Sin datos</p>'; return; }
|
||||
var items = inventoryVS.data.filter(function(it) { return selectedItems.has(it.id); });
|
||||
if (!items.length) { container.innerHTML = '<p style="color:var(--color-text-muted);">Ninguno</p>'; return; }
|
||||
var html = '<table class="data-table" style="font-size:var(--text-caption);"><thead><tr><th>ID</th><th>No. Parte</th><th>Nombre</th><th>Stock</th><th style="text-align:right">Precio</th></tr></thead><tbody>';
|
||||
items.forEach(function(it) {
|
||||
html += '<tr><td>' + it.id + '</td><td class="td--mono">' + esc(it.part_number) + '</td><td>' + esc(it.name) + '</td><td>' + it.stock + '</td><td style="text-align:right">$' + fmt(it.price_1) + '</td></tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
container.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Cargando verificaciones...</p>';
|
||||
|
||||
var ids = Array.from(selectedItems);
|
||||
fetch('/pos/api/marketplace-ext/inventory-check', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ inventory_ids: ids })
|
||||
}).then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.items) { container.innerHTML = '<p style="color:var(--color-error);">Error cargando preview</p>'; return; }
|
||||
var html = '';
|
||||
data.items.forEach(function(it) {
|
||||
if (!it.exists) {
|
||||
html += '<div class="meli-preview-card" style="opacity:0.6;"><div style="color:var(--color-error);">Item #' + it.inventory_id + ' no encontrado</div></div>';
|
||||
return;
|
||||
}
|
||||
meliPreviewData[it.inventory_id] = it;
|
||||
var checks = '';
|
||||
checks += '<span class="meli-check ' + (it.has_image ? 'ok' : 'fail') + '">' + (it.has_image ? '✅' : '❌') + ' Imagen</span>';
|
||||
checks += '<span class="meli-check ' + (it.has_stock ? 'ok' : 'fail') + '">' + (it.has_stock ? '✅' : '❌') + ' Stock</span>';
|
||||
checks += '<span class="meli-check ' + (it.has_price ? 'ok' : 'fail') + '">' + (it.has_price ? '✅' : '❌') + ' Precio</span>';
|
||||
if (it.already_published) {
|
||||
checks += '<span class="meli-check ok">✅ <a href="' + esc(it.existing_listing.permalink || '#') + '" target="_blank" style="color:var(--color-success);text-decoration:underline;">Ya publicado (' + esc(it.existing_listing.status) + ')</a></span>';
|
||||
}
|
||||
var imgSrc = it.image_url || '';
|
||||
var imgHtml = imgSrc ? '<img src="' + esc(imgSrc) + '" alt="" onerror="this.style.display=\'none\'">' : '<div style="width:56px;height:56px;background:var(--color-surface-2);border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;font-size:10px;color:var(--color-text-muted);">Sin img</div>';
|
||||
if (!it.has_image) {
|
||||
imgHtml = '<div class="meli-img-upload" onclick="document.getElementById(\'meliImgUpload-' + it.inventory_id + '\').click()">' +
|
||||
'<div style="font-size:10px;">+ Subir</div>' +
|
||||
'<input type="file" id="meliImgUpload-' + it.inventory_id + '" accept="image/*" onchange="handleMeliImageUpload(' + it.inventory_id + ', this)">' +
|
||||
'</div>';
|
||||
}
|
||||
html += '<div class="meli-preview-card" id="meliCard-' + it.inventory_id + '">' +
|
||||
imgHtml +
|
||||
'<div>' +
|
||||
'<input type="text" class="meli-title-input" id="meliTitle-' + it.inventory_id + '" value="' + esc(it.title) + '" maxlength="60" oninput="updateMeliTitleCount(' + it.inventory_id + ')">' +
|
||||
'<div style="font-size:10px;color:var(--color-text-muted);text-align:right;" id="meliTitleCount-' + it.inventory_id + '">' + it.title.length + '/60</div>' +
|
||||
'<div class="meli-checks-row">' + checks + '</div>' +
|
||||
'</div>' +
|
||||
'<div><label style="font-size:10px;color:var(--color-text-muted);">Precio</label><input type="number" class="meli-num-input" id="meliPrice-' + it.inventory_id + '" value="' + it.price + '"></div>' +
|
||||
'<div><label style="font-size:10px;color:var(--color-text-muted);">Stock</label><input type="number" class="meli-num-input" id="meliStock-' + it.inventory_id + '" value="' + it.stock + '"></div>' +
|
||||
'</div>';
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}).catch(function() { container.innerHTML = '<p style="color:var(--color-error);">Error de red</p>'; });
|
||||
}
|
||||
|
||||
window.updateMeliTitleCount = function(id) {
|
||||
var el = document.getElementById('meliTitle-' + id);
|
||||
var countEl = document.getElementById('meliTitleCount-' + id);
|
||||
if (el && countEl) countEl.textContent = el.value.length + '/60';
|
||||
};
|
||||
|
||||
window.handleMeliImageUpload = function(itemId, input) {
|
||||
if (!input.files || !input.files[0]) return;
|
||||
var file = input.files[0];
|
||||
var formData = new FormData();
|
||||
formData.append('image', file);
|
||||
fetch('/pos/api/inventory/items/' + itemId + '/image', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token },
|
||||
body: formData
|
||||
}).then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.image_url) {
|
||||
showToast('Imagen subida', 'success');
|
||||
refreshMeliPublishPreview();
|
||||
if (inventoryVS) inventoryVS.refresh();
|
||||
} else {
|
||||
showToast(data.error || 'Error subiendo imagen', 'error');
|
||||
}
|
||||
}).catch(function(){ showToast('Error de red', 'error'); });
|
||||
};
|
||||
|
||||
var meliCategorySearchTimeout;
|
||||
var meliCatItems = [];
|
||||
var meliCatActiveIndex = -1;
|
||||
@@ -863,6 +932,34 @@
|
||||
document.getElementById('meliCategoryResults').innerHTML = '';
|
||||
meliCatItems = [];
|
||||
meliCatActiveIndex = -1;
|
||||
loadCategoryAttributes(id);
|
||||
};
|
||||
|
||||
window.loadCategoryAttributes = function(categoryId) {
|
||||
var grid = document.getElementById('meliAttrsGrid');
|
||||
var section = document.getElementById('meliAttrsSection');
|
||||
grid.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Cargando atributos...</p>';
|
||||
section.style.display = 'block';
|
||||
fetch('/pos/api/marketplace-ext/categories/' + encodeURIComponent(categoryId) + '/attributes', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
meliCategoryAttrs = data.attributes || [];
|
||||
if (!meliCategoryAttrs.length) { grid.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">No hay atributos obligatorios adicionales.</p>'; return; }
|
||||
var html = '';
|
||||
meliCategoryAttrs.forEach(function(attr) {
|
||||
var attrId = esc(attr.id);
|
||||
var attrName = esc(attr.name || attr.id);
|
||||
var inputHtml = '<input type="text" class="meli-title-input" id="meliAttr-' + attrId + '" placeholder="' + attrName + '">';
|
||||
if (attr.values && attr.values.length) {
|
||||
inputHtml = '<select class="meli-title-input" id="meliAttr-' + attrId + '">' +
|
||||
'<option value="">Selecciona ' + attrName + '</option>' +
|
||||
attr.values.map(function(v) { return '<option value="' + esc(v.name) + '">' + esc(v.name) + '</option>'; }).join('') +
|
||||
'</select>';
|
||||
}
|
||||
html += '<div class="inv-field"><label>' + attrName + (attr.tags && attr.tags.required ? ' *' : '') + '</label>' + inputHtml + '</div>';
|
||||
});
|
||||
grid.innerHTML = html;
|
||||
}).catch(function() { grid.innerHTML = '<p style="color:var(--color-error);font-size:var(--text-caption);">Error cargando atributos</p>'; });
|
||||
};
|
||||
|
||||
window.handleMeliCatKeydown = function(e) {
|
||||
@@ -896,6 +993,114 @@
|
||||
}
|
||||
});
|
||||
|
||||
function _collectMeliCustomData() {
|
||||
var ids = Array.from(selectedItems);
|
||||
var customData = { titles: {}, prices: {}, stocks: {}, attributes: {} };
|
||||
ids.forEach(function(id) {
|
||||
var titleEl = document.getElementById('meliTitle-' + id);
|
||||
var priceEl = document.getElementById('meliPrice-' + id);
|
||||
var stockEl = document.getElementById('meliStock-' + id);
|
||||
if (titleEl) customData.titles[id] = titleEl.value;
|
||||
if (priceEl) customData.prices[id] = parseFloat(priceEl.value);
|
||||
if (stockEl) customData.stocks[id] = parseInt(stockEl.value);
|
||||
var attrs = [];
|
||||
meliCategoryAttrs.forEach(function(attr) {
|
||||
var el = document.getElementById('meliAttr-' + attr.id);
|
||||
if (el && el.value) {
|
||||
attrs.push({ id: attr.id, value_name: el.value });
|
||||
}
|
||||
});
|
||||
if (attrs.length) customData.attributes[id] = attrs;
|
||||
});
|
||||
return customData;
|
||||
}
|
||||
|
||||
window.validateMeliPublish = function() {
|
||||
var categoryId = document.getElementById('meliCategoryId').value.trim();
|
||||
if (!categoryId) { document.getElementById('meliPublishResult').innerHTML = '<span style="color:var(--color-error);">Selecciona una categoría de MercadoLibre</span>'; return; }
|
||||
var listingType = document.getElementById('meliListingType').value;
|
||||
var shippingMode = document.getElementById('meliShippingMode').value;
|
||||
var ids = Array.from(selectedItems);
|
||||
var resultEl = document.getElementById('meliPublishResult');
|
||||
var btn = document.getElementById('meliValidateBtn');
|
||||
btn.disabled = true;
|
||||
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Validando con MercadoLibre...</span>';
|
||||
fetch('/pos/api/marketplace-ext/listings/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
inventory_ids: ids,
|
||||
category_id: categoryId,
|
||||
listing_type: listingType,
|
||||
shipping_mode: shippingMode,
|
||||
custom_data: _collectMeliCustomData()
|
||||
})
|
||||
}).then(function(r){ return r.json(); })
|
||||
.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 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>';
|
||||
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) {
|
||||
html += '<li>Item #' + esc(f.inventory_id) + ': ' + esc(f.error) + '</li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
resultEl.innerHTML = html;
|
||||
}).catch(function(e) { btn.disabled = false; resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(e.message) + '</span>'; });
|
||||
};
|
||||
|
||||
function _renderPublishResult(data, resultEl) {
|
||||
var success = (data.success || []).length;
|
||||
var failedList = data.failed || [];
|
||||
var failed = failedList.length;
|
||||
var html = '<div style="margin-bottom:var(--space-2);"><span style="color:var(--color-success);">✅ ' + success + ' publicado(s)</span> · <span style="color:var(--color-error);">❌ ' + failed + ' fallo(s)</span></div>';
|
||||
if (failedList.length) {
|
||||
html += '<ul style="margin:0;padding-left:var(--space-4);font-size:var(--text-caption);color:var(--color-text-secondary);">';
|
||||
failedList.forEach(function(f) {
|
||||
html += '<li>Item #' + esc(f.inventory_id) + ': ' + esc(f.error) + '</li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
resultEl.innerHTML = html;
|
||||
if (success > 0) {
|
||||
selectedItems.clear();
|
||||
updateSelectionUI();
|
||||
if (inventoryVS) inventoryVS.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function _pollMeliAsync(taskId, resultEl, btn) {
|
||||
var attempts = 0;
|
||||
var maxAttempts = 60; // 2 min
|
||||
var interval = setInterval(function() {
|
||||
attempts++;
|
||||
fetch('/pos/api/marketplace-ext/listings/async/' + encodeURIComponent(taskId), { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.status === 'done') {
|
||||
clearInterval(interval);
|
||||
btn.disabled = false;
|
||||
_renderPublishResult(data.result || {}, resultEl);
|
||||
setTimeout(function() { closeMeliPublishModal(); }, 3000);
|
||||
} else if (attempts >= maxAttempts) {
|
||||
clearInterval(interval);
|
||||
btn.disabled = false;
|
||||
resultEl.innerHTML = '<span style="color:var(--color-error);">Timeout esperando resultado. Revisa la pestaña Publicaciones más tarde.</span>';
|
||||
} else {
|
||||
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Publicando en segundo plano... (' + attempts + 's)</span>';
|
||||
}
|
||||
}).catch(function() {
|
||||
clearInterval(interval);
|
||||
btn.disabled = false;
|
||||
resultEl.innerHTML = '<span style="color:var(--color-error);">Error consultando progreso</span>';
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
window.executeMeliPublish = function() {
|
||||
var categoryId = document.getElementById('meliCategoryId').value.trim();
|
||||
if (!categoryId) { document.getElementById('meliPublishResult').innerHTML = '<span style="color:var(--color-error);">Selecciona una categoría de MercadoLibre</span>'; return; }
|
||||
@@ -905,37 +1110,30 @@
|
||||
var resultEl = document.getElementById('meliPublishResult');
|
||||
var btn = document.getElementById('meliPublishBtn');
|
||||
btn.disabled = true;
|
||||
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Publicando ' + ids.length + ' producto(s)...</span>';
|
||||
fetch('/pos/api/marketplace-ext/listings', {
|
||||
var useAsync = ids.length > 3;
|
||||
var endpoint = useAsync ? '/pos/api/marketplace-ext/listings/async' : '/pos/api/marketplace-ext/listings';
|
||||
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">' + (useAsync ? 'Encolando ' : 'Publicando ') + ids.length + ' producto(s)...</span>';
|
||||
fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
inventory_ids: ids,
|
||||
category_id: categoryId,
|
||||
listing_type: listingType,
|
||||
shipping_mode: shippingMode
|
||||
shipping_mode: shippingMode,
|
||||
custom_data: _collectMeliCustomData()
|
||||
})
|
||||
}).then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
btn.disabled = false;
|
||||
if (data.error) { resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(data.error) + '</span>'; return; }
|
||||
var success = (data.success || []).length;
|
||||
var failedList = data.failed || [];
|
||||
var failed = failedList.length;
|
||||
var html = '<div style="margin-bottom:var(--space-2);"><span style="color:var(--color-success);">✅ ' + success + ' publicado(s)</span> · <span style="color:var(--color-error);">❌ ' + failed + ' fallo(s)</span></div>';
|
||||
if (failedList.length) {
|
||||
html += '<ul style="margin:0;padding-left:var(--space-4);font-size:var(--text-caption);color:var(--color-text-secondary);">';
|
||||
failedList.forEach(function(f) {
|
||||
html += '<li>Item #' + esc(f.inventory_id) + ': ' + esc(f.error) + '</li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
resultEl.innerHTML = html;
|
||||
if (success > 0) {
|
||||
selectedItems.clear();
|
||||
updateSelectionUI();
|
||||
if (inventoryVS) inventoryVS.refresh();
|
||||
setTimeout(function() { closeMeliPublishModal(); }, 2500);
|
||||
if (data.error) { btn.disabled = false; resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(data.error) + '</span>'; return; }
|
||||
if (useAsync && data.task_id) {
|
||||
_pollMeliAsync(data.task_id, resultEl, btn);
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
_renderPublishResult(data, resultEl);
|
||||
if ((data.success || []).length > 0) {
|
||||
setTimeout(function() { closeMeliPublishModal(); }, 2500);
|
||||
}
|
||||
}
|
||||
}).catch(function(e) { btn.disabled = false; resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(e.message) + '</span>'; });
|
||||
};
|
||||
|
||||
23
pos/tasks.py
23
pos/tasks.py
@@ -267,3 +267,26 @@ def sync_vehicle_compatibility_task(self, tenant_id, item_id, part_number, name,
|
||||
tenant_conn.close()
|
||||
if master_conn:
|
||||
master_conn.close()
|
||||
|
||||
|
||||
@celery.task(bind=True)
|
||||
def publish_meli_items_task(self, tenant_id: int, inventory_ids: list, category_id: str, listing_type: str = "gold_special", shipping_mode: str = "me2", custom_data: dict = None):
|
||||
"""Publish inventory items to MercadoLibre asynchronously."""
|
||||
from services import marketplace_external_service as meli_svc
|
||||
from tenant_db import get_tenant_conn
|
||||
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
try:
|
||||
result = meli_svc.publish_items(
|
||||
conn,
|
||||
inventory_ids=inventory_ids,
|
||||
meli_category_id=category_id,
|
||||
listing_type_id=listing_type,
|
||||
shipping_mode=shipping_mode,
|
||||
custom_data=custom_data or {},
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"success": [], "failed": [{"inventory_id": "batch", "error": str(e)}]}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||
<meta name="theme-color" content="#F5A623" />
|
||||
|
||||
<link rel="stylesheet" href="/pos/static/css/inventory.css?v=4">
|
||||
<link rel="stylesheet" href="/pos/static/css/inventory.css?v=6">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -846,9 +846,12 @@
|
||||
</div>
|
||||
<div class="inv-modal__body">
|
||||
<div id="meliPublishSelectedCount" style="font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:var(--space-3);">0 productos seleccionados</div>
|
||||
<div id="meliPublishItemsPreview" style="max-height:200px;overflow-y:auto;border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-3);margin-bottom:var(--space-4);">
|
||||
<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Selecciona productos del inventario para ver el preview.</p>
|
||||
|
||||
<!-- Pre-flight checks & editable preview -->
|
||||
<div id="meliPublishItemsPreview" style="max-height:320px;overflow-y:auto;margin-bottom:var(--space-4);">
|
||||
<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Cargando preview...</p>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-bottom:var(--space-4);">
|
||||
<div class="inv-field">
|
||||
<label>Categoría ML *</label>
|
||||
@@ -874,10 +877,20 @@
|
||||
<small style="color:var(--color-text-muted);font-size:var(--text-caption);">Tu cuenta requiere ME2 obligatoriamente.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div id="meliPublishResult" style="min-height:1.5em;"></div>
|
||||
|
||||
<!-- Dynamic required attributes section -->
|
||||
<div id="meliAttrsSection" style="display:none;">
|
||||
<div class="meli-attrs-section">
|
||||
<strong style="font-size:var(--text-body-sm);">Atributos requeridos para esta categoría</strong>
|
||||
<div id="meliAttrsGrid" class="meli-attrs-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="meliPublishResult" style="min-height:1.5em;margin-top:var(--space-3);"></div>
|
||||
</div>
|
||||
<div class="inv-modal__footer">
|
||||
<button class="btn btn--ghost" onclick="closeMeliPublishModal()">Cancelar</button>
|
||||
<button class="btn btn--secondary" id="meliValidateBtn" onclick="validateMeliPublish()">Validar con ML</button>
|
||||
<button class="btn btn--meli" id="meliPublishBtn" onclick="executeMeliPublish()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg> Publicar</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -895,7 +908,7 @@
|
||||
<script src="/pos/static/js/pos-utils.js" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||
<script src="/pos/static/js/virtual-scroll.js" defer></script>
|
||||
<script src="/pos/static/js/inventory.js?v=10" defer></script>
|
||||
<script src="/pos/static/js/inventory.js?v=12" defer></script>
|
||||
<script src="/pos/static/js/offline-banner.js" defer></script>
|
||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||
|
||||
Reference in New Issue
Block a user