- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos - F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado - F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment - F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings) - F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant - Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas - Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant) - Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant - Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
343 lines
12 KiB
Python
343 lines
12 KiB
Python
"""Celery tasks for Nexus POS background jobs."""
|
|
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
# Ensure pos/ is on path for imports when Celery worker runs standalone
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
from celery_app import celery
|
|
import psycopg2
|
|
import redis as redis_lib
|
|
|
|
MASTER_DB_URL = os.environ.get('MASTER_DB_URL', 'postgresql://postgres@/nexus_autoparts')
|
|
REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
|
|
|
|
|
|
def _get_db():
|
|
return psycopg2.connect(MASTER_DB_URL)
|
|
|
|
|
|
def _get_redis():
|
|
return redis_lib.from_url(REDIS_URL, decode_responses=True)
|
|
|
|
|
|
@celery.task(bind=True, max_retries=3)
|
|
def warm_vehicle_cache_task(self, batch_size=5000, ttl=3600):
|
|
"""Warm Redis cache for vehicle info from part_vehicle_preview."""
|
|
conn = _get_db()
|
|
cur = conn.cursor()
|
|
r = _get_redis()
|
|
r.ping()
|
|
|
|
cur.execute("SELECT id_part FROM parts WHERE oem_part_number IS NOT NULL ORDER BY id_part")
|
|
all_ids = [row[0] for row in cur.fetchall()]
|
|
total = len(all_ids)
|
|
|
|
processed = 0
|
|
cached = 0
|
|
start = time.time()
|
|
|
|
for i in range(0, total, batch_size):
|
|
batch = all_ids[i:i + batch_size]
|
|
cur.execute("""
|
|
SELECT part_id, name_brand, name_model, year_car
|
|
FROM part_vehicle_preview
|
|
WHERE part_id = ANY(%s)
|
|
""", (batch,))
|
|
|
|
pipe = r.pipeline()
|
|
batch_cached = 0
|
|
for row in cur.fetchall():
|
|
info = f"{row[1]} {row[2]} {row[3]}"
|
|
pipe.setex(f'nexus:vehicle:{row[0]}', ttl, info)
|
|
batch_cached += 1
|
|
pipe.execute()
|
|
|
|
processed += len(batch)
|
|
cached += batch_cached
|
|
self.update_state(
|
|
state='PROGRESS',
|
|
meta={'current': processed, 'total': total, 'cached': cached}
|
|
)
|
|
|
|
cur.close()
|
|
conn.close()
|
|
elapsed = time.time() - start
|
|
return {'total': total, 'cached': cached, 'elapsed': int(elapsed)}
|
|
|
|
|
|
@celery.task(bind=True, max_retries=2)
|
|
def bulk_import_inventory_task(self, csv_path, tenant_id, branch_id=None):
|
|
"""Bulk import inventory from CSV in background."""
|
|
from services.inventory_engine import bulk_import_csv
|
|
conn = _get_db()
|
|
try:
|
|
result = bulk_import_csv(conn, csv_path, tenant_id, branch_id)
|
|
conn.commit()
|
|
return result
|
|
except Exception as exc:
|
|
conn.rollback()
|
|
raise self.retry(exc=exc)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@celery.task(bind=True, max_retries=1)
|
|
def generate_report_task(self, report_type, params, tenant_id):
|
|
"""Generate heavy reports asynchronously."""
|
|
# Placeholder: implement actual report generation per type
|
|
self.update_state(state='PROGRESS', meta={'step': 'collecting_data'})
|
|
time.sleep(2) # Simulate work
|
|
return {
|
|
'report_type': report_type,
|
|
'tenant_id': tenant_id,
|
|
'status': 'completed',
|
|
'url': f'/pos/static/reports/{report_type}_{tenant_id}.pdf',
|
|
}
|
|
|
|
|
|
# ─── MercadoLibre Tasks ───────────────────────────────────────────────────
|
|
|
|
@celery.task(bind=True, max_retries=3)
|
|
def sync_meli_stock_price_task(self, tenant_id: int):
|
|
"""Sync stock and price for all active ML listings.
|
|
|
|
Opt-in per tenant via tenant_config.meli_auto_sync_stock_price. Without it
|
|
the task is a no-op: pushing the local POS price/quantity to ML zeroes and
|
|
pauses any listing whose local product has no stock, which destroys sales
|
|
for tenants whose inventory is not (yet) loaded. Enable the flag only once
|
|
the tenant's stock is trustworthy.
|
|
"""
|
|
from services import marketplace_external_service as meli_svc
|
|
from tenant_db import get_tenant_conn
|
|
|
|
conn = get_tenant_conn(tenant_id)
|
|
try:
|
|
cfg = meli_svc.get_meli_config(conn)
|
|
if not cfg.get('meli_auto_sync_stock_price'):
|
|
return {'skipped': True, 'reason': 'meli_auto_sync_stock_price disabled'}
|
|
# Pass conn so rotated OAuth tokens are persisted back to tenant_config
|
|
svc = meli_svc._get_meli_service(cfg, conn)
|
|
if not svc:
|
|
return {'error': 'MercadoLibre not configured'}
|
|
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"""
|
|
SELECT id, inventory_id, external_item_id, publish_price
|
|
FROM marketplace_listings
|
|
WHERE is_active = true AND channel = 'mercadolibre'
|
|
"""
|
|
)
|
|
listings = cur.fetchall()
|
|
cur.close()
|
|
|
|
from services.inventory_engine import get_stock_bulk
|
|
stock_map = get_stock_bulk(conn, branch_id=None)
|
|
|
|
updated = 0
|
|
failed = 0
|
|
skipped = 0
|
|
for row in listings:
|
|
listing_id, inv_id, ext_id, last_price = row
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT price_1 FROM inventory WHERE id = %s", (inv_id,))
|
|
price_row = cur.fetchone()
|
|
cur.close()
|
|
current_price = float(price_row[0]) if price_row and price_row[0] else 0
|
|
current_stock = stock_map.get(inv_id, 0)
|
|
|
|
# Never push price <= 0: ML rejects it and it would blank out the
|
|
# live publication. This also covers listings not linked to any
|
|
# local inventory (inventory_id NULL), where there is no reliable
|
|
# stock either — skip them instead of pushing quantity 0.
|
|
if current_price <= 0:
|
|
skipped += 1
|
|
continue
|
|
|
|
try:
|
|
svc.update_item(
|
|
ext_id,
|
|
{"price": round(current_price, 2), "available_quantity": max(int(current_stock or 0), 0)},
|
|
)
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"""
|
|
UPDATE marketplace_listings
|
|
SET last_sync_at = NOW(), sync_errors = NULL, publish_price = %s
|
|
WHERE id = %s
|
|
""",
|
|
(current_price, listing_id),
|
|
)
|
|
conn.commit()
|
|
cur.close()
|
|
updated += 1
|
|
except Exception as e:
|
|
conn.rollback()
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"UPDATE marketplace_listings SET sync_errors = %s WHERE id = %s",
|
|
(str(e)[:500], listing_id),
|
|
)
|
|
conn.commit()
|
|
cur.close()
|
|
failed += 1
|
|
|
|
# Drain the stock-sync queue so pause/reactivate-by-stock takes effect.
|
|
# record_operation() enqueues here whenever a listed product's stock
|
|
# moves; process_meli_sync_queue pauses items at 0 stock and reactivates
|
|
# them once stock returns. Best-effort: never fail the main sync.
|
|
queued = 0
|
|
try:
|
|
qres = meli_svc.process_meli_sync_queue(conn, batch_size=100)
|
|
if isinstance(qres, dict):
|
|
queued = qres.get('processed', 0)
|
|
except Exception:
|
|
pass
|
|
|
|
return {'updated': updated, 'failed': failed, 'skipped': skipped,
|
|
'total': len(listings), 'queued': queued}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@celery.task(bind=True, max_retries=3)
|
|
def sync_meli_orders_task(self, tenant_id: int):
|
|
"""Fetch new orders from MercadoLibre."""
|
|
from services import marketplace_external_service as meli_svc
|
|
from tenant_db import get_tenant_conn
|
|
|
|
conn = get_tenant_conn(tenant_id)
|
|
try:
|
|
# Determine last check date from most recent order
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"SELECT MAX(created_at) FROM marketplace_orders WHERE channel = 'mercadolibre'"
|
|
)
|
|
row = cur.fetchone()
|
|
last_check = row[0]
|
|
cur.close()
|
|
|
|
date_from = None
|
|
if last_check:
|
|
date_from = last_check.strftime('%Y-%m-%dT%H:%M:%S.000-00:00')
|
|
|
|
result = meli_svc.fetch_and_save_orders(conn, date_from=date_from)
|
|
return result
|
|
except Exception as e:
|
|
return {'error': str(e)}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@celery.task(bind=True, max_retries=3)
|
|
def process_meli_webhook_task(self, tenant_id: int, topic: str, resource: str):
|
|
"""Process a single MercadoLibre webhook notification.
|
|
|
|
Handles the concrete resource notified by ML (order / question / item)
|
|
instead of triggering a full sync.
|
|
"""
|
|
from services import marketplace_external_service as meli_svc
|
|
from tenant_db import get_tenant_conn
|
|
|
|
conn = get_tenant_conn(tenant_id)
|
|
try:
|
|
resource_id = (resource or "").rstrip("/").split("/")[-1]
|
|
if not resource_id:
|
|
return {'ok': True, 'topic': topic, 'skipped': 'no resource'}
|
|
|
|
if topic.startswith("orders"):
|
|
result = meli_svc.fetch_and_save_order(conn, resource_id)
|
|
return {'ok': True, 'topic': topic, **result}
|
|
|
|
if topic.startswith("questions"):
|
|
meli_svc.fetch_question_from_ml(conn, resource_id)
|
|
return {'ok': True, 'topic': topic, 'question_id': resource_id}
|
|
|
|
if topic.startswith("items"):
|
|
result = meli_svc.sync_listing_from_meli(conn, resource_id)
|
|
return {'ok': True, 'topic': topic, **result}
|
|
|
|
return {'ok': True, 'topic': topic}
|
|
except Exception as e:
|
|
return {'error': str(e)}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@celery.task(bind=True, max_retries=2)
|
|
def sync_vehicle_compatibility_task(self, tenant_id, item_id, part_number, name, brand, compat_source):
|
|
"""Fetch AI/TecDoc vehicle compatibility in background after item creation."""
|
|
from tenant_db import get_tenant_conn, get_master_conn
|
|
from services.inventory_vehicle_compat import auto_match_vehicle_compatibility, save_qwen_fitment
|
|
from services.qwen_fitment import get_vehicle_fitment
|
|
|
|
tenant_conn = None
|
|
master_conn = None
|
|
try:
|
|
tenant_conn = get_tenant_conn(tenant_id)
|
|
|
|
if compat_source in ('tecdoc', 'both'):
|
|
try:
|
|
master_conn = get_master_conn()
|
|
auto_match_vehicle_compatibility(
|
|
master_conn, tenant_conn, item_id, part_number,
|
|
brand=brand, name=name
|
|
)
|
|
master_conn.close()
|
|
master_conn = None
|
|
except Exception as am_err:
|
|
print(f"[sync_vehicle_compat] TecDoc error for item {item_id}: {am_err}")
|
|
|
|
if compat_source in ('qwen', 'both'):
|
|
try:
|
|
fitment = get_vehicle_fitment(part_number, name, brand or '')
|
|
save_qwen_fitment(tenant_conn, item_id, fitment)
|
|
except Exception as qwen_err:
|
|
print(f"[sync_vehicle_compat] QWEN error for item {item_id}: {qwen_err}")
|
|
|
|
tenant_conn.commit()
|
|
return {'status': 'ok', 'item_id': item_id, 'tenant_id': tenant_id}
|
|
except Exception as exc:
|
|
if tenant_conn:
|
|
tenant_conn.rollback()
|
|
raise self.retry(exc=exc, countdown=10)
|
|
finally:
|
|
if tenant_conn:
|
|
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, base_url: str = None, chunk_size: int = 10, use_llm_description: bool = False):
|
|
"""Publish inventory items to MercadoLibre asynchronously.
|
|
|
|
Publishes in chunks of `chunk_size` with a short pause between them to
|
|
stay clear of ML's rate limit (429) instead of firing the whole batch at
|
|
once. Set chunk_size=0 to disable pacing.
|
|
"""
|
|
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 {},
|
|
base_url=base_url,
|
|
use_llm_description=use_llm_description,
|
|
chunk_size=chunk_size,
|
|
)
|
|
return result
|
|
except Exception as e:
|
|
return {"success": [], "failed": [{"inventory_id": "batch", "error": str(e)}]}
|
|
finally:
|
|
conn.close()
|