feat: módulos de envío TNS/ERP Lab + sync de médico, empresa y valor total

- Hub /envios/tns con 5 sub-tabs (Terceros, Transacción RIPS, Facturas Venta, Prueba RDA, Automatización) via iframe lazy
- Hub /envios/erp con sub-tabs Pacientes y Sync Automático (historial scheduler)
- Modo embed (?embed=1) en base.html para cargar páginas sin sidebar/header dentro de iframes
- Sidebar simplificado: 6 ítems individuales reemplazados por módulos TNS y ERP Lab
- Scheduler y endpoint /pacientes/examenes ahora incluyen médico ordenante (DOCIDMEDICO), empresa/EPS (NIT_EMPRESA) y valor total (VALORTOTAL) desde Firebird

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-16 21:13:48 -05:00
co-authored by Claude Sonnet 4.6
parent 31c0b71c90
commit 8e27225326
8 changed files with 676 additions and 165 deletions
+26
View File
@@ -0,0 +1,26 @@
from fastapi import APIRouter, Request, Depends
from app.auth import get_current_user
from app.database import get_connection
router = APIRouter(prefix="/envios", tags=["envios"])
@router.get("/tns")
async def envios_tns(request: Request, user: dict = Depends(get_current_user)):
return request.app.state.templates.TemplateResponse("envios_tns.html", {
"request": request, "user": user,
})
@router.get("/erp")
async def envios_erp(request: Request, user: dict = Depends(get_current_user)):
conn = get_connection()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
wa_url = configs.get("whatsapp_url", "")
wa_key = configs.get("whatsapp_api_key", "")
return request.app.state.templates.TemplateResponse("envios_erp.html", {
"request": request, "user": user,
"wa_configurado": bool(wa_url and wa_key),
"wa_url": wa_url,
})
+13 -1
View File
@@ -42,10 +42,17 @@ SELECT
TRIM(rel.COD_EXAMEN) AS COD_EXAMEN, TRIM(rel.COD_EXAMEN) AS COD_EXAMEN,
TRIM(ex.NOMBRE) AS NOM_EXAMEN, TRIM(ex.NOMBRE) AS NOM_EXAMEN,
COALESCE(NULLIF(TRIM(ex.NUM_ISS), ''), TRIM(rel.COD_EXAMEN)) AS CUPS, COALESCE(NULLIF(TRIM(ex.NUM_ISS), ''), TRIM(rel.COD_EXAMEN)) AS CUPS,
rel.PRECIO rel.PRECIO,
TRIM(r.DIAG_PPAL) AS DIAG_PPAL,
TRIM(d.CONCEPTO) AS DIAG_CONCEPTO,
TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO,
TRIM(r.NIT_EMPRESA) AS NIT_EMPRESA,
r.VALORTOTAL
FROM RECEPCION r FROM RECEPCION r
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
LEFT JOIN EXAMEN ex ON TRIM(ex.CODIGO) = TRIM(rel.COD_EXAMEN) LEFT JOIN EXAMEN ex ON TRIM(ex.CODIGO) = TRIM(rel.COD_EXAMEN)
LEFT JOIN DIAGNOSTICO d ON TRIM(d.COD_DIAG) = TRIM(r.DIAG_PPAL)
LEFT JOIN MEDICO m ON m.CODIGO = r.COD_MEDICO
JOIN PACIENTE p ON p.CODIGO = r.COD_PACIENTE JOIN PACIENTE p ON p.CODIGO = r.COD_PACIENTE
WHERE TRIM(p.DOCIDENT) = :cedula WHERE TRIM(p.DOCIDENT) = :cedula
AND r.FECHA_RECEPCION = CURRENT_DATE AND r.FECHA_RECEPCION = CURRENT_DATE
@@ -129,6 +136,11 @@ async def examenes_paciente(request: Request, cedula: str = ""):
"nombre": (row.get("NOM_EXAMEN") or "").strip(), "nombre": (row.get("NOM_EXAMEN") or "").strip(),
"cups": (row.get("CUPS") or "").strip(), "cups": (row.get("CUPS") or "").strip(),
"precio": row.get("PRECIO"), "precio": row.get("PRECIO"),
"diagnostico_cod": (row.get("DIAG_PPAL") or "").strip(),
"diagnostico_nombre": (row.get("DIAG_CONCEPTO") or "").strip(),
"medico_docidmedico": (row.get("DOCIDMEDICO") or "").strip(),
"nit_empresa": (row.get("NIT_EMPRESA") or "").strip(),
"valor_total": row.get("VALORTOTAL"),
}) })
return JSONResponse({"ok": True, "examenes": examenes}) return JSONResponse({"ok": True, "examenes": examenes})
+140
View File
@@ -0,0 +1,140 @@
"""
Scheduler: sync automático de pacientes con recepción reciente → WhatsApp Lab.
Corre cada minuto y envía solo los pacientes con HORAINICIORECEPCION en los últimos 2 min.
"""
from datetime import datetime, timedelta
from app.database import get_connection
from app.services.firebird_service import get_firebird_from_config
from app.services.whatsapp_sync import sync_todos, guardar_sync_log
_SQL_HOY = """
SELECT DISTINCT
p.CODIGO,
p.TIPOIDENT,
p.DOCIDENT,
p.NOMBRES,
p.APELLIDOS,
p.DIRECCION,
p.CIUDAD AS COD_CIUDAD,
c.NOMBRE AS NOM_CIUDAD,
p.TELEFONOS,
p.EMAIL,
p.F_NACIMIENTO,
p.SEXO,
p.TIPORES,
p.CODETNIA,
r.HORAINICIORECEPCION
FROM PACIENTE p
LEFT JOIN CIUDAD c ON c.CODIGO = p.CIUDAD
JOIN RECEPCION r ON r.COD_PACIENTE = p.CODIGO
WHERE r.FECHA_RECEPCION = CURRENT_DATE
AND r.NUM_FACTURA > 0
"""
_SQL_EXAMENES_HOY = """
SELECT
TRIM(p.DOCIDENT) AS DOCIDENT,
r.IDRECEPCION,
r.HORAINICIORECEPCION,
TRIM(rel.COD_EXAMEN) AS COD_EXAMEN,
TRIM(ex.NOMBRE) AS NOM_EXAMEN,
COALESCE(NULLIF(TRIM(ex.NUM_ISS), ''), TRIM(rel.COD_EXAMEN)) AS CUPS,
rel.PRECIO,
TRIM(r.DIAG_PPAL) AS DIAG_PPAL,
TRIM(d.CONCEPTO) AS DIAG_CONCEPTO,
TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO,
TRIM(r.NIT_EMPRESA) AS NIT_EMPRESA,
r.VALORTOTAL
FROM RECEPCION r
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
LEFT JOIN EXAMEN ex ON TRIM(ex.CODIGO) = TRIM(rel.COD_EXAMEN)
LEFT JOIN DIAGNOSTICO d ON TRIM(d.COD_DIAG) = TRIM(r.DIAG_PPAL)
LEFT JOIN MEDICO m ON m.CODIGO = r.COD_MEDICO
JOIN PACIENTE p ON p.CODIGO = r.COD_PACIENTE
WHERE r.FECHA_RECEPCION = CURRENT_DATE
AND r.NUM_FACTURA > 0
ORDER BY r.IDRECEPCION
"""
def _parse_hora(val) -> datetime | None:
if not val:
return None
s = str(val)
try:
if "T" in s:
dt = datetime.fromisoformat(s)
ahora = datetime.now()
return ahora.replace(hour=dt.hour, minute=dt.minute, second=dt.second, microsecond=0)
partes = s.split(":")
ahora = datetime.now()
return ahora.replace(hour=int(partes[0]), minute=int(partes[1]),
second=int(partes[2].split(".")[0]), microsecond=0)
except Exception:
return None
async def sync_recientes():
"""Job que corre cada minuto: sincroniza pacientes con recepción en los últimos 2 min."""
conn = get_connection()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
wa_url = configs.get("whatsapp_url", "").rstrip("/")
wa_key = configs.get("whatsapp_api_key", "")
if not wa_url or not wa_key:
return
fb, ok, _ = get_firebird_from_config(configs)
if not ok:
return
ok_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
limite = datetime.now() - timedelta(minutes=2)
recientes = [
row for row in rows_pac
if (h := _parse_hora(row.get("HORAINICIORECEPCION"))) is None or h >= limite
]
if not recientes:
return
# 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:
h = _parse_hora(ex.get("HORAINICIORECEPCION"))
if h is not None and h < limite:
continue
doc = str(ex.get("DOCIDENT") or "").strip()
if not doc:
continue
examenes_map.setdefault(doc, []).append({
"cod_examen": (ex.get("COD_EXAMEN") or "").strip(),
"nombre": (ex.get("NOM_EXAMEN") or "").strip(),
"cups": (ex.get("CUPS") or "").strip(),
"precio": ex.get("PRECIO"),
"recepcion_id": ex.get("IDRECEPCION"),
"hora": str(ex.get("HORAINICIORECEPCION") or "")[:8],
"diagnostico_cod": (ex.get("DIAG_PPAL") or "").strip(),
"diagnostico_nombre": (ex.get("DIAG_CONCEPTO") or "").strip(),
"medico_docidmedico": (ex.get("DOCIDMEDICO") or "").strip(),
"nit_empresa": (ex.get("NIT_EMPRESA") or "").strip(),
"valor_total": ex.get("VALORTOTAL"),
})
ingest_url = f"{wa_url}/api/lab/ingest_paciente.php"
timeout = int(configs.get("api_timeout", 30))
resultado = await sync_todos(recientes, ingest_url, wa_key, timeout,
modo="upsert", examenes_map=examenes_map)
if resultado["total"] > 0:
guardar_sync_log(resultado, 0, origen="scheduler", modo="upsert")
+8 -2
View File
@@ -106,10 +106,13 @@ async def sync_paciente(
api_key: str, api_key: str,
client: Optional[httpx.AsyncClient] = None, client: Optional[httpx.AsyncClient] = None,
modo: str = "upsert", modo: str = "upsert",
examenes: Optional[list] = None,
) -> dict: ) -> dict:
"""Envía un paciente al endpoint de WhatsApp. Retorna {ok, action, message}.""" """Envía un paciente al endpoint de WhatsApp. Retorna {ok, action, message}."""
payload = mapear_paciente(row) payload = mapear_paciente(row)
payload["modo"] = modo payload["modo"] = modo
if examenes:
payload["examenes"] = examenes
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-Lab-Key": api_key, "X-Lab-Key": api_key,
@@ -139,13 +142,16 @@ async def sync_paciente(
} }
async def sync_todos(rows: list, url: str, api_key: str, timeout: int = 30, modo: str = "upsert") -> dict: async def sync_todos(rows: list, url: str, api_key: str, timeout: int = 30, modo: str = "upsert",
examenes_map: Optional[dict] = None) -> dict:
"""Envía una lista de filas de pacientes. Retorna resumen {total, created, skipped, updated, errores}.""" """Envía una lista de filas de pacientes. Retorna resumen {total, created, skipped, updated, errores}."""
resultado = {"total": len(rows), "created": 0, "skipped": 0, "updated": 0, "errores": 0, "detalle": []} resultado = {"total": len(rows), "created": 0, "skipped": 0, "updated": 0, "errores": 0, "detalle": []}
async with httpx.AsyncClient(timeout=timeout) as client: async with httpx.AsyncClient(timeout=timeout) as client:
for row in rows: for row in rows:
r = await sync_paciente(row, url, api_key, client, modo) doc = str(row.get("DOCIDENT") or "").strip()
examenes = examenes_map.get(doc) if examenes_map else None
r = await sync_paciente(row, url, api_key, client, modo, examenes)
if r["ok"]: if r["ok"]:
action = r["action"] action = r["action"]
if action == "created": if action == "created":
+41 -27
View File
@@ -28,52 +28,63 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
</head> </head>
<body class="h-full"> <body class="h-full">
{% set embedded = request.query_params.get('embed') == '1' %}
{% set cur = request.url.path %}
<div class="min-h-full"> <div class="min-h-full">
{% if user %} {% if user %}
{% if not embedded %}
<!-- Sidebar --> <!-- Sidebar -->
<div class="fixed inset-y-0 left-0 w-64 bg-gray-900 text-white z-30"> <div class="fixed inset-y-0 left-0 w-64 bg-gray-900 text-white z-30">
<div class="flex items-center h-16 px-6 border-b border-gray-700"> <div class="flex items-center h-16 px-6 border-b border-gray-700">
<i class="fas fa-file-medical text-blue-400 text-xl mr-3"></i> <i class="fas fa-file-medical text-blue-400 text-xl mr-3"></i>
<span class="font-bold text-lg">RIPS Manager</span> <span class="font-bold text-lg">RIPS Manager</span>
</div> </div>
<nav class="mt-4 px-3 space-y-1"> <nav class="mt-4 px-3 space-y-1 overflow-y-auto" style="max-height: calc(100vh - 130px)">
<a href="/dashboard" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/dashboard' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}"> <a href="/dashboard" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/dashboard' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
<i class="fas fa-chart-pie w-5 mr-2"></i> Dashboard <i class="fas fa-chart-pie w-5 mr-2"></i> Dashboard
</a> </a>
<a href="/config" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/config' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}"> <a href="/config" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/config' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
<i class="fas fa-cog w-5 mr-2"></i> Configuración <i class="fas fa-cog w-5 mr-2"></i> Configuración
</a> </a>
<a href="/queries" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/queries' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}"> <a href="/queries" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/queries' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
<i class="fas fa-database w-5 mr-2"></i> Consultas SQL <i class="fas fa-database w-5 mr-2"></i> Consultas SQL
</a> </a>
<a href="/contratos" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/contratos' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}"> <a href="/contratos" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/contratos' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
<i class="fas fa-file-contract w-5 mr-2"></i> Contratos <i class="fas fa-file-contract w-5 mr-2"></i> Contratos
</a> </a>
<hr class="my-3 border-gray-700"> <hr class="my-3 border-gray-700">
<p class="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">Envíos</p> <p class="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Módulos de Envío</p>
<a href="/terceros" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/terceros' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
<i class="fas fa-user w-5 mr-2"></i> Terceros␍ <!-- Módulo TNS -->
<a href="/envios/tns" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/envios/tns' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
<i class="fas fa-paper-plane w-5 mr-2"></i>
<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> </a>
<a href="/transaccion" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/transaccion' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}"> {% if cur == '/envios/tns' %}
<i class="fas fa-exchange-alt w-5 mr-2"></i> Transacción RIPS␍ <div class="ml-4 border-l border-gray-700 pl-3">
</a> <p class="text-xs text-gray-500 py-0.5">Terceros · Transacción · Ventas</p>
<a href="/ventas" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/ventas' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}"> <p class="text-xs text-gray-500 py-0.5">Prueba RDA · Automatización</p>
<i class="fas fa-file-invoice-dollar w-5 mr-2"></i> Facturas Venta </div>
</a> {% endif %}
<a href="/automation" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/automation' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
<i class="fas fa-robot w-5 mr-2"></i> Automatización␍ <!-- Módulo ERP Lab -->
</a> <a href="/envios/erp" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/envios/erp' %}bg-green-700 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
<a href="/pacientes" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/pacientes' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}"> <i class="fas fa-flask w-5 mr-2"></i>
<i class="fas fa-users w-5 mr-2"></i> Pacientes <span class="ml-auto text-xs bg-green-500 text-white px-1.5 rounded-full">WA</span> <span class="flex-1">ERP Lab</span>
</a> <span class="text-xs px-1.5 py-0.5 rounded {% if cur == '/envios/erp' %}bg-green-500 text-green-100{% else %}bg-gray-700 text-gray-400{% endif %}">WA</span>
<a href="/test-rda" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/test-rda' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
<i class="fas fa-flask w-5 mr-2"></i> Prueba RDA
</a> </a>
{% if cur == '/envios/erp' %}
<div class="ml-4 border-l border-gray-700 pl-3">
<p class="text-xs text-gray-500 py-0.5">Pacientes · Sync Automático</p>
</div>
{% endif %}
<hr class="my-3 border-gray-700"> <hr class="my-3 border-gray-700">
<a href="/logs" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/logs' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}"> <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 <i class="fas fa-history w-5 mr-2"></i> Historial TNS
</a> </a>
<a href="/logs/actividad" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/logs/actividad' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}"> <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 <i class="fas fa-user-clock w-5 mr-2"></i> Actividad
</a> </a>
<a href="/auth/logout" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 hover:bg-gray-700"> <a href="/auth/logout" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 hover:bg-gray-700">
@@ -92,9 +103,11 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
<!-- Main content --> <!-- Main content -->
<div class="pl-64"> <div class="{% if not embedded %}pl-64{% endif %}">
{% if not embedded %}
<header class="bg-white shadow-sm border-b border-gray-200"> <header class="bg-white shadow-sm border-b border-gray-200">
<div class="flex items-center justify-between h-16 px-8"> <div class="flex items-center justify-between h-16 px-8">
<h1 class="text-xl font-semibold text-gray-800">{% block header %}Dashboard{% endblock %}</h1> <h1 class="text-xl font-semibold text-gray-800">{% block header %}Dashboard{% endblock %}</h1>
@@ -106,7 +119,8 @@
</div> </div>
</div> </div>
</header> </header>
<main class="p-8"> {% endif %}
<main class="{% if not embedded %}p-8{% else %}p-4{% endif %}">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
</div> </div>
+198
View File
@@ -0,0 +1,198 @@
{% extends "base.html" %}
{% block title %}ERP Lab{% endblock %}
{% block header %}<i class="fas fa-flask mr-2 text-green-400"></i> ERP Lab · Ximena Caicedo{% endblock %}
{% block content %}
<div class="-m-8 flex flex-col" style="height: calc(100vh - 64px)">
<!-- Barra de sub-tabs -->
<div class="bg-white border-b border-gray-200 px-4 flex-shrink-0">
<div class="flex">
<button id="btn-pacientes" onclick="switchTab('pacientes')"
class="tab-btn flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap">
<i class="fas fa-users text-xs"></i> Pacientes
</button>
<button id="btn-scheduler" onclick="switchTab('scheduler')"
class="tab-btn flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap">
<i class="fas fa-sync-alt text-xs"></i> Sync Automático
<span class="text-xs bg-green-100 text-green-700 px-1.5 py-0.5 rounded-full font-semibold">1 min</span>
</button>
</div>
</div>
<!-- Paneles -->
<div class="flex-1 relative overflow-hidden bg-gray-50">
<!-- Tab: Pacientes (iframe) -->
<div id="panel-pacientes" class="tab-panel absolute inset-0 hidden">
<iframe id="iframe-pacientes" class="w-full h-full border-0"></iframe>
</div>
<!-- Tab: Sync Automático (inline) -->
<div id="panel-scheduler" class="tab-panel absolute inset-0 hidden overflow-y-auto">
<div class="p-6 space-y-5 max-w-5xl mx-auto">
<!-- Estado del scheduler -->
<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-robot mr-2 text-green-500"></i>Estado del Scheduler
</h3>
<span class="flex items-center gap-2 text-sm text-green-600 font-medium">
<span class="w-2 h-2 rounded-full bg-green-400 animate-pulse"></span>
Activo — cada 1 minuto
</span>
</div>
<div class="p-5 grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm text-gray-600">
<div class="flex items-start gap-3">
<i class="fas fa-database text-blue-400 mt-0.5"></i>
<div>
<p class="font-medium text-gray-700">Fuente</p>
<p class="text-xs text-gray-500">Firebird · DBLAB_XIMENA_FB25</p>
</div>
</div>
<div class="flex items-start gap-3">
<i class="fas fa-filter text-purple-400 mt-0.5"></i>
<div>
<p class="font-medium text-gray-700">Ventana de captura</p>
<p class="text-xs text-gray-500">Recepciones con HORAINICIORECEPCION en los últimos 2 minutos</p>
</div>
</div>
<div class="flex items-start gap-3">
<i class="fas fa-paper-plane text-green-400 mt-0.5"></i>
<div>
<p class="font-medium text-gray-700">Destino</p>
<p class="text-xs text-gray-500">WhatsApp Lab · <code>ingest_paciente.php</code></p>
</div>
</div>
</div>
<div class="px-5 pb-4 border-t border-gray-100 pt-3">
<p class="text-xs text-gray-500 font-medium mb-2">Datos sincronizados por recepción:</p>
<div class="flex flex-wrap gap-2">
<span class="text-xs bg-blue-50 text-blue-700 px-2 py-1 rounded">Paciente (upsert)</span>
<span class="text-xs bg-purple-50 text-purple-700 px-2 py-1 rounded">Exámenes + CUPS</span>
<span class="text-xs bg-orange-50 text-orange-700 px-2 py-1 rounded">Diagnóstico CIE-10</span>
<span class="text-xs bg-green-50 text-green-700 px-2 py-1 rounded">Médico ordenante</span>
<span class="text-xs bg-teal-50 text-teal-700 px-2 py-1 rounded">Empresa / EPS</span>
<span class="text-xs bg-yellow-50 text-yellow-700 px-2 py-1 rounded">Valor total</span>
</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">
<h3 class="font-semibold text-gray-800">
<i class="fas fa-history mr-2 text-gray-400"></i>Historial de sync automático
</h3>
<button onclick="cargarHistorialScheduler()"
class="text-sm text-blue-600 hover:underline flex items-center gap-1">
<i class="fas fa-refresh text-xs"></i> Actualizar
</button>
</div>
<div id="scheduler-hist" class="p-4">
<p class="text-sm text-gray-400 text-center py-4">Cargando…</p>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const _TAB_ACTIVE = 'border-green-500 text-green-700 bg-green-50';
const _TAB_INACTIVE = 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300';
let _schedulerLoaded = false;
function switchTab(name) {
document.querySelectorAll('.tab-panel').forEach(p => p.classList.add('hidden'));
document.querySelectorAll('.tab-btn').forEach(b => {
b.className = b.className.replace(_TAB_ACTIVE, '').replace(_TAB_INACTIVE, '').trim();
b.classList.add(..._TAB_INACTIVE.split(' '));
});
document.getElementById('panel-' + name).classList.remove('hidden');
const btn = document.getElementById('btn-' + name);
btn.className = btn.className.replace(_TAB_INACTIVE, '').trim();
btn.classList.add(..._TAB_ACTIVE.split(' '));
if (name === 'pacientes') {
const iframe = document.getElementById('iframe-pacientes');
if (!iframe.src) iframe.src = '/pacientes?embed=1';
}
if (name === 'scheduler' && !_schedulerLoaded) {
_schedulerLoaded = true;
cargarHistorialScheduler();
}
sessionStorage.setItem('erp-tab', name);
}
const _origenLabel = { manual: 'Manual', tercero: 'Tercero', scheduler: 'Scheduler', automation: 'Automatización' };
const _modoLabel = { insertar: 'Solo nuevos', upsert: 'Upsert' };
async function cargarHistorialScheduler() {
const el = document.getElementById('scheduler-hist');
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();
const sched = rows.filter(r => r.origen === 'scheduler');
if (!sched.length) {
el.innerHTML = '<p class="text-sm text-gray-400 text-center py-6">Sin ejecuciones automáticas registradas 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-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>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
${sched.map(r => {
const fecha = r.created_at.replace('T', ' ').slice(0, 16);
const errDet = r.errores_det ? JSON.parse(r.errores_det) : [];
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-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>
</tr>`;
}).join('')}
</tbody>
</table>
</div>`;
} catch (e) {
el.innerHTML = `<p class="text-sm text-red-500 text-center py-4">Error: ${e.message}</p>`;
}
}
(function init() {
document.querySelectorAll('.tab-btn').forEach(b => {
b.classList.add(..._TAB_INACTIVE.split(' '), 'border-b-2');
});
const saved = sessionStorage.getItem('erp-tab') || 'pacientes';
switchTab(saved);
})();
</script>
{% endblock %}
+103
View File
@@ -0,0 +1,103 @@
{% extends "base.html" %}
{% block title %}Módulo TNS{% endblock %}
{% block header %}<i class="fas fa-paper-plane mr-2 text-blue-400"></i> Módulo TNS{% endblock %}
{% block content %}
<div class="-m-8 flex flex-col" style="height: calc(100vh - 64px)">
<!-- Barra de sub-tabs -->
<div class="bg-white border-b border-gray-200 px-4 flex-shrink-0">
<div class="flex">
<button id="btn-terceros" onclick="switchTab('terceros', '/terceros?embed=1')"
class="tab-btn flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap">
<i class="fas fa-user text-xs"></i> Terceros
</button>
<button id="btn-transaccion" onclick="switchTab('transaccion', '/transaccion?embed=1')"
class="tab-btn flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap">
<i class="fas fa-exchange-alt text-xs"></i> Transacción RIPS
</button>
<button id="btn-ventas" onclick="switchTab('ventas', '/ventas?embed=1')"
class="tab-btn flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap">
<i class="fas fa-file-invoice-dollar text-xs"></i> Facturas Venta
</button>
<button id="btn-rda" onclick="switchTab('rda', '/test-rda?embed=1')"
class="tab-btn flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap">
<i class="fas fa-flask text-xs"></i> Prueba RDA
</button>
<button id="btn-automation" onclick="switchTab('automation', '/automation?embed=1')"
class="tab-btn flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap">
<i class="fas fa-robot text-xs"></i> Automatización
</button>
</div>
</div>
<!-- Paneles de iframe -->
<div class="flex-1 relative overflow-hidden bg-gray-50">
<div id="panel-terceros" class="tab-panel absolute inset-0 hidden">
<iframe id="iframe-terceros" class="w-full h-full border-0"></iframe>
</div>
<div id="panel-transaccion" class="tab-panel absolute inset-0 hidden">
<iframe id="iframe-transaccion" class="w-full h-full border-0"></iframe>
</div>
<div id="panel-ventas" class="tab-panel absolute inset-0 hidden">
<iframe id="iframe-ventas" class="w-full h-full border-0"></iframe>
</div>
<div id="panel-rda" class="tab-panel absolute inset-0 hidden">
<iframe id="iframe-rda" class="w-full h-full border-0"></iframe>
</div>
<div id="panel-automation" class="tab-panel absolute inset-0 hidden">
<iframe id="iframe-automation" class="w-full h-full border-0"></iframe>
</div>
</div>
</div>
<script>
const _TAB_ACTIVE = 'border-blue-500 text-blue-600 bg-blue-50';
const _TAB_INACTIVE = 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300';
function switchTab(name, src) {
document.querySelectorAll('.tab-panel').forEach(p => p.classList.add('hidden'));
document.querySelectorAll('.tab-btn').forEach(b => {
b.className = b.className.replace(_TAB_ACTIVE, '').replace(_TAB_INACTIVE, '').trim();
b.classList.add(..._TAB_INACTIVE.split(' '));
});
const panel = document.getElementById('panel-' + name);
const btn = document.getElementById('btn-' + name);
const iframe = document.getElementById('iframe-' + name);
panel.classList.remove('hidden');
btn.className = btn.className.replace(_TAB_INACTIVE, '').trim();
btn.classList.add(..._TAB_ACTIVE.split(' '));
if (src && !iframe.src) {
iframe.src = src;
}
sessionStorage.setItem('tns-tab', name);
sessionStorage.setItem('tns-src-' + name, src);
}
(function init() {
// Establecer clases base en todos los botones
document.querySelectorAll('.tab-btn').forEach(b => {
b.classList.add(..._TAB_INACTIVE.split(' '), 'border-b-2');
});
const saved = sessionStorage.getItem('tns-tab') || 'terceros';
const srcMap = {
terceros: '/terceros?embed=1',
transaccion:'/transaccion?embed=1',
ventas: '/ventas?embed=1',
rda: '/test-rda?embed=1',
automation: '/automation?embed=1',
};
switchTab(saved, srcMap[saved] || srcMap.terceros);
})();
</script>
{% endblock %}
+13 -1
View File
@@ -8,11 +8,14 @@ from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
import uvicorn import uvicorn
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.database import init_db from app.database import init_db
from app.auth import decode_token from app.auth import decode_token
from app.services.scheduler import sync_recientes
app = FastAPI(title="RIPS Manager", version="1.0.0") app = FastAPI(title="RIPS Manager", version="1.0.0")
_scheduler = AsyncIOScheduler()
templates = Jinja2Templates( templates = Jinja2Templates(
directory=os.path.join(os.path.dirname(__file__), "app", "templates") directory=os.path.join(os.path.dirname(__file__), "app", "templates")
@@ -60,6 +63,14 @@ async def startup():
config_defaults() config_defaults()
query_defaults() query_defaults()
contratos_defaults() contratos_defaults()
_scheduler.add_job(sync_recientes, "interval", minutes=1, id="wa_sync_recientes",
max_instances=1, coalesce=True)
_scheduler.start()
@app.on_event("shutdown")
async def shutdown():
_scheduler.shutdown(wait=False)
@app.get("/") @app.get("/")
@@ -67,12 +78,13 @@ async def root():
return RedirectResponse(url="/dashboard") return RedirectResponse(url="/dashboard")
from app.routes import auth, dashboard, config, queries, terceros, transaccion, logs, automation, test_rda, debug_fb, pacientes, contratos, ventas from app.routes import auth, dashboard, config, queries, terceros, transaccion, logs, automation, test_rda, debug_fb, pacientes, contratos, ventas, envios
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(dashboard.router) app.include_router(dashboard.router)
app.include_router(config.router) app.include_router(config.router)
app.include_router(queries.router) app.include_router(queries.router)
app.include_router(envios.router)
app.include_router(terceros.router) app.include_router(terceros.router)
app.include_router(transaccion.router) app.include_router(transaccion.router)
app.include_router(logs.router) app.include_router(logs.router)