feat: add Resumen Diario TNS page as submodule

Groups envios by factura+tipo per day and shows estado_final
(exitoso if any send succeeded, error persistente if all failed).
Supports inline reenvío and JSON detail modal. Added to sidebar
under Historial TNS and as TNS submodule link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-26 17:59:42 -05:00
co-authored by Claude Sonnet 4.6
parent 3859095f17
commit ab2ef167f9
3 changed files with 336 additions and 2 deletions
+55 -1
View File
@@ -1,5 +1,5 @@
import json as json_lib
from datetime import datetime
from datetime import datetime, date as date_cls
import httpx
from fastapi import APIRouter, Request, Depends
from fastapi.responses import JSONResponse
@@ -199,6 +199,60 @@ async def reenviar_envio(envio_id: int, request: Request, user: dict = Depends(g
return JSONResponse({"success": new_status != "error", "message": msg})
@router.get("/resumen")
async def resumen_page(
request: Request,
user: dict = Depends(get_current_user),
fecha: str = "",
tipo: str = "",
):
if not fecha:
fecha = date_cls.today().isoformat()
conn = get_connection()
where_extra = ""
params_q: list = [fecha]
if tipo:
where_extra = "AND tipo = ?"
params_q.append(tipo)
rows = conn.execute(f"""
SELECT
factura, tipo, contrato, cedula, idrecepcion,
COUNT(*) AS intentos,
SUM(CASE WHEN status != 'error' THEN 1 ELSE 0 END) AS exitos,
MIN(created_at) AS primer_envio,
MAX(created_at) AS ultimo_envio,
MAX(CASE WHEN status = 'error' THEN id END) AS ultimo_error_id,
MAX(CASE WHEN status != 'error' THEN id END) AS exitoso_id,
CASE WHEN SUM(CASE WHEN status != 'error' THEN 1 ELSE 0 END) > 0
THEN MAX(CASE WHEN status != 'error' THEN status END)
ELSE 'error'
END AS estado_final
FROM envios
WHERE DATE(created_at) = ? {where_extra}
GROUP BY factura, tipo, contrato, cedula, idrecepcion
ORDER BY estado_final, tipo, factura
""", params_q).fetchall()
conn.close()
total = len(rows)
exitosos = sum(1 for r in rows if r["estado_final"] != "error")
errores = sum(1 for r in rows if r["estado_final"] == "error")
return request.app.state.templates.TemplateResponse("resumen.html", {
"request": request, "user": user,
"fecha": fecha,
"filtro_tipo": tipo,
"rows": rows,
"total": total,
"exitosos": exitosos,
"errores": errores,
})
@router.get("/detalle/{envio_id}")
async def detalle_envio(envio_id: int, request: Request, user: dict = Depends(get_current_user)):
conn = get_connection()
+7 -1
View File
@@ -61,10 +61,13 @@
<span class="flex-1">TNS</span>
<span class="text-xs px-1.5 py-0.5 rounded {% if cur == '/envios/tns' %}bg-blue-500 text-blue-100{% else %}bg-gray-700 text-gray-400{% endif %}">5</span>
</a>
{% if cur == '/envios/tns' %}
{% if cur in ('/envios/tns', '/logs/resumen') %}
<div class="ml-4 border-l border-gray-700 pl-3">
<p class="text-xs text-gray-500 py-0.5">Terceros · Transacción · Ventas</p>
<p class="text-xs text-gray-500 py-0.5">Prueba RDA · Automatización</p>
<a href="/logs/resumen" class="flex items-center py-0.5 text-xs {% if cur == '/logs/resumen' %}text-blue-400{% else %}text-gray-500 hover:text-gray-300{% endif %}">
<i class="fas fa-calendar-check mr-1.5 text-[10px]"></i> Resumen Diario
</a>
</div>
{% endif %}
@@ -84,6 +87,9 @@
<a href="/logs" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/logs' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
<i class="fas fa-history w-5 mr-2"></i> Historial TNS
</a>
<a href="/logs/resumen" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/logs/resumen' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
<i class="fas fa-calendar-check w-5 mr-2"></i> Resumen Diario
</a>
<a href="/logs/actividad" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/logs/actividad' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
<i class="fas fa-user-clock w-5 mr-2"></i> Actividad
</a>
+274
View File
@@ -0,0 +1,274 @@
{% extends "base.html" %}
{% block title %}Resumen Diario{% endblock %}
{% block header %}Resumen Diario TNS{% endblock %}
{% block content %}
<!-- Stats -->
<div class="flex flex-wrap gap-3 mb-5">
<div class="bg-white rounded-lg border border-gray-200 px-4 py-2.5 shadow-sm flex items-center gap-3">
<i class="fas fa-layer-group text-gray-400 text-sm"></i>
<span class="font-bold text-sm text-gray-700">{{ total }}</span>
<span class="text-gray-400 text-xs">facturas procesadas</span>
</div>
<div class="bg-white rounded-lg border border-green-200 px-4 py-2.5 shadow-sm flex items-center gap-3">
<i class="fas fa-check-circle text-green-500 text-sm"></i>
<span class="font-bold text-sm text-green-600">{{ exitosos }}</span>
<span class="text-gray-400 text-xs">exitosas</span>
</div>
<div class="bg-white rounded-lg border border-red-200 px-4 py-2.5 shadow-sm flex items-center gap-3">
<i class="fas fa-times-circle text-red-500 text-sm"></i>
<span class="font-bold text-sm text-red-600">{{ errores }}</span>
<span class="text-gray-400 text-xs">error persistente</span>
</div>
</div>
<!-- Filtros -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 mb-5">
<form method="get" action="/logs/resumen" class="p-4">
<div class="flex flex-wrap items-end gap-3">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Fecha</label>
<input type="date" name="fecha" value="{{ fecha }}"
class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Tipo</label>
<select name="tipo" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm min-w-[130px]">
<option value="">Todos</option>
<option value="terceros" {{ 'selected' if filtro_tipo == 'terceros' }}>Terceros</option>
<option value="transaccion" {{ 'selected' if filtro_tipo == 'transaccion' }}>Transacción</option>
<option value="ventas" {{ 'selected' if filtro_tipo == 'ventas' }}>Ventas</option>
</select>
</div>
<div class="flex gap-2">
<button type="submit"
class="px-4 py-1.5 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">
<i class="fas fa-search mr-1"></i> Consultar
</button>
<a href="/logs/resumen" class="px-3 py-1.5 bg-gray-100 text-gray-600 rounded-lg text-sm hover:bg-gray-200" title="Hoy">
<i class="fas fa-calendar-day"></i>
</a>
</div>
</div>
</form>
</div>
<!-- Tabla -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
<div class="px-6 py-3 border-b border-gray-100 flex items-center justify-between">
<span class="text-xs text-gray-500">
{% if total %}{{ total }} registro{{ 's' if total != 1 }} — {{ fecha }}{% else %}Sin registros para {{ fecha }}{% endif %}
</span>
<span class="text-xs text-gray-400">
<i class="fas fa-info-circle mr-1"></i>
Estado final: si hubo al menos un envío exitoso, se marca como exitoso
</span>
</div>
{% if rows %}
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead>
<tr class="text-left text-gray-400 border-b border-gray-200 bg-gray-50/60">
<th class="px-4 py-3 font-medium">Tipo</th>
<th class="px-2 py-3 font-medium">Factura</th>
<th class="px-2 py-3 font-medium">IDRECEP.</th>
<th class="px-2 py-3 font-medium">Contrato</th>
<th class="px-2 py-3 font-medium">Cédula</th>
<th class="px-2 py-3 font-medium text-center">Intentos</th>
<th class="px-2 py-3 font-medium">Estado final</th>
<th class="px-2 py-3 font-medium">Primer envío</th>
<th class="px-2 py-3 font-medium">Último envío</th>
<th class="px-2 py-3"></th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr class="border-b border-gray-50 hover:bg-gray-50 transition-colors
{% if r.estado_final == 'success' %}bg-green-50/20
{% elif r.estado_final == 'warning' %}bg-yellow-50/30
{% else %}bg-red-50/20{% endif %}"
data-error-id="{{ r.ultimo_error_id or '' }}"
data-exitoso-id="{{ r.exitoso_id or '' }}">
<td class="px-4 py-2.5">
<span class="px-2 py-0.5 rounded text-xs font-medium
{% if r.tipo == 'terceros' %}bg-blue-100 text-blue-700
{% elif r.tipo == 'ventas' %}bg-emerald-100 text-emerald-700
{% else %}bg-purple-100 text-purple-700{% endif %}">
{{ r.tipo }}
</span>
</td>
<td class="px-2 py-2.5 text-gray-700 font-medium">{{ r.factura or '—' }}</td>
<td class="px-2 py-2.5 text-gray-500 font-mono">{{ r.idrecepcion or '—' }}</td>
<td class="px-2 py-2.5 text-gray-500">{{ r.contrato or '—' }}</td>
<td class="px-2 py-2.5 text-gray-500 font-mono">{{ r.cedula or '—' }}</td>
<td class="px-2 py-2.5 text-center">
<span class="px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 font-mono">{{ r.intentos }}</span>
{% if r.exitos > 0 and r.intentos > r.exitos %}
<span class="text-gray-400 text-[10px] ml-0.5">({{ r.exitos }} ok)</span>
{% endif %}
</td>
<td class="px-2 py-2.5">
{% if r.estado_final == 'success' %}
<span class="px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">✓ Enviado</span>
{% elif r.estado_final == 'warning' %}
<span class="px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-700">! Ya existe</span>
{% else %}
<span class="px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700">✗ Error persistente</span>
{% endif %}
</td>
<td class="px-2 py-2.5 text-gray-400 whitespace-nowrap">{{ r.primer_envio[:16] if r.primer_envio else '—' }}</td>
<td class="px-2 py-2.5 text-gray-400 whitespace-nowrap">{{ r.ultimo_envio[:16] if r.ultimo_envio else '—' }}</td>
<td class="px-2 py-2.5 flex items-center gap-1.5">
{% set ver_id = r.exitoso_id or r.ultimo_error_id %}
{% if ver_id %}
<button onclick="verDetalle({{ ver_id }})"
class="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-600 rounded" title="Ver detalle">
<i class="fas fa-eye"></i>
</button>
{% endif %}
{% if r.estado_final == 'error' and r.ultimo_error_id %}
<button onclick="reenviar({{ r.ultimo_error_id }}, this)"
class="px-2 py-1 bg-orange-100 hover:bg-orange-200 text-orange-700 rounded" title="Reenviar a TNS">
<i class="fas fa-redo text-xs"></i>
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-16 text-gray-400">
<i class="fas fa-calendar-times text-5xl mb-4 block opacity-30"></i>
<p class="text-sm">Sin envíos registrados para {{ fecha }}</p>
</div>
{% endif %}
</div>
<!-- Modal detalle (idéntico al de logs.html) -->
<div id="modal-detalle" class="fixed inset-0 z-50 hidden">
<div class="absolute inset-0 bg-black/60" onclick="closeModal()"></div>
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-3xl bg-white rounded-xl shadow-2xl max-h-[88vh] overflow-hidden flex flex-col">
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center shrink-0">
<h3 id="modal-titulo" class="font-semibold text-gray-800 text-sm"></h3>
<button onclick="closeModal()" class="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
</div>
<div id="modal-info" class="px-6 py-2.5 bg-gray-50 border-b border-gray-200 shrink-0 text-xs text-gray-500 flex flex-wrap gap-4"></div>
<div class="flex border-b border-gray-200 shrink-0 px-4">
<button onclick="showTab('t-json')" id="btn-t-json"
class="tab-btn px-4 py-2 text-sm font-medium border-b-2 border-blue-500 text-blue-600 -mb-px">
JSON enviado</button>
<button onclick="showTab('t-tns')" id="btn-t-tns"
class="tab-btn px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 -mb-px">
Respuesta TNS</button>
<button onclick="showTab('t-msg')" id="btn-t-msg"
class="tab-btn px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 -mb-px">
Mensaje</button>
</div>
<div class="overflow-y-auto flex-1 p-5">
<pre id="t-json" class="tab-pane text-xs font-mono bg-gray-50 rounded-lg p-4 overflow-x-auto whitespace-pre-wrap"></pre>
<pre id="t-tns" class="tab-pane hidden text-xs font-mono bg-yellow-50 rounded-lg p-4 overflow-x-auto whitespace-pre-wrap text-yellow-900"></pre>
<pre id="t-msg" class="tab-pane hidden text-xs font-mono bg-blue-50 rounded-lg p-4 overflow-x-auto whitespace-pre-wrap text-blue-900"></pre>
</div>
</div>
</div>
<script>
async function reenviar(id, btn) {
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin text-xs"></i>';
try {
const resp = await fetch(`/logs/reenviar/${id}`, {method: 'POST', credentials: 'include'});
const data = await resp.json();
if (data.success) {
showToast('Reenvío exitoso', 'success');
const row = btn.closest('tr');
row.classList.remove('bg-red-50/20');
row.classList.add('bg-green-50/20');
const badge = row.querySelector('td:nth-child(7) span');
if (badge) {
badge.textContent = '✓ Enviado';
badge.className = 'px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700';
}
btn.remove();
} else {
showToast(data.message || 'Error al reenviar', 'error');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-redo text-xs"></i>';
}
} catch(e) {
showToast('Error de conexión', 'error');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-redo text-xs"></i>';
}
}
async function verDetalle(id) {
const resp = await fetch(`/logs/detalle/${id}`, {credentials: 'include'});
const data = await resp.json();
if (data.error) { showToast(data.error, 'error'); return; }
const estadoLabel = data.status === 'success' ? '✓ Enviado' : data.status === 'warning' ? '! Ya existe' : '✗ Error';
document.getElementById('modal-titulo').innerHTML =
`<i class="fas fa-file-alt mr-2 text-blue-500"></i>Envío #${data.id} — <b>${data.tipo}</b> — <span class="${data.status === 'success' ? 'text-green-600' : data.status === 'warning' ? 'text-yellow-600' : 'text-red-600'}">${estadoLabel}</span>`;
const info = [
data.factura ? `<span><b>Factura:</b> ${data.factura}</span>` : '',
data.idrecepcion ? `<span><b>IDRECEPCION:</b> ${data.idrecepcion}</span>` : '',
data.contrato ? `<span><b>Contrato:</b> ${data.contrato}</span>` : '',
(data.fecha_inicio && data.fecha_fin) ? `<span><b>Período:</b> ${data.fecha_inicio}${data.fecha_fin}</span>` : '',
data.username ? `<span><b>Usuario:</b> ${data.username}</span>` : '',
data.created_at ? `<span><b>Enviado:</b> ${data.created_at.slice(0, 16)}</span>` : '',
].filter(Boolean).join('');
document.getElementById('modal-info').innerHTML = info;
const jsonPre = document.getElementById('t-json');
if (data.json_enviado) {
try { jsonPre.innerHTML = syntaxHighlight(JSON.parse(data.json_enviado)); }
catch(e) { jsonPre.textContent = data.json_enviado; }
} else {
jsonPre.textContent = '(sin JSON guardado)';
}
const tnsPre = document.getElementById('t-tns');
const raw = data.respuesta_api || '(sin respuesta)';
try { tnsPre.textContent = JSON.stringify(JSON.parse(raw), null, 2); }
catch(e) { tnsPre.textContent = raw; }
document.getElementById('t-msg').textContent = data.mensaje_tns || '(sin mensaje)';
showTab('t-json');
document.getElementById('modal-detalle').classList.remove('hidden');
}
function showTab(tabId) {
document.querySelectorAll('.tab-pane').forEach(p => p.classList.add('hidden'));
document.querySelectorAll('.tab-btn').forEach(b => {
b.classList.remove('border-blue-500','text-blue-600');
b.classList.add('border-transparent','text-gray-500');
});
document.getElementById(tabId).classList.remove('hidden');
const btn = document.getElementById('btn-' + tabId);
if (btn) {
btn.classList.add('border-blue-500','text-blue-600');
btn.classList.remove('border-transparent','text-gray-500');
}
}
function closeModal() {
document.getElementById('modal-detalle').classList.add('hidden');
}
function syntaxHighlight(obj) {
let json = JSON.stringify(obj, null, 2);
json = json.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
return json
.replace(/("(?:[^"\\]|\\.)*")(?=\s*:)/g,'<span class="text-blue-600">$1</span>')
.replace(/:(\s*)("(?:[^"\\]|\\.)*")/g,': $1<span class="text-green-700">$2</span>')
.replace(/: (\d+(?:\.\d+)?)/g,': <span class="text-orange-600">$1</span>')
.replace(/: (null|true|false)/g,': <span class="text-purple-600">$1</span>');
}
</script>
{% endblock %}