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,
})
+25 -13
View File
@@ -39,14 +39,21 @@ _SQL_EXAMENES_CEDULA = """
SELECT SELECT
r.IDRECEPCION, r.IDRECEPCION,
r.HORAINICIORECEPCION, r.HORAINICIORECEPCION,
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)
JOIN PACIENTE p ON p.CODIGO = r.COD_PACIENTE 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 TRIM(p.DOCIDENT) = :cedula WHERE TRIM(p.DOCIDENT) = :cedula
AND r.FECHA_RECEPCION = CURRENT_DATE AND r.FECHA_RECEPCION = CURRENT_DATE
ORDER BY r.IDRECEPCION DESC ORDER BY r.IDRECEPCION DESC
@@ -123,12 +130,17 @@ async def examenes_paciente(request: Request, cedula: str = ""):
if hora_dt and hora_dt < limite: if hora_dt and hora_dt < limite:
continue continue
examenes.append({ examenes.append({
"recepcion_id": row.get("IDRECEPCION"), "recepcion_id": row.get("IDRECEPCION"),
"hora": row.get("HORAINICIORECEPCION"), "hora": row.get("HORAINICIORECEPCION"),
"cod_examen": (row.get("COD_EXAMEN") or "").strip(), "cod_examen": (row.get("COD_EXAMEN") or "").strip(),
"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":
+163 -149
View File
@@ -1,151 +1,165 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="es" class="h-full bg-gray-50"> <html lang="es" class="h-full bg-gray-50">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RIPS Manager - {% block title %}Dashboard{% endblock %}</title> <title>RIPS Manager - {% block title %}Dashboard{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/sql/sql.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/sql/sql.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/addon/hint/show-hint.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/addon/hint/show-hint.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/addon/hint/sql-hint.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/addon/hint/sql-hint.min.js"></script>
<style> <style>
.CodeMirror { border: 1px solid #d1d5db; border-radius: 0.5rem; font-size: 13px; height: auto; min-height: 200px; } .CodeMirror { border: 1px solid #d1d5db; border-radius: 0.5rem; font-size: 13px; height: auto; min-height: 200px; }
.CodeMirror-hints { font-size: 12px; } .CodeMirror-hints { font-size: 12px; }
</style> </style>
<script> <script>
tailwind.config = { tailwind.config = {
theme: { theme: {
extend: { extend: {
colors: { colors: {
primary: {'50': '#eff6ff','100': '#dbeafe','200': '#bfdbfe','300': '#93c5fd','400': '#60a5fa','500': '#3b82f6','600': '#2563eb','700': '#1d4ed8','800': '#1e40af','900': '#1e3a8a'} primary: {'50': '#eff6ff','100': '#dbeafe','200': '#bfdbfe','300': '#93c5fd','400': '#60a5fa','500': '#3b82f6','600': '#2563eb','700': '#1d4ed8','800': '#1e40af','900': '#1e3a8a'}
} }
} }
} }
} }
</script> </script>
<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">
<div class="min-h-full"> {% set embedded = request.query_params.get('embed') == '1' %}
{% if user %} {% set cur = request.url.path %}
<!-- Sidebar --> <div class="min-h-full">
<div class="fixed inset-y-0 left-0 w-64 bg-gray-900 text-white z-30"> {% if user %}
<div class="flex items-center h-16 px-6 border-b border-gray-700"> {% if not embedded %}
<i class="fas fa-file-medical text-blue-400 text-xl mr-3"></i> <!-- Sidebar -->
<span class="font-bold text-lg">RIPS Manager</span> <div class="fixed inset-y-0 left-0 w-64 bg-gray-900 text-white z-30">
</div> <div class="flex items-center h-16 px-6 border-b border-gray-700">
<nav class="mt-4 px-3 space-y-1"> <i class="fas fa-file-medical text-blue-400 text-xl mr-3"></i>
<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 %}"> <span class="font-bold text-lg">RIPS Manager</span>
<i class="fas fa-chart-pie w-5 mr-2"></i> Dashboard␍ </div>
</a> <nav class="mt-4 px-3 space-y-1 overflow-y-auto" style="max-height: calc(100vh - 130px)">
<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="/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-cog w-5 mr-2"></i> Configuración␍ <i class="fas fa-chart-pie w-5 mr-2"></i> Dashboard
</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 %}">
<i class="fas fa-database w-5 mr-2"></i> Consultas SQL␍
</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 %}">
<i class="fas fa-file-contract w-5 mr-2"></i> Contratos␍
</a>
<hr class="my-3 border-gray-700">
<p class="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">Envíos</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␍
</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 %}">
<i class="fas fa-exchange-alt w-5 mr-2"></i> Transacción RIPS␍
</a>
<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 %}">
<i class="fas fa-file-invoice-dollar w-5 mr-2"></i> Facturas Venta
</a> </a>
<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 %}"> <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-robot w-5 mr-2"></i> Automatización <i class="fas fa-cog w-5 mr-2"></i> Configuración
</a>
<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-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>
</a> </a>
<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 %}"> <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-flask w-5 mr-2"></i> Prueba RDA <i class="fas fa-database w-5 mr-2"></i> Consultas SQL
</a> </a>
<hr class="my-3 border-gray-700"> <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 %}">
<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 %}"> <i class="fas fa-file-contract w-5 mr-2"></i> Contratos
<i class="fas fa-history w-5 mr-2"></i> Historial␍ </a>
</a> <hr class="my-3 border-gray-700">
<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 %}"> <p class="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Módulos de Envío</p>
<i class="fas fa-user-clock w-5 mr-2"></i> Actividad␍
</a> <!-- Módulo TNS -->
<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="/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-sign-out-alt w-5 mr-2"></i> Salir␍ <i class="fas fa-paper-plane w-5 mr-2"></i>
</a> <span class="flex-1">TNS</span>
</nav> <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>
<div class="absolute bottom-0 left-0 right-0 px-4 py-3 border-t border-gray-700"> </a>
<div class="flex items-center"> {% if cur == '/envios/tns' %}
<div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-sm font-bold"> <div class="ml-4 border-l border-gray-700 pl-3">
{{ user.username[0]|upper }}␍ <p class="text-xs text-gray-500 py-0.5">Terceros · Transacción · Ventas</p>
</div> <p class="text-xs text-gray-500 py-0.5">Prueba RDA · Automatización</p>
<div class="ml-3"> </div>
<p class="text-sm font-medium text-white">{{ user.username }}</p> {% endif %}
<p class="text-xs text-gray-400">Sesión activa</p>
</div> <!-- Módulo ERP Lab -->
</div> <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 %}">
</div> <i class="fas fa-flask w-5 mr-2"></i>
</div> <span class="flex-1">ERP Lab</span>
<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>
<!-- Main content --> </a>
<div class="pl-64"> {% if cur == '/envios/erp' %}
<header class="bg-white shadow-sm border-b border-gray-200"> <div class="ml-4 border-l border-gray-700 pl-3">
<div class="flex items-center justify-between h-16 px-8"> <p class="text-xs text-gray-500 py-0.5">Pacientes · Sync Automático</p>
<h1 class="text-xl font-semibold text-gray-800">{% block header %}Dashboard{% endblock %}</h1> </div>
<div class="flex items-center space-x-4"> {% endif %}
<span class="text-sm text-gray-500">
<i class="far fa-calendar-alt mr-1"></i> <hr class="my-3 border-gray-700">
<script>document.write(new Date().toLocaleDateString('es-CO', {year:'numeric',month:'long',day:'numeric'}))</script> <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 %}">
</span> <i class="fas fa-history w-5 mr-2"></i> Historial TNS
</div> </a>
</div> <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 %}">
</header> <i class="fas fa-user-clock w-5 mr-2"></i> Actividad
<main class="p-8"> </a>
{% block content %}{% endblock %}␍ <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">
</main> <i class="fas fa-sign-out-alt w-5 mr-2"></i> Salir
</div> </a>
{% else %}␍ </nav>
<main> <div class="absolute bottom-0 left-0 right-0 px-4 py-3 border-t border-gray-700">
{% block auth_content %}{% endblock %}␍ <div class="flex items-center">
</main> <div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-sm font-bold">
{% endif %}␍ {{ user.username[0]|upper }}
</div> </div>
<div class="ml-3">
<script> <p class="text-sm font-medium text-white">{{ user.username }}</p>
console.log('%c RIPS Manager v1.0 ', 'background:#1e3a8a;color:white;font-size:18px;font-weight:bold;padding:8px 12px;border-radius:4px;'); <p class="text-xs text-gray-400">Sesión activa</p>
console.log('%c Desarrollado por U-SITE SAS BIC', 'font-size:14px;'); </div>
console.log('%c https://u-site.app/', 'color:#2563eb;font-size:12px;'); </div>
console.log('%c Sistema de generacion y envio de RIPS (Res. 2275/2023)', 'font-style:italic;font-size:12px;'); </div>
</div>
async function fetchJSON(url, options = {}) { {% endif %}
const resp = await fetch(url, options);
return await resp.json(); <!-- Main content -->
} <div class="{% if not embedded %}pl-64{% endif %}">
{% if not embedded %}
function showToast(message, type = 'success') { <header class="bg-white shadow-sm border-b border-gray-200">
const colors = {success: 'bg-green-500', error: 'bg-red-500', info: 'bg-blue-500', warning: 'bg-yellow-500'}; <div class="flex items-center justify-between h-16 px-8">
const toast = document.createElement('div'); <h1 class="text-xl font-semibold text-gray-800">{% block header %}Dashboard{% endblock %}</h1>
toast.className = `fixed top-4 right-4 z-50 ${colors[type]} text-white px-6 py-3 rounded-lg shadow-lg transition-all duration-500`; <div class="flex items-center space-x-4">
toast.textContent = message; <span class="text-sm text-gray-500">
document.body.appendChild(toast); <i class="far fa-calendar-alt mr-1"></i>
setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 500); }, 4000); <script>document.write(new Date().toLocaleDateString('es-CO', {year:'numeric',month:'long',day:'numeric'}))</script>
} </span>
</div>
function showLoading(btn) { </div>
btn.disabled = true; </header>
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i> Procesando...'; {% endif %}
} <main class="{% if not embedded %}p-8{% else %}p-4{% endif %}">
{% block content %}{% endblock %}
function hideLoading(btn, text) { </main>
btn.disabled = false; </div>
btn.innerHTML = text; {% else %}
} <main>
</script> {% block auth_content %}{% endblock %}
</body> </main>
</html> {% endif %}
</div>
<script>
console.log('%c RIPS Manager v1.0 ', 'background:#1e3a8a;color:white;font-size:18px;font-weight:bold;padding:8px 12px;border-radius:4px;');
console.log('%c Desarrollado por U-SITE SAS BIC', 'font-size:14px;');
console.log('%c https://u-site.app/', 'color:#2563eb;font-size:12px;');
console.log('%c Sistema de generacion y envio de RIPS (Res. 2275/2023)', 'font-style:italic;font-size:12px;');
async function fetchJSON(url, options = {}) {
const resp = await fetch(url, options);
return await resp.json();
}
function showToast(message, type = 'success') {
const colors = {success: 'bg-green-500', error: 'bg-red-500', info: 'bg-blue-500', warning: 'bg-yellow-500'};
const toast = document.createElement('div');
toast.className = `fixed top-4 right-4 z-50 ${colors[type]} text-white px-6 py-3 rounded-lg shadow-lg transition-all duration-500`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 500); }, 4000);
}
function showLoading(btn) {
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i> Procesando...';
}
function hideLoading(btn, text) {
btn.disabled = false;
btn.innerHTML = text;
}
</script>
</body>
</html>
+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)