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:
@@ -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>🖥</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">🔍</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">🚀</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">🖥</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">🌐</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>🖥 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>🖥 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">✉</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">📱</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">📍</div>
|
||||
|
||||
@@ -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,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>'; });
|
||||
};
|
||||
|
||||
|
||||
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