feat: store and display per-patient sync detail in historial

- Add detalle_json column to sync_wa_log via migration
- guardar_sync_log() now stores doc, nombre, action, examenes count per patient
- Historial table shows "Ver N" button that expands a row with patient list,
  action badge (Nuevo/Actualizado/Omitido) and exam count per patient

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-17 09:33:46 -05:00
co-authored by Claude Sonnet 4.6
parent 29b6aa4016
commit e044f21e3e
3 changed files with 61 additions and 15 deletions
+4
View File
@@ -14,6 +14,10 @@ def get_connection():
def _migrate(conn):
sync_cols = {r[1] for r in conn.execute("PRAGMA table_info(sync_wa_log)")}
if "detalle_json" not in sync_cols:
conn.execute("ALTER TABLE sync_wa_log ADD COLUMN detalle_json TEXT")
cols = {r[1] for r in conn.execute("PRAGMA table_info(envios)")}
if "idrecepcion" not in cols:
conn.execute("ALTER TABLE envios ADD COLUMN idrecepcion INTEGER")
+17 -4
View File
@@ -14,22 +14,34 @@ from app.database import get_connection
def guardar_sync_log(
resultado: dict,
user_id: int,
user_id,
origen: str = "manual",
modo: str = "insertar",
) -> None:
detalle_raw = resultado.get("detalle", [])
errores_det = None
errores = [d for d in resultado.get("detalle", []) if not d.get("ok")]
errores = [d for d in detalle_raw 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,
)
detalle_json = None
if detalle_raw:
detalle_json = json.dumps(
[{"doc": d["doc"], "nombre": d["nombre"], "action": d.get("action", ""),
"examenes": d.get("examenes_guardados", 0)}
for d in detalle_raw],
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, total, created, skipped, updated, errores, errores_det, detalle_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
user_id,
origen,
@@ -40,6 +52,7 @@ def guardar_sync_log(
resultado.get("updated", 0),
resultado.get("errores", 0),
errores_det,
detalle_json,
),
)
conn.commit()
+40 -11
View File
@@ -158,6 +158,11 @@ function switchTab(name) {
const _origenLabel = { manual: 'Manual', tercero: 'Tercero', scheduler: 'Scheduler', automation: 'Automatización' };
const _modoLabel = { insertar: 'Solo nuevos', upsert: 'Upsert' };
function toggleDet(id) {
const row = document.getElementById(id);
if (row) row.classList.toggle('hidden');
}
async function ejecutarSyncNow() {
const btn = document.getElementById('btn-sync-now');
const ventana = document.getElementById('sel-ventana').value;
@@ -235,34 +240,58 @@ async function cargarHistorialScheduler() {
<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-3 font-medium">Usuario</th>
<th class="pb-2 pr-3 font-medium text-right">Procesados</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 text-right">Detalle</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
${sched.map(r => {
${sched.map((r, i) => {
const fecha = r.created_at.replace('T', ' ').slice(0, 16);
const errDet = r.errores_det ? JSON.parse(r.errores_det) : [];
const pacientes = r.detalle_json ? JSON.parse(r.detalle_json) : [];
const rowId = 'hist-det-' + i;
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-3 text-xs text-gray-400">${r.username || '—'}</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 || '—'}
${r.errores > 0 && errDet.length
? errDet.map(e => `<div class="text-red-600 text-xs">${e.doc}${e.msg}</div>`).join('')
: r.errores || '—'}
</td>
</tr>`;
<td class="py-2 text-right">
${pacientes.length ? `
<button onclick="toggleDet('${rowId}')"
class="text-xs text-blue-500 hover:underline">Ver ${pacientes.length}</button>` : '—'}
</td>
</tr>
${pacientes.length ? `
<tr id="${rowId}" class="hidden bg-gray-50">
<td colspan="7" class="px-4 pb-3 pt-1">
<div class="text-xs text-gray-500 space-y-0.5 max-h-48 overflow-y-auto">
${pacientes.map(p => {
const badge = p.action === 'created' ? '<span class="text-blue-600">Nuevo</span>'
: p.action === 'updated' ? '<span class="text-green-600">Actualizado</span>'
: p.action === 'skipped' ? '<span class="text-gray-400">Omitido</span>'
: `<span class="text-red-500">${p.action}</span>`;
const exBadge = p.examenes > 0
? `<span class="text-purple-500">${p.examenes} exám.</span>` : '';
return `<div class="flex items-center gap-3 py-0.5 border-b border-gray-100">
<span class="text-gray-400 w-28 shrink-0">${p.doc}</span>
<span class="flex-1 truncate">${p.nombre}</span>
${badge} ${exBadge}
</div>`;
}).join('')}
</div>
</td>
</tr>` : ''}`;
}).join('')}
</tbody>
</table>