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:
co-authored by
Claude Sonnet 4.6
parent
8e27225326
commit
dc18d128fb
+12
-1
@@ -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
@@ -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", []),
|
||||
}
|
||||
|
||||
@@ -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>';
|
||||
|
||||
Reference in New Issue
Block a user