feat(sync-log): historial de sincronizaciones WhatsApp en sync_wa_log

- Nueva tabla sync_wa_log (total, created, skipped, updated, errores, origen, modo)
- Log guardado en /pacientes/sync-all, /terceros/send y /automation/run
- Ruta GET /pacientes/historial devuelve últimos 100 registros
- UI: tabla de historial con origen, modo, conteos y detalle de errores

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-09 11:13:28 -05:00
co-authored by Claude Sonnet 4.6
parent 1aab14f705
commit b0175a2ca5
6 changed files with 174 additions and 7 deletions
+15
View File
@@ -76,6 +76,21 @@ def init_db():
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS sync_wa_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
origen TEXT NOT NULL DEFAULT 'manual',
modo TEXT NOT NULL DEFAULT 'insertar',
total INTEGER NOT NULL DEFAULT 0,
created INTEGER NOT NULL DEFAULT 0,
skipped INTEGER NOT NULL DEFAULT 0,
updated INTEGER NOT NULL DEFAULT 0,
errores INTEGER NOT NULL DEFAULT 0,
errores_det TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
""")
conn.commit()
_migrate(conn)
+3 -4
View File
@@ -13,7 +13,7 @@ from app.services.json_generator import (
agrupar_por_recepcion,
)
from app.services.api_client import get_tns_token, TNS_BASE
from app.services.whatsapp_sync import sync_paciente
from app.services.whatsapp_sync import sync_paciente, sync_todos, guardar_sync_log
router = APIRouter(prefix="/automation", tags=["automation"])
@@ -266,9 +266,8 @@ async def run_automation(
if wa_url and wa_key and rows_pac:
ingest_url = f"{wa_url}/api/lab/ingest_paciente.php"
try:
async with httpx.AsyncClient(timeout=timeout) as wa_client:
for row in rows_pac:
await sync_paciente(row, ingest_url, wa_key, wa_client)
wa_resultado = await sync_todos(rows_pac, ingest_url, wa_key, timeout, modo="upsert")
guardar_sync_log(wa_resultado, user["user_id"], origen="automation", modo="upsert")
except Exception:
pass
+18 -1
View File
@@ -4,7 +4,7 @@ from fastapi.responses import JSONResponse
from app.auth import get_current_user
from app.database import get_connection
from app.services.firebird_service import get_firebird_from_config
from app.services.whatsapp_sync import sync_todos
from app.services.whatsapp_sync import sync_todos, guardar_sync_log
router = APIRouter(prefix="/pacientes", tags=["pacientes"])
@@ -118,6 +118,9 @@ async def sync_all(
timeout = int(configs.get("api_timeout", 30))
resultado = await sync_todos(rows, ingest_url, wa_key, timeout, modo="insertar")
guardar_sync_log(resultado, user["user_id"], origen="manual", modo="insertar")
resultado["success"] = True
resultado["message"] = (
f"{resultado['total']} procesados — "
@@ -126,3 +129,17 @@ async def sync_all(
f"{resultado['errores']} errores."
)
return JSONResponse(resultado)
@router.get("/historial")
async def historial(request: Request, user: dict = Depends(get_current_user)):
conn = get_connection()
rows = conn.execute("""
SELECT l.*, u.username
FROM sync_wa_log l
LEFT JOIN users u ON u.id = l.user_id
ORDER BY l.created_at DESC
LIMIT 100
""").fetchall()
conn.close()
return JSONResponse([dict(r) for r in rows])
+15 -2
View File
@@ -8,7 +8,7 @@ from app.auth import get_current_user
from app.services.firebird_service import get_firebird_from_config
from app.services.json_generator import generar_tercero_api
from app.services.api_client import get_tns_token, TNS_BASE
from app.services.whatsapp_sync import sync_paciente
from app.services.whatsapp_sync import sync_paciente, guardar_sync_log
router = APIRouter(prefix="/terceros", tags=["terceros"])
@@ -191,8 +191,21 @@ async def send_terceros(
if wa_url and wa_key and rows:
ingest_url = f"{wa_url}/api/lab/ingest_paciente.php"
try:
wa_result = await sync_paciente(rows[0], ingest_url, wa_key)
wa_result = await sync_paciente(rows[0], ingest_url, wa_key, modo="upsert")
wa_sync = {"ok": wa_result["ok"], "action": wa_result["action"]}
guardar_sync_log(
{
"total": 1,
"created": 1 if wa_result["action"] == "created" else 0,
"skipped": 1 if wa_result["action"] == "skipped" else 0,
"updated": 1 if wa_result["action"] == "updated" else 0,
"errores": 0 if wa_result["ok"] else 1,
"detalle": [] if wa_result["ok"] else [wa_result],
},
user["user_id"],
origen="tercero",
modo="upsert",
)
except Exception:
wa_sync = {"ok": False, "action": "error"}
+37
View File
@@ -4,10 +4,47 @@ Mapea campos Firebird al formato esperado por /api/lab/ingest_paciente.php.
"""
import re
import json
import httpx
from datetime import datetime
from typing import Optional
from app.database import get_connection
def guardar_sync_log(
resultado: dict,
user_id: int,
origen: str = "manual",
modo: str = "insertar",
) -> None:
errores_det = None
errores = [d for d in resultado.get("detalle", []) if not d.get("ok")]
if errores:
errores_det = json.dumps(
[{"doc": e["doc"], "nombre": e["nombre"], "msg": e["message"]} for e in errores],
ensure_ascii=False,
)
conn = get_connection()
conn.execute(
"""INSERT INTO sync_wa_log
(user_id, origen, modo, total, created, skipped, updated, errores, errores_det)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
user_id,
origen,
modo,
resultado.get("total", 0),
resultado.get("created", 0),
resultado.get("skipped", 0),
resultado.get("updated", 0),
resultado.get("errores", 0),
errores_det,
),
)
conn.commit()
conn.close()
_TIPO_DOC_MAP = {
"CC": "CC", "TI": "TI", "RC": "RC", "CE": "CE",
"PA": "PA", "NIT": "NIT", "MS": "MS", "AS": "CC",
+86
View File
@@ -73,6 +73,21 @@
</div>
</div>
<!-- Historial -->
<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">
<h3 class="font-semibold text-gray-800">
<i class="fas fa-history mr-2 text-gray-400"></i>Historial de sincronizaciones
</h3>
<button onclick="cargarHistorial()" class="text-sm text-blue-600 hover:underline">
<i class="fas fa-refresh mr-1"></i>Actualizar
</button>
</div>
<div id="historialContainer" class="p-4">
<p class="text-sm text-gray-400 text-center py-4">Cargando…</p>
</div>
</div>
<!-- Info sobre la API -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200">
@@ -187,5 +202,76 @@ function renderDetalle(detalle) {
</div>
</details>`;
}
const _origenLabel = { manual: 'Manual', tercero: 'Tercero', automation: 'Automatización' };
const _modoLabel = { insertar: 'Solo nuevos', upsert: 'Upsert' };
async function cargarHistorial() {
const el = document.getElementById('historialContainer');
el.innerHTML = '<p class="text-sm text-gray-400 text-center py-4">Cargando…</p>';
try {
const resp = await fetch('/pacientes/historial');
const rows = await resp.json();
if (!rows.length) {
el.innerHTML = '<p class="text-sm text-gray-400 text-center py-6">Sin registros aún.</p>';
return;
}
el.innerHTML = `
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs text-gray-500 border-b border-gray-100">
<th class="pb-2 pr-4 font-medium">Fecha</th>
<th class="pb-2 pr-4 font-medium">Origen</th>
<th class="pb-2 pr-4 font-medium">Modo</th>
<th class="pb-2 pr-3 font-medium text-right">Total</th>
<th class="pb-2 pr-3 font-medium text-right text-blue-600">Nuevos</th>
<th class="pb-2 pr-3 font-medium text-right text-gray-400">Omitidos</th>
<th class="pb-2 pr-3 font-medium text-right text-green-600">Actualizados</th>
<th class="pb-2 pr-3 font-medium text-right text-red-500">Errores</th>
<th class="pb-2 font-medium">Usuario</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
${rows.map(r => {
const errDet = r.errores_det ? JSON.parse(r.errores_det) : [];
const fecha = r.created_at.replace('T', ' ').slice(0, 16);
return `
<tr class="hover:bg-gray-50">
<td class="py-2 pr-4 text-gray-500 whitespace-nowrap font-mono text-xs">${fecha}</td>
<td class="py-2 pr-4">
<span class="px-2 py-0.5 rounded-full text-xs font-medium ${
r.origen === 'manual' ? 'bg-blue-50 text-blue-700' :
r.origen === 'tercero' ? 'bg-orange-50 text-orange-700' :
'bg-purple-50 text-purple-700'
}">${_origenLabel[r.origen] || r.origen}</span>
</td>
<td class="py-2 pr-4 text-gray-500 text-xs">${_modoLabel[r.modo] || r.modo}</td>
<td class="py-2 pr-3 text-right font-medium">${r.total}</td>
<td class="py-2 pr-3 text-right text-blue-600 font-medium">${r.created}</td>
<td class="py-2 pr-3 text-right text-gray-400">${r.skipped}</td>
<td class="py-2 pr-3 text-right text-green-600">${r.updated}</td>
<td class="py-2 pr-3 text-right ${r.errores > 0 ? 'text-red-500 font-medium' : 'text-gray-300'}">
${r.errores > 0 && errDet.length ? `
<details>
<summary class="cursor-pointer">${r.errores}</summary>
<div class="absolute z-10 bg-white border border-red-100 rounded-lg shadow-lg p-2 text-xs mt-1 max-w-xs">
${errDet.map(e => `<div class="text-red-600">${e.doc}${e.msg}</div>`).join('')}
</div>
</details>` : r.errores || '—'}
</td>
<td class="py-2 text-gray-400 text-xs">${r.username || '—'}</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>`;
} catch (e) {
el.innerHTML = `<p class="text-sm text-red-500 text-center py-4">Error: ${e.message}</p>`;
}
}
// Cargar historial al abrir la página
cargarHistorial();
</script>
{% endblock %}