From 8e272253263453b0025b3b2479760d15e11d7c1d Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:13:48 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20m=C3=B3dulos=20de=20env=C3=ADo=20TNS/ER?= =?UTF-8?q?P=20Lab=20+=20sync=20de=20m=C3=A9dico,=20empresa=20y=20valor=20?= =?UTF-8?q?total?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/routes/envios.py | 26 +++ app/routes/pacientes.py | 38 +++-- app/services/scheduler.py | 140 +++++++++++++++ app/services/whatsapp_sync.py | 10 +- app/templates/base.html | 312 ++++++++++++++++++---------------- app/templates/envios_erp.html | 198 +++++++++++++++++++++ app/templates/envios_tns.html | 103 +++++++++++ main.py | 14 +- 8 files changed, 676 insertions(+), 165 deletions(-) create mode 100644 app/routes/envios.py create mode 100644 app/services/scheduler.py create mode 100644 app/templates/envios_erp.html create mode 100644 app/templates/envios_tns.html diff --git a/app/routes/envios.py b/app/routes/envios.py new file mode 100644 index 0000000..2ee658c --- /dev/null +++ b/app/routes/envios.py @@ -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, + }) diff --git a/app/routes/pacientes.py b/app/routes/pacientes.py index 536fa8c..e6c0689 100644 --- a/app/routes/pacientes.py +++ b/app/routes/pacientes.py @@ -39,14 +39,21 @@ _SQL_EXAMENES_CEDULA = """ SELECT 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(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) -JOIN PACIENTE p ON p.CODIGO = r.COD_PACIENTE +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 TRIM(p.DOCIDENT) = :cedula AND r.FECHA_RECEPCION = CURRENT_DATE ORDER BY r.IDRECEPCION DESC @@ -123,12 +130,17 @@ async def examenes_paciente(request: Request, cedula: str = ""): if hora_dt and hora_dt < limite: continue examenes.append({ - "recepcion_id": row.get("IDRECEPCION"), - "hora": row.get("HORAINICIORECEPCION"), - "cod_examen": (row.get("COD_EXAMEN") or "").strip(), - "nombre": (row.get("NOM_EXAMEN") or "").strip(), - "cups": (row.get("CUPS") or "").strip(), - "precio": row.get("PRECIO"), + "recepcion_id": row.get("IDRECEPCION"), + "hora": row.get("HORAINICIORECEPCION"), + "cod_examen": (row.get("COD_EXAMEN") or "").strip(), + "nombre": (row.get("NOM_EXAMEN") or "").strip(), + "cups": (row.get("CUPS") or "").strip(), + "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}) diff --git a/app/services/scheduler.py b/app/services/scheduler.py new file mode 100644 index 0000000..047f73b --- /dev/null +++ b/app/services/scheduler.py @@ -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") diff --git a/app/services/whatsapp_sync.py b/app/services/whatsapp_sync.py index 4845919..605eee1 100644 --- a/app/services/whatsapp_sync.py +++ b/app/services/whatsapp_sync.py @@ -106,10 +106,13 @@ async def sync_paciente( api_key: str, client: Optional[httpx.AsyncClient] = None, modo: str = "upsert", + examenes: Optional[list] = None, ) -> dict: """Envía un paciente al endpoint de WhatsApp. Retorna {ok, action, message}.""" payload = mapear_paciente(row) payload["modo"] = modo + if examenes: + payload["examenes"] = examenes headers = { "Content-Type": "application/json", "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}.""" resultado = {"total": len(rows), "created": 0, "skipped": 0, "updated": 0, "errores": 0, "detalle": []} async with httpx.AsyncClient(timeout=timeout) as client: 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"]: action = r["action"] if action == "created": diff --git a/app/templates/base.html b/app/templates/base.html index 6ccf29c..af2b933 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,151 +1,165 @@ - - - - - - RIPS Manager - {% block title %}Dashboard{% endblock %} - - - - - - - - - - - -
- {% if user %} - -
-
- - RIPS Manager -
- +
+
+
+ {{ user.username[0]|upper }} +
+
+

{{ user.username }}

+

Sesión activa

+
+
+
+
+ {% endif %} + + +
+ {% if not embedded %} +
+
+

{% block header %}Dashboard{% endblock %}

+
+ + + + +
+
+
+ {% endif %} +
+ {% block content %}{% endblock %} +
+
+ {% else %} +
+ {% block auth_content %}{% endblock %} +
+ {% endif %} +
+ + + + diff --git a/app/templates/envios_erp.html b/app/templates/envios_erp.html new file mode 100644 index 0000000..6f44410 --- /dev/null +++ b/app/templates/envios_erp.html @@ -0,0 +1,198 @@ +{% extends "base.html" %} +{% block title %}ERP Lab{% endblock %} +{% block header %} ERP Lab · Ximena Caicedo{% endblock %} +{% block content %} +
+ + +
+
+ + +
+
+ + +
+ + + + + + + +
+
+ + +{% endblock %} diff --git a/app/templates/envios_tns.html b/app/templates/envios_tns.html new file mode 100644 index 0000000..adc5e0d --- /dev/null +++ b/app/templates/envios_tns.html @@ -0,0 +1,103 @@ +{% extends "base.html" %} +{% block title %}Módulo TNS{% endblock %} +{% block header %} Módulo TNS{% endblock %} +{% block content %} +
+ + +
+
+ + + + + +
+
+ + +
+ + + + + + + + + + + +
+
+ + +{% endblock %} diff --git a/main.py b/main.py index dcd9ed7..42572ee 100644 --- a/main.py +++ b/main.py @@ -8,11 +8,14 @@ from fastapi import FastAPI, Request from fastapi.responses import RedirectResponse from fastapi.templating import Jinja2Templates import uvicorn +from apscheduler.schedulers.asyncio import AsyncIOScheduler from app.database import init_db from app.auth import decode_token +from app.services.scheduler import sync_recientes app = FastAPI(title="RIPS Manager", version="1.0.0") +_scheduler = AsyncIOScheduler() templates = Jinja2Templates( directory=os.path.join(os.path.dirname(__file__), "app", "templates") @@ -60,6 +63,14 @@ async def startup(): config_defaults() query_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("/") @@ -67,12 +78,13 @@ async def root(): 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(dashboard.router) app.include_router(config.router) app.include_router(queries.router) +app.include_router(envios.router) app.include_router(terceros.router) app.include_router(transaccion.router) app.include_router(logs.router)