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:
2026-05-26 04:37:05 +00:00
parent 4866823ba9
commit b314a781a1
8 changed files with 706 additions and 108 deletions

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nexus Autoparts — Sistema completo para refaccionarias</title>
<meta name="description" content="POS + Catalogo TecDoc 1.5M+ partes + Marketplace B2B + IA. Todo lo que necesita una refaccionaria en una sola plataforma.">
<meta name="description" content="POS + Catalogo 1.5M+ partes + IA + Venta en linea. Todo lo que necesita una refaccionaria en una sola plataforma.">
<script>
(function(){
var t = localStorage.getItem('nexus-theme') || 'industrial';
@@ -41,7 +41,7 @@
<canvas id="heroCanvas"></canvas>
<div class="hero-content">
<h1 class="nx-reveal">Nexus Autoparts</h1>
<p class="subtitle nx-reveal">Todo lo que necesita una refaccionaria en una sola plataforma. POS, inventario, catalogo TecDoc, facturacion, marketplace B2B e inteligencia artificial.</p>
<p class="subtitle nx-reveal">Todo lo que necesita una refaccionaria en una sola plataforma. POS, inventario, catalogo de partes, facturacion, venta en linea e inteligencia artificial.</p>
<div class="typewriter-line nx-reveal">
<span id="typewriterText"></span><span class="typewriter-cursor"></span>
</div>
@@ -78,59 +78,46 @@
<section class="product">
<div class="container">
<h2 class="section-title nx-reveal">El Producto</h2>
<p class="section-subtitle nx-reveal">El unico sistema que combina POS + Inventario + CFDI + Catalogo + Marketplace + IA en una sola plataforma</p>
<p class="section-subtitle nx-reveal">Las 3 funcionalidades principales que hacen crecer tu refaccionaria</p>
<div class="product-grid nx-stagger">
<div class="product-card product-card--orange nx-reveal">
<h3>Ventas & POS</h3>
<h3>Catalogo Completo + POS + Inventario</h3>
<ul>
<li>Punto de venta completo con F-keys y escaner</li>
<li>Caja registradora multi-caja, cortes X/Z</li>
<li>Cotizaciones, apartados, devoluciones</li>
<li>Clientes con credito y 3 niveles de precio</li>
<li>Facturacion CFDI 4.0 (Ingreso, Egreso, Pago)</li>
<li>Impresion termica ESC/POS</li>
<li>Contabilidad con polizas automaticas</li>
<li>Reportes: ventas, ABC, cortes, utilidad</li>
<li>Catalogo completo: 1.5M+ partes OEM y 304K+ aftermarket</li>
<li>Punto de venta completo con escaner y teclas rapidas</li>
<li>Inventario append-only con toma fisica y alertas de stock</li>
<li>Navegacion por vehiculo: Marca > Modelo > Ano > Motor</li>
<li>Decodificador VIN + busqueda por placas MX</li>
<li>Facturacion CFDI 4.0 integrada</li>
</ul>
</div>
<div class="product-card product-card--cyan nx-reveal">
<h3>Catalogo & Inventario</h3>
<h3>Agente AI para WhatsApp</h3>
<ul>
<li>Catalogo TecDoc: 1.5M+ partes OEM</li>
<li>304K+ partes aftermarket con cross-refs</li>
<li>Navegacion: Ano > Marca > Modelo > Motor</li>
<li>VIN decoder + busqueda por placas MX</li>
<li>Inventario append-only, toma fisica</li>
<li>Imagenes de productos con upload masivo</li>
<li>Traduccion automatica EN > ES (326 partes)</li>
<li>Marketplace B2B: bodegas ↔ talleres</li>
<li>Atiende consultas de autopartes 24/7 automaticamente</li>
<li>Genera cotizaciones inteligentes desde la conversacion</li>
<li>Reconoce piezas por foto con Vision AI</li>
<li>Transcripcion de notas de voz a texto</li>
<li>Envia catalogos y cotizaciones directo al cliente</li>
<li>Reduce llamadas y aumenta conversiones</li>
</ul>
</div>
<div class="product-card product-card--green nx-reveal">
<h3>IA & Plataforma</h3>
<h3>Vinculacion con Mercado Libre</h3>
<ul>
<li>Chatbot IA: diagnostico, cotizacion inteligente</li>
<li>Entrada por voz (Web Speech API)</li>
<li>Reconocimiento de partes por foto (Vision AI)</li>
<li>WhatsApp Business integrado (envio de cotizaciones)</li>
<li>Gestion de flotillas y mantenimiento</li>
<li>PWA + App Android, modo kiosko</li>
<li>Offline-first con sync automatico</li>
<li>2 temas, 2 idiomas (ES/EN), 2 monedas (MXN/USD)</li>
<li>Publica tu inventario en Mercado Libre en minutos</li>
<li>Sincronizacion automatica de stock y precios</li>
<li>Descarga ordenes y conviertelas en ventas del POS</li>
<li>Gestiona listados, preguntas y ventas desde un solo lugar</li>
<li>Empieza a vender en linea sin complicaciones</li>
<li>Mas canales, mas ventas, mismo inventario</li>
</ul>
</div>
</div>
<div class="hw-banner nx-reveal">
<div class="hw-banner-inner">
<span>&#128421;</span>
<div class="hw-text">A partir del plan <strong>Pro</strong>: servidor en <strong>rack 3D personalizado</strong> — Mini PC + switch + AP + UPS.<br>Todo incluido por <strong>$2,000 MXN/mes</strong>. Solo conectar y empezar a vender.</div>
</div>
</div>
</div>
</section>
@@ -152,12 +139,12 @@
<div class="step nx-reveal">
<div class="step-number">2</div>
<h3>Catalogo + Inventario</h3>
<p>Tu inventario conectado al catalogo TecDoc. Busca por vehiculo, parte o VIN.</p>
<p>Tu inventario conectado al catalogo de partes. Busca por vehiculo, parte o VIN.</p>
</div>
<div class="step nx-reveal">
<div class="step-number">3</div>
<h3>Vende y Crece</h3>
<p>POS, facturacion, marketplace B2B, WhatsApp e IA — todo desde un solo lugar.</p>
<p>POS, facturacion, venta en linea, WhatsApp e IA — todo desde un solo lugar.</p>
</div>
</div>
</div>
@@ -176,7 +163,7 @@
<div class="diff-grid nx-stagger">
<div class="diff-card nx-reveal">
<div class="diff-icon">&#128269;</div>
<h4>Catalogo TecDoc</h4>
<h4>Catalogo Completo</h4>
<p>1.5M+ partes con cross-references. Nadie mas lo tiene en MX.</p>
</div>
<div class="diff-card nx-reveal">
@@ -191,13 +178,13 @@
</div>
<div class="diff-card nx-reveal">
<div class="diff-icon">&#128640;</div>
<h4>Marketplace B2B</h4>
<p>Conecta bodegas con talleres. Mas ventas, menos llamadas.</p>
<h4>Venta en Linea</h4>
<p>Conecta tu inventario con Mercado Libre y vende 24/7.</p>
</div>
<div class="diff-card nx-reveal">
<div class="diff-icon">&#128421;</div>
<h4>Hardware incluido</h4>
<p>Rack 3D con servidor. Renta todo por $2,000/mes.</p>
<h4>Hardware opcional</h4>
<p>Mini rack 3D con servidor. Disponible como add-on.</p>
</div>
<div class="diff-card nx-reveal">
<div class="diff-icon">&#127760;</div>
@@ -227,41 +214,46 @@
<section class="pricing">
<div class="container">
<h2 class="section-title nx-reveal">Planes</h2>
<p class="section-subtitle nx-reveal">Software desde $999/mes. Hardware incluido a partir del plan Pro.</p>
<p class="section-subtitle nx-reveal">Elige el plan que se ajuste a tu refaccionaria. Paga anual y ahorra 2 meses.</p>
<div class="pricing-grid nx-stagger">
<div class="pricing-card nx-reveal">
<h4>Basico</h4>
<div class="pricing-price">$999</div>
<div class="pricing-period">MXN / mes — solo software</div>
<h4>POS Basico</h4>
<div class="pricing-price">$650</div>
<div class="pricing-period">MXN / mes</div>
<ul>
<li>POS + Inventario</li>
<li>Catalogo TecDoc</li>
<li>CFDI 4.0</li>
<li>Punto de venta completo</li>
<li>Inventario y catalogo de partes</li>
<li>Facturacion CFDI 4.0</li>
<li>Reportes basicos</li>
</ul>
</div>
<div class="pricing-card featured nx-reveal">
<h4>Pro</h4>
<div class="pricing-price">$2,000</div>
<div class="pricing-period">MXN / mes — hardware incluido</div>
<h4>Sistema Completo</h4>
<div class="pricing-price">$1,660</div>
<div class="pricing-period">MXN / mes</div>
<ul>
<li>Todo Basico +</li>
<li>Todo lo del POS Basico +</li>
<li>Agente AI para WhatsApp</li>
<li>Vinculacion con Mercado Libre</li>
<li>Sync automatico de stock y ordenes</li>
<li>Contabilidad automatica</li>
<li>Chatbot IA + WhatsApp</li>
<li>Marketplace B2B</li>
<li>&#128421; Mini PC + rack 3D + red incluidos</li>
<li>Multi-sucursal y flotillas</li>
</ul>
</div>
<div class="pricing-card nx-reveal">
<h4>Enterprise</h4>
<div class="pricing-price">$3,999</div>
<div class="pricing-period">MXN / mes — hardware incluido</div>
</div>
<div class="pricing-note nx-reveal" style="text-align:center; margin-top:var(--space-6); font-size:var(--text-body-sm); color:var(--color-text-secondary);">
<p><strong>Paga anual y ahorra 2 meses.</strong> Aplica a meses sin intereses (MSI).</p>
</div>
<div class="pricing-grid nx-stagger" style="margin-top:var(--space-8);">
<div class="pricing-card nx-reveal" style="grid-column: 1 / -1; max-width: 600px; margin: 0 auto;">
<h4>Add-on: Mini Rack con Servidor</h4>
<div class="pricing-price">$3,000</div>
<div class="pricing-period">MXN / mes</div>
<ul>
<li>Todo Pro +</li>
<li>Flotillas + Multi-bodega</li>
<li>API dedicada</li>
<li>Soporte prioritario</li>
<li>&#128421; Hardware dedicado por sucursal</li>
<li>Mini PC con POS preinstalado</li>
<li>Switch + Access Point + UPS</li>
<li>Rack 3D personalizado</li>
<li>Solo conectar y empezar a vender</li>
</ul>
</div>
</div>
@@ -281,12 +273,12 @@
<div class="contact-card nx-reveal">
<div class="contact-icon">&#9993;</div>
<h4>Email</h4>
<a href="mailto:ialcarazsalazar@consultoria-as.com">ialcarazsalazar@consultoria-as.com</a>
<a href="mailto:ivan@nexusautoparts.com.mx">ivan@nexusautoparts.com.mx</a>
</div>
<div class="contact-card nx-reveal">
<div class="contact-icon">&#128241;</div>
<h4>WhatsApp</h4>
<a href="https://wa.me/526641234567" class="btn-whatsapp" target="_blank" rel="noopener">Enviar Mensaje</a>
<a href="https://wa.me/526642170990" class="btn-whatsapp" target="_blank" rel="noopener">Enviar Mensaje</a>
</div>
<div class="contact-card nx-reveal">
<div class="contact-icon">&#128205;</div>

View File

@@ -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):

View File

@@ -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:

View File

@@ -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)

View File

@@ -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;

View File

@@ -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,16 +808,79 @@
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 = [];
@@ -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,29 +993,67 @@
}
});
window.executeMeliPublish = function() {
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('meliPublishBtn');
var btn = document.getElementById('meliValidateBtn');
btn.disabled = true;
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Publicando ' + ids.length + ' producto(s)...</span>';
fetch('/pos/api/marketplace-ext/listings', {
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
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;
@@ -935,8 +1070,71 @@
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; }
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('meliPublishBtn');
btn.disabled = true;
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,
custom_data: _collectMeliCustomData()
})
}).then(function(r) { return r.json(); })
.then(function(data) {
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>'; });
};

View File

@@ -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()

View File

@@ -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>