feat(envios/erp): add manual sync trigger endpoint and test UI

- POST /envios/erp/sync-now?ventana_min=N calls sync_recientes with configurable window
- sync_recientes() now returns a result dict (ok, total, created, updated, skipped, errores, detalle)
- ERP tab shows a "Prueba manual" card with ventana selector (2m / 30m / 2h / hoy) and result breakdown

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-16 21:40:54 -05:00
co-authored by Claude Sonnet 4.6
parent 8e27225326
commit dc18d128fb
3 changed files with 124 additions and 14 deletions
+12 -1
View File
@@ -1,6 +1,8 @@
from fastapi import APIRouter, Request, Depends
from fastapi import APIRouter, Request, Depends, Query
from fastapi.responses import JSONResponse
from app.auth import get_current_user
from app.database import get_connection
from app.services.scheduler import sync_recientes
router = APIRouter(prefix="/envios", tags=["envios"])
@@ -24,3 +26,12 @@ async def envios_erp(request: Request, user: dict = Depends(get_current_user)):
"wa_configurado": bool(wa_url and wa_key),
"wa_url": wa_url,
})
@router.post("/erp/sync-now")
async def erp_sync_now(
ventana_min: int = Query(default=30, ge=1, le=1440),
user: dict = Depends(get_current_user),
):
resultado = await sync_recientes(ventana_min=ventana_min)
return JSONResponse(resultado)
+27 -13
View File
@@ -76,8 +76,8 @@ def _parse_hora(val) -> datetime | None:
return None
async def sync_recientes():
"""Job que corre cada minuto: sincroniza pacientes con recepción en los últimos 2 min."""
async def sync_recientes(ventana_min: int = 2) -> dict:
"""Job que corre cada minuto: sincroniza pacientes con recepción en los últimos `ventana_min` min."""
conn = get_connection()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
@@ -85,29 +85,31 @@ async def sync_recientes():
wa_url = configs.get("whatsapp_url", "").rstrip("/")
wa_key = configs.get("whatsapp_api_key", "")
if not wa_url or not wa_key:
return
return {"ok": False, "error": "whatsapp_url o whatsapp_api_key no configurados"}
fb, ok, _ = get_firebird_from_config(configs)
fb, ok, msg = get_firebird_from_config(configs)
if not ok:
return
return {"ok": False, "error": f"Firebird: {msg}"}
ok_pac, _, rows_pac = fb.execute_query(_SQL_HOY, None)
ok_ex, _, rows_ex = fb.execute_query(_SQL_EXAMENES_HOY, None)
ok_pac, err_pac, rows_pac = fb.execute_query(_SQL_HOY, None)
ok_ex, _, rows_ex = fb.execute_query(_SQL_EXAMENES_HOY, None)
fb.disconnect()
if not ok_pac or not rows_pac:
return
if not ok_pac:
return {"ok": False, "error": f"Query pacientes: {err_pac}"}
limite = datetime.now() - timedelta(minutes=2)
total_hoy = len(rows_pac) if rows_pac else 0
limite = datetime.now() - timedelta(minutes=ventana_min)
recientes = [
row for row in rows_pac
row for row in (rows_pac or [])
if (h := _parse_hora(row.get("HORAINICIORECEPCION"))) is None or h >= limite
]
if not recientes:
return
return {"ok": True, "total": 0, "created": 0, "updated": 0,
"skipped": 0, "errores": 0, "total_hoy": total_hoy,
"ventana_min": ventana_min, "mensaje": "Sin recepciones en la ventana de tiempo"}
# Construir mapa DOCIDENT → lista de exámenes filtrados por la misma ventana de 2 min
examenes_map: dict[str, list] = {}
if ok_ex and rows_ex:
for ex in rows_ex:
@@ -138,3 +140,15 @@ async def sync_recientes():
if resultado["total"] > 0:
guardar_sync_log(resultado, 0, origen="scheduler", modo="upsert")
return {
"ok": True,
"total": resultado["total"],
"created": resultado["created"],
"updated": resultado["updated"],
"skipped": resultado["skipped"],
"errores": resultado["errores"],
"total_hoy": total_hoy,
"ventana_min": ventana_min,
"detalle": resultado.get("detalle", []),
}
+85
View File
@@ -78,6 +78,31 @@
</div>
</div>
<!-- Prueba manual -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="font-semibold text-gray-800">
<i class="fas fa-play-circle mr-2 text-blue-500"></i>Prueba manual
</h3>
</div>
<div class="p-5 flex flex-wrap items-center gap-4">
<select id="sel-ventana"
class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-300">
<option value="2">Últimos 2 minutos (igual al scheduler)</option>
<option value="30" selected>Últimos 30 minutos</option>
<option value="120">Últimas 2 horas</option>
<option value="1440">Hoy completo</option>
</select>
<button id="btn-sync-now" onclick="ejecutarSyncNow()"
class="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
<i class="fas fa-bolt text-xs"></i> Ejecutar ahora
</button>
</div>
<div id="sync-now-result" class="hidden px-5 pb-5">
<div id="sync-now-inner"></div>
</div>
</div>
<!-- Historial de sync automático -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
@@ -133,6 +158,66 @@ function switchTab(name) {
const _origenLabel = { manual: 'Manual', tercero: 'Tercero', scheduler: 'Scheduler', automation: 'Automatización' };
const _modoLabel = { insertar: 'Solo nuevos', upsert: 'Upsert' };
async function ejecutarSyncNow() {
const btn = document.getElementById('btn-sync-now');
const ventana = document.getElementById('sel-ventana').value;
const resBox = document.getElementById('sync-now-result');
const inner = document.getElementById('sync-now-inner');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin text-xs"></i> Sincronizando…';
resBox.classList.remove('hidden');
inner.innerHTML = '<p class="text-sm text-gray-400">Ejecutando…</p>';
try {
const resp = await fetch(`/envios/erp/sync-now?ventana_min=${ventana}`, { method: 'POST' });
const d = await resp.json();
if (!d.ok) {
inner.innerHTML = `
<div class="flex items-center gap-2 text-red-600 bg-red-50 border border-red-200 rounded-lg px-4 py-3 text-sm">
<i class="fas fa-exclamation-circle"></i>
<span>${d.error || 'Error desconocido'}</span>
</div>`;
} else if (d.total === 0) {
inner.innerHTML = `
<div class="flex items-center gap-2 text-gray-500 bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-sm">
<i class="fas fa-info-circle text-blue-400"></i>
<span>${d.mensaje || 'Sin recepciones en la ventana seleccionada.'} (Hoy: <strong>${d.total_hoy}</strong> recepción/es registradas)</span>
</div>`;
} else {
const detalles = (d.detalle || []).map(p => {
const a = p.action || (p.ok ? 'updated' : 'error');
const badge = a === 'created' ? '<span class="text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">Nuevo</span>'
: a === 'updated' ? '<span class="text-xs bg-green-100 text-green-700 px-1.5 py-0.5 rounded">Actualizado</span>'
: a === 'skipped' ? '<span class="text-xs bg-gray-100 text-gray-500 px-1.5 py-0.5 rounded">Omitido</span>'
: `<span class="text-xs bg-red-100 text-red-600 px-1.5 py-0.5 rounded" title="${p.message || ''}">${a}</span>`;
return `<div class="flex items-center justify-between py-1 border-b border-gray-50 text-xs">
<span class="text-gray-700">${p.nombre || p.doc || '—'}</span>
${badge}
</div>`;
}).join('');
inner.innerHTML = `
<div class="bg-green-50 border border-green-200 rounded-lg px-4 py-3 mb-3">
<div class="flex flex-wrap gap-4 text-sm">
<span class="text-green-700 font-semibold"><i class="fas fa-check-circle mr-1"></i>${d.total} procesados</span>
<span class="text-blue-600">${d.created} nuevos</span>
<span class="text-green-600">${d.updated} actualizados</span>
<span class="text-gray-400">${d.skipped} omitidos</span>
${d.errores > 0 ? `<span class="text-red-500 font-medium">${d.errores} errores</span>` : ''}
<span class="text-gray-400 ml-auto text-xs">Hoy: ${d.total_hoy} recepciones</span>
</div>
</div>
${detalles ? `<div class="max-h-48 overflow-y-auto pr-1">${detalles}</div>` : ''}`;
}
} catch (e) {
inner.innerHTML = `<p class="text-sm text-red-500">Error de red: ${e.message}</p>`;
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-bolt text-xs"></i> Ejecutar ahora';
}
}
async function cargarHistorialScheduler() {
const el = document.getElementById('scheduler-hist');
el.innerHTML = '<p class="text-sm text-gray-400 text-center py-4">Cargando…</p>';