diff --git a/auth/permisos.ts b/auth/permisos.ts index ee313d8cf1..2aa55206b6 100644 --- a/auth/permisos.ts +++ b/auth/permisos.ts @@ -137,6 +137,7 @@ export default [ child: [ { key: 'tipoPrestacion', title: 'Tipo de prestación', type: 'prestacion', avoidAll: true }, { key: 'validacion', title: 'Validación extras', type: 'prestacion', avoidAll: true }, + { key: 'prevalidacion', title: 'Pre-validación (doble firma)', type: 'prestacion', avoidAll: true }, { key: 'servicio-intermedio', title: 'Servicio Intermedio', type: 'servicio-intermedio', avoidAll: true, visibility: 'restricted' }, { key: 'plantilla', title: 'Cargar o editar plantillas', type: 'boolean', visibility: 'restricted' } ] diff --git a/config.ts b/config.ts index 8a75d27a24..0699642393 100644 --- a/config.ts +++ b/config.ts @@ -183,6 +183,12 @@ export const modules = { export const defaultLimit = 50; export const maxLimit = 1000; +// Configuración RUP +export const rup = { + // Tope de firmas de pre-validación (residentes) por informe. Ver docs/rup/0001-doble-firma-informe.md + maxFirmasPrevalidacion: 4 +}; + // Configuracion MPI export const algoritmo = 'Levenshtein'; diff --git a/docs/rup/0001-doble-firma-informe.md b/docs/rup/0001-doble-firma-informe.md new file mode 100644 index 0000000000..a5c9d7f226 --- /dev/null +++ b/docs/rup/0001-doble-firma-informe.md @@ -0,0 +1,243 @@ +--- +tags: + - rup + - auth + - pdf +scope: + - api + - app +--- + +# ADR 0001: Doble firma del informe y rol del profesional firmante + +- **Estado:** Aceptada. Enmendada el 2026-10-01 (el rol se deriva de permisos en esta etapa, ver "Decisión"), el 2026-10-19 (pre-validar requiere el permiso explícito `rup:prevalidacion`; etapa 1 implementada, ver "Historial de cambios") y el 2026-10-31 (se elimina el estado `pendienteValidacion` y la cola de pendientes del punto de inicio; "Validar" se oculta solo a quien tiene `rup:prevalidacion`). +- **Fecha:** 2026-10-01 +- **Repos afectados:** `api`, `app` + +> **Este documento es vivo.** Cualquier cambio posterior que afecte a lo decidido acá +> (modelo de firmas, rol del profesional, permisos, estados, atribución en estadísticas) +> debe actualizar este ADR en el mismo PR. Si la decisión cambia de fondo, no se borra: +> se marca como `Reemplazada` y se crea un ADR nuevo que la referencie. + +## Contexto + +Hoy el informe médico (una Prestación RUP cuyo último estado es `validada`) tiene un único +firmante: quien presiona "Validar". Esa identidad sale del JWT y se guarda en +`estados[].createdBy`. La producción estadística le atribuye el informe a esa persona, aun +cuando lo haya elaborado un médico residente (práctica habitual en hospitales escuela). + +La atribución no es uniforme, y eso agrava el problema: el turno (`turno.profesional`), el +informe PDF y la codificación (que alimenta PECAS) toman al validador, mientras que otros +listados de producción toman `solicitud.profesional`, que es quien ejecutó o fue asignado. +Según el reporte que se mire, el mismo informe se le asigna a personas distintas. + +Se requiere mostrar y registrar correctamente: + +- a los profesionales que elaboran el informe (residentes o médicos de planta), +- al profesional que valida, +- la firma de cada uno, +- todos ellos en el informe final. + +Debe contemplarse: elaboración y validación por la misma persona (firma única), elaboración +por residente y validación por otro, elaboración por varios residentes con una única +validación del médico de planta, informes históricos y modificación de informes existentes. + +Hoy el sistema no tiene el concepto de residente ni una lista de residentes. Un usuario es +un login (`AuthUsers`, con permisos Shiro) vinculado a un `Profesional` (matrícula, firmas) +por `documento == username`. + +## Decisión + +### Quién puede qué + +1. **Pre-validar** (firmar antes de la validación): un usuario que sea **profesional** + (tiene un `Profesional` asociado en su sesión) y que tenga el permiso Shiro + `rup:prevalidacion:`. Este permiso es el que se asigna a los + médicos residentes. (Enmienda 2026-10-19: antes la pre-validación estaba abierta + a cualquier profesional; ahora requiere el permiso explícito.) +2. **Validar:** cualquier profesional que hoy pueda validar la prestación (creador, o quien + tiene `rup:validacion:` para validar prestaciones ajenas), **salvo** que + tenga `rup:prevalidacion:`. (Enmienda 2026-10-31: `rup:validacion` NO es + requisito para validar; nunca lo fue. Ese permiso habilita abrir y validar prestaciones de + otros en el punto de inicio. Exigirlo para el botón "Validar" fue una regresión: ocultaba el + botón a médicos que validaban sus propias prestaciones sin ese permiso.) +3. Un profesional con `rup:prevalidacion` **solo puede pre-validar**: tener ese permiso es, + para el sistema, lo que define al residente. Por convención administrativa, un usuario no + debería tener `rup:prevalidacion` y `rup:validacion` a la vez; si los tuviera, la + derivación de rol da prioridad a validador y la API **rechaza** la pre-validación (debe + validar directo). Enmienda 2026-10-21. + +### Rol derivado de permisos (en esta etapa) + +No existe una lista de residentes. Por eso el rol del firmante **se deriva de los permisos** +y no se agrega ningún atributo a `Profesional` en esta etapa: + +- Firma con permiso de validación: figura como validador (médico de planta). +- Firma sin permiso de validación: figura como firma previa (residente). + +La derivación está encapsulada en `deriveRolFirmante(req, tipoPrestacionId)` +(`modules/rup/controllers/prestacion.ts`): devuelve `'validador'` si el usuario tiene +`rup:validacion:`, `'prevalidador'` en caso contrario. Es el único punto a tocar +si más adelante se agrega un atributo explícito de rol en `Profesional`. + +El rol que se calculó al firmar se **guarda en la propia firma** (snapshot). Si los permisos +de la persona cambian después, los informes ya firmados conservan el rol que tenía al firmar. + +La derivación debe estar encapsulada en un único punto del código, de modo que agregar más +adelante un atributo explícito en `Profesional` (la opción híbrida evaluada) no obligue a +tocar a los consumidores. + +### Modelo de datos + +- `firmasPrevalidacion`: **lista** de firmas previas. Cada una guarda el profesional + (`id`, `nombre`, `apellido`, `documento`), el rol (snapshot), las **matrículas** vigentes + (snapshot en texto, obtenidas con `searchMatriculas` y con el mismo formato que el pie del + informe PDF: `Título MP 123 - Especialidad ME 456`) y la fecha. + **Implementado** (`modules/rup/schemas/prestacion.ts`, subschema `FirmaPrevalidacionSchema`). +- `firmaValidacion`: **una sola** firma, con la misma forma. **Diferido en esta etapa:** + como la validación no se modifica (ver enmienda 2026-10-19), el validador se deriva en + lectura de `estadoActual.createdBy` del estado `validada` (igual que para firma única e + informes históricos). El campo propio se agregará cuando se decida tocar el flujo de validación. +- Las firmas previas no se derivan de `estados[]`, porque un estado tiene un único + `createdBy`. Todas las firmas son rastreables. +- **La pre-validación no cambia el estado** (enmienda 2026-10-31). La prestación sigue en + `ejecucion` y acumula firmas en `firmasPrevalidacion`; varios residentes pueden sumar la + suya (`$push` atómico con anti-duplicado y tope en el filtro). La cantidad de firmas se + muestra como **badge** sobre la prestación en los listados del punto de inicio (agenda, + sobreturnos y fuera de agenda) y en el panel de firmas de la pantalla de validación. + **Reemplaza** al estado `pendienteValidacion` de la etapa 1: los usuarios reportaron que un + estado nuevo les generaba problemas en los flujos existentes (acciones del punto de inicio + gateadas por `ejecucion`, filtros, reportes) y saturaba la pantalla junto con la cola + dedicada. El estado se eliminó del enum sin migración: no llegó a producción. + +Flujo: + +``` +ejecucion (0..4 firmas de pre-validación) ──(valida)──► validada [doble firma o firma única] +``` + +- **Quitar pre-validación** (op `quitarPrevalidacion`, análoga a `romperValidacion`): saca una + firma de `firmasPrevalidacion` (p. ej. el residente se equivocó). La puede quitar el propio + firmante o un validador (`rup:validacion`); quitar la firma de otro sin ese permiso se + rechaza. Solo con la prestación en `ejecucion`. `$pull` atómico condicionado a que la firma + exista. El estado no cambia. +- **Romper validación** (op `romperValidacion`): la prestación vuelve a `ejecucion` y + `firmasPrevalidacion` se limpia, porque las firmas avalaban el contenido validado. Quedan en + la copia de `prestacionesHistorial`; si corresponde, los residentes vuelven a pre-validar. +- El validador puede corregir el contenido antes de validar. No hay rechazo ni devolución. +- Cualquier médico con el permiso puede validar; no se asigna un validador específico. +- En la firma única, el validador no tiene firmas previas y se muestra una sola firma. + +### Reglas del conjunto de firmas + +- **Tope:** hasta 4 firmas previas (residentes) y 1 validador (médico de planta). Se hace + cumplir en la API, no solo en la interfaz. El valor vive en `config.ts` + (`rup.maxFirmasPrevalidacion`) y la app lo obtiene de + `GET /api/modules/rup/prevalidacion/config` → `{ maxFirmasPrevalidacion }`, para no duplicarlo. +- **Concurrencia:** las firmas previas se agregan de forma atómica (`$push`); varios + residentes pueden firmar al mismo tiempo y ninguna se puede perder. +- **Sin duplicados:** un mismo profesional no puede figurar dos veces. + +### Presentación en el PDF + +- Orden: primero los residentes (firmas previas), al final el médico de planta (validador). +- Layout dinámico que escala con la cantidad de firmas sin desbordar la página. + +### Estadísticas + +- **Reportes institucionales:** cuentan informes (prestaciones), no firmantes. No se ven + afectados por la cantidad de firmas. +- **Reportes por profesional:** cuentan los informes en los que participó el profesional, + como firmante previo o como validador, y cada uno suma el informe completo (no se + reparte). El fin es académico: el residente debe poder demostrar cuántos informes e + intervenciones tuvo. + +## Alternativas consideradas + +| Alternativa | Resultado | +|---|---| +| **Solo perfil de permisos** (residente = profesional sin `rup:validacion`) | **Adoptada para esta etapa.** Evita tocar el schema core `Profesional` y no exige cargar una lista que no existe. Riesgo en "Consecuencias". | +| **Híbrida:** atributo de rol en `Profesional` para el rótulo, permiso para la capacidad, snapshot en la firma | Evaluada primero y aceptada. Se pospone porque no existe una lista de residentes para poblar el atributo. Es la evolución prevista si el riesgo de rótulo erróneo se materializa. | +| **Solo atributo en `Profesional`** | Descartada: crea dos fuentes de verdad (rol y permisos) que pueden desincronizarse, y sin snapshot cambiar el rol reescribiría los informes históricos. | + +## Consecuencias + +**Positivas** + +- Una sola fuente de verdad (los permisos); no hay desincronización posible entre rol y + capacidad. +- No se modifica el schema core de `Profesional` ni hace falta migrarlo. +- Los informes firmados son estables en el tiempo (snapshot). +- Los informes históricos no requieren migración: sin `firmasPrevalidacion` ni + `firmaValidacion`, se resuelve en lectura con el validador como único firmante + (`estadoActual.createdBy`). + +**Negativas / a vigilar** + +- **Rótulo implícito.** "Profesional sin permiso de validación" no equivale a "residente". + El requisito de ser profesional descarta a administrativos, pero un profesional que no sea + médico (por ejemplo de otra disciplina) con acceso a RUP y sin el permiso figuraría como + residente. Mitigación: el snapshot, y mostrar el rol por función si hace falta. Si el + problema aparece, se pasa a la opción híbrida. +- **La API hoy no exige `rup:validacion` para validar.** Se verificó que el permiso solo se + consulta en la API al romper una validación (`routes/prestacion.ts`, op `romperValidacion`); + la operación `estadoPush` con `validada` no lo chequea. Hoy el control es solo visual en + la app (botón). La asociación del profesional con tipos de prestación + (`rup:tipoPrestacion:`) tampoco lo cubre: filtra listados y es un permiso distinto + (puede trabajar con el tipo, no puede validarlo), y `PATCH /prestaciones/:id` no lo + consulta. Hacer cumplir la regla en la API es parte de este cambio, y puede + **cortar flujos que hoy validan sin el permiso** (por ejemplo internación, que ya se + exceptúa en `romperValidacion` con `desdeInternacion`). Hay que relevarlos antes de + activar el control. + - **Decisión 2026-10-19 (revertida el 2026-10-31):** se había gateado el botón "Validar" + de `prestacionValidacion` por `rup:validacion:`. Eso materializó el riesgo de arriba: + médicos que validaban sus propias prestaciones sin `rup:validacion` dejaron de ver el + botón. Se revierte. + - **Decisión 2026-10-31:** el botón "Validar" se muestra como antes de la doble firma, y se + oculta **únicamente** a quien tiene `rup:prevalidacion:` (residente). El backend + (`estadoPush` con `validada`) sigue sin exigir permiso alguno, y la validación de + internación (`listado-internacion`, flujo propio por egreso) queda **sin tocar**. El + enforcement real en la API sigue pendiente de relevamiento. + - **UI excluyente:** el botón "Pre-validar" no se muestra a quien tiene `rup:validacion` + (un médico de planta valida directo, no pre-valida), reforzando en la interfaz la + convención de que ambos permisos son excluyentes. + +> **Observación — principio general de gateo (importante).** +> La pre-validación es una capacidad de acceso de **primera clase**, no un caso aparte. +> Históricamente, muchos puntos del código gatean el acceso a una prestación por +> «la creó el usuario actual **o** tiene `rup:validacion`». Todo gateo de ese tipo debe +> contemplar **además** `rup:prevalidacion`, o el residente queda afuera de prestaciones +> que debería poder abrir, continuar y pre-validar. Esto aplica de forma transversal: +> listados, apertura/continuación de prestaciones y acciones, tanto **por agenda** como +> **fuera de agenda**. Casos ya corregidos en el punto de inicio RUP: el filtro de +> «fuera de agenda», `checkPuedeValidar` y `tienePermisos`. Al tocar cualquier flujo que +> decida visibilidad o acciones sobre una prestación, revisar que la condición incluya los +> tres: creador, validador (`rup:validacion`) y pre-validador (`rup:prevalidacion`). +- Las consultas de estadísticas por profesional deben considerar tanto `firmasPrevalidacion` + como `firmaValidacion`, y conviene indexar ambos campos. +- El snapshot duplica un dato a propósito; es el costo de la estabilidad histórica. + +## Alcance y puntos abiertos + +Pendiente de definir: + +- Orden de las firmas entre los residentes (se propone orden de firma). +- Relevamiento de flujos que validan sin el permiso, antes de hacer cumplir la regla en la API. +- Cómo se muestra el rol en el informe (rótulo "Residente" o por función). + +## Historial de cambios del documento + +| Fecha | Cambio | +|---|---| +| 2026-10-01 | Versión inicial. Decisión híbrida aceptada. | +| 2026-10-01 | Corregido el contexto tras verificar contra el código: la atribución no es siempre al validador, es inconsistente entre reportes. | +| 2026-10-01 | Se agrega el caso de varios residentes: las firmas previas pasan a ser una lista y la validación sigue siendo una. | +| 2026-10-01 | Decididos: tope de firmas, producción completa para cada firmante, trazabilidad por lista propia, layout dinámico. | +| 2026-10-01 | Confirmado el tope: 4 firmas previas (residentes) + 1 validador (médico de planta). | +| 2026-10-01 | Confirmados el estado `pendienteValidacion` y los nombres `firmasPrevalidacion` / `firmaValidacion`. El rol pasa a derivarse de permisos en esta etapa (no hay lista de residentes); la opción híbrida queda como evolución. Se documentan las reglas de quién pre-valida y valida, y que la API hoy no exige el permiso para validar. Orden del PDF: residentes primero, planta al final. Estadísticas: institucional por informes, por profesional por participación. | +| 2026-10-01 | Precisado que `rup:tipoPrestacion` no reemplaza al control de `rup:validacion` en la API. | +| 2026-10-19 | Visibilidad en punto de inicio RUP: el residente (permiso `rup:prevalidacion`) ve y puede continuar/pre-validar prestaciones que no creó, tanto fuera de agenda como por agenda (antes el filtro solo contemplaba creador o permiso de validación). | +| 2026-10-19 | Implementación etapa 1. Pre-validar pasa a requerir el permiso explícito `rup:prevalidacion:` (el que tendrán los residentes); rol sigue derivándose de permisos vía `deriveRolFirmante`. Se implementan `firmasPrevalidacion` (lista, `$push` atómico, tope 4, anti-duplicado), el estado `pendienteValidacion` (admite pre-firmar en `ejecucion` o `pendienteValidacion`) y el permiso en `permisos.ts`. `firmaValidacion` se difiere: la validación NO se modifica y el validador se deriva de `estadoActual.createdBy`. Enforcement de validación: solo UI ambulatoria (botón gateado por `rup:validacion`); internación sin tocar; backend sin exigir el permiso. | +| 2026-10-21 | Ubicación: `docs/rup/`, con metadata (`tags`, `scope`) en front matter. Tope de firmas movido a `config.ts`. La API rechaza la pre-validación de usuarios con `rup:validacion`. Matrícula de la firma vía `searchMatriculas` (vigencia, posgrado, matrícula externa). `quitarPrevalidacion` rechaza quitar firmas ajenas sin permiso de validación. | +| 2026-10-21 | Resuelto el punto abierto de `romperValidacion`: al volver a `ejecucion` se limpian las firmas de pre-validación (quedan en el historial). | +| 2026-10-31 | Feedback de usuarios. (1) Se elimina la cola "Pendientes de validación" del punto de inicio: saturaba la pantalla junto con agendas y fuera de agenda. (2) Se elimina el estado `pendienteValidacion`: la pre-validación ya no cambia el estado, la prestación sigue en `ejecucion` y los listados muestran un badge con la cantidad de firmas; `quitarPrevalidacion` opera en `ejecucion` y no transiciona. (3) Se revierte el gate de "Validar" por `rup:validacion` (regresión: ocultaba el botón a médicos que validaban sin ese permiso); ahora solo se oculta a quien tiene `rup:prevalidacion`. | diff --git a/modules/rup/controllers/prestacion.ts b/modules/rup/controllers/prestacion.ts index 96d7bb7e18..e2303be5ac 100644 --- a/modules/rup/controllers/prestacion.ts +++ b/modules/rup/controllers/prestacion.ts @@ -1,6 +1,7 @@ import { ObjectId } from '@andes/core'; import { Types } from 'mongoose'; import { Auth } from '../../../auth/auth.class'; +import { rup as rupConfig } from '../../../config'; import { userScheduler } from '../../../config.private'; import { AppCache } from '../../../connections'; import { PacienteCtr } from '../../../core-v2/mpi/paciente/paciente.routes'; @@ -11,6 +12,75 @@ import moment = require('moment'); import { tipoPrestacion } from '../../../core/tm/schemas/tipoPrestacion'; +/** + * Deriva el rol del profesional firmante a partir de sus permisos (ADR 0001). + * Único punto de derivación: si en el futuro se agrega un atributo explícito de + * rol en Profesional, solo cambia esta función. + * + * - Con permiso de validación sobre el tipo de prestación -> 'validador' (médico de planta) + * - Sin ese permiso (solo pre-validación) -> 'prevalidador' (residente) + * + * @export + * @param {*} req request autenticado + * @param {string} tipoPrestacionId id del tipo de prestación + * @returns {'validador' | 'prevalidador'} + */ +export function deriveRolFirmante(req, tipoPrestacionId: string): 'validador' | 'prevalidador' { + return Auth.check(req, 'rup:validacion:' + tipoPrestacionId) ? 'validador' : 'prevalidador'; +} + +/** + * Configuración de la pre-validación que necesita la app (ADR 0001), para no duplicar el tope. + */ +export function configPrevalidacion() { + return { maxFirmasPrevalidacion: rupConfig.maxFirmasPrevalidacion }; +} + +/** + * Estados en los que una prestación admite agregar o quitar una firma de pre-validación (ADR 0001). + * La pre-validación no cambia el estado: la prestación sigue en ejecución con N firmas. + */ +export function admitePrevalidacion(tipoEstado: string): boolean { + return tipoEstado === 'ejecucion'; +} + +/** + * Matrículas vigentes del profesional en texto, con el mismo formato que el pie del + * informe PDF ("Título MP 123 - Especialidad ME 456"). Devuelve null si no hay ninguna. + * + * @export + * @param {*} infoMatriculas resultado de searchMatriculas + * @returns {(string | null)} + */ +export function formatearMatriculas(infoMatriculas): string | null { + const grado = (infoMatriculas?.formacionGrado || []).map(e => `${e.nombre} MP ${e.numero}`); + const posgrado = (infoMatriculas?.formacionPosgrado || []).map(e => `${e.nombre} ME ${e.numero}`); + const matriculas = [...grado, ...posgrado]; + return matriculas.length ? matriculas.join(' - ') : null; +} + +/** + * Usuario del request con el mismo shape que arma el AuditPlugin (usuario u app + organización). + * Se usa en las operaciones atómicas (findOneAndUpdate), donde el plugin no corre. + */ +export function usuarioAuditoria(req) { + return { ...(req.user.usuario || req.user.app), organizacion: req.user.organizacion }; +} + +/** + * Las operaciones atómicas no hacen data.save(), así que la entrada de historial que + * updateRegistroHistorialSolicitud agregó en memoria (solo prestaciones 'top') se perdería. + * Devuelve esa entrada lista para un $push, o null si no corresponde. + */ +export function historialParaUpdateAtomico(prestacion, usuario) { + const historial = prestacion.inicio === 'top' && prestacion.solicitud?.historial; + if (!historial?.length) { + return null; + } + const ultima = historial[historial.length - 1]; + return { ...(ultima.toObject ? ultima.toObject() : ultima), createdAt: new Date(), createdBy: usuario }; +} + /** * Libera la referencia al turno dentro de la solicitud * @@ -60,6 +130,8 @@ export function updateRegistroHistorialSolicitud(solicitud, datos) { suspenderTurno: 'Turno suspendido', asignada: 'Profesional asignado', validada: 'Validada', + prevalidacion: 'Pre-validada', + quitarPrevalidacion: 'Pre-validación quitada', ejecucion: 'Ejecutada', romperValidacion: 'Ejecutada', anulada: 'Anulada', diff --git a/modules/rup/prevalidacion.test.ts b/modules/rup/prevalidacion.test.ts new file mode 100644 index 0000000000..51702be70f --- /dev/null +++ b/modules/rup/prevalidacion.test.ts @@ -0,0 +1,100 @@ +import { getFakeRequest } from '@andes/unit-test'; +import { rup as rupConfig } from '../../config'; +import { admitePrevalidacion, configPrevalidacion, deriveRolFirmante, formatearMatriculas, historialParaUpdateAtomico } from './controllers/prestacion'; + +/** + * Tests de la derivación de rol del firmante (ADR 0001). + * El rol NO se persiste en Profesional: se deriva de los permisos del usuario + * al momento de firmar y se guarda como snapshot en la firma. + */ +describe('deriveRolFirmante', () => { + const TIPO = '5a26e113291f463c1b982d98'; + + function reqConPermisos(permisos: string[]) { + const req: any = getFakeRequest(); + req.user.permisos = permisos; + return req; + } + + it('con permiso de validación sobre el tipo => validador (médico de planta)', () => { + const req = reqConPermisos(['rup:validacion:' + TIPO]); + expect(deriveRolFirmante(req, TIPO)).toBe('validador'); + }); + + it('con wildcard de validación => validador', () => { + const req = reqConPermisos(['rup:validacion:*']); + expect(deriveRolFirmante(req, TIPO)).toBe('validador'); + }); + + it('solo con permiso de pre-validación => prevalidador (residente)', () => { + const req = reqConPermisos(['rup:prevalidacion:' + TIPO]); + expect(deriveRolFirmante(req, TIPO)).toBe('prevalidador'); + }); + + it('sin permisos relevantes => prevalidador', () => { + const req = reqConPermisos(['mpi:paciente:*']); + expect(deriveRolFirmante(req, TIPO)).toBe('prevalidador'); + }); + + it('con ambos permisos gana validación (ADR: si valida, figura como validador)', () => { + const req = reqConPermisos(['rup:validacion:' + TIPO, 'rup:prevalidacion:' + TIPO]); + expect(deriveRolFirmante(req, TIPO)).toBe('validador'); + }); + + it('permiso de validación de OTRO tipo no aplica a este tipo', () => { + const req = reqConPermisos(['rup:validacion:otroTipoId']); + expect(deriveRolFirmante(req, TIPO)).toBe('prevalidador'); + }); +}); + +describe('admitePrevalidacion', () => { + it('admite solo ejecución (la pre-validación no cambia el estado)', () => { + expect(admitePrevalidacion('ejecucion')).toBe(true); + }); + + it('no admite otros estados', () => { + expect(admitePrevalidacion('validada')).toBe(false); + expect(admitePrevalidacion('pendiente')).toBe(false); + expect(admitePrevalidacion('anulada')).toBe(false); + expect(admitePrevalidacion('pendienteValidacion')).toBe(false); + }); +}); + +describe('formatearMatriculas', () => { + it('arma grado (MP) y posgrado (ME) con el formato del informe PDF', () => { + const info = { + formacionGrado: [{ nombre: 'Médico', numero: 1234 }], + formacionPosgrado: [{ nombre: 'Cardiología', numero: 55 }] + }; + expect(formatearMatriculas(info)).toBe('Médico MP 1234 - Cardiología ME 55'); + }); + + it('sin matrículas vigentes => null', () => { + expect(formatearMatriculas(null)).toBeNull(); + expect(formatearMatriculas({ formacionGrado: [], formacionPosgrado: [] })).toBeNull(); + }); +}); + +describe('historialParaUpdateAtomico', () => { + const usuario = { username: '30000000' }; + + it('devuelve la última entrada de historial auditada en prestaciones top', () => { + const prestacion = { inicio: 'top', solicitud: { historial: [{ accion: 'creacion' }, { accion: 'prevalidacion' }] } }; + const entrada: any = historialParaUpdateAtomico(prestacion, usuario); + expect(entrada.accion).toBe('prevalidacion'); + expect(entrada.createdBy).toBe(usuario); + expect(entrada.createdAt).toBeInstanceOf(Date); + }); + + it('fuera de top no corresponde historial => null', () => { + const prestacion = { inicio: 'agenda', solicitud: { historial: [{ accion: 'prevalidacion' }] } }; + expect(historialParaUpdateAtomico(prestacion, usuario)).toBeNull(); + }); +}); + +describe('configPrevalidacion', () => { + it('expone el tope de firmas definido en config', () => { + expect(configPrevalidacion()).toEqual({ maxFirmasPrevalidacion: rupConfig.maxFirmasPrevalidacion }); + expect(rupConfig.maxFirmasPrevalidacion).toBe(4); + }); +}); diff --git a/modules/rup/routes/prestacion.ts b/modules/rup/routes/prestacion.ts index bfa5a2a5da..9d51bde482 100755 --- a/modules/rup/routes/prestacion.ts +++ b/modules/rup/routes/prestacion.ts @@ -4,8 +4,10 @@ import { EventCore } from '@andes/event-bus'; import * as express from 'express'; import * as moment from 'moment'; import { Types } from 'mongoose'; +import { rup as rupConfig } from '../../../config'; import { AppCache } from '../../../connections'; import { PacienteCtr } from '../../../core-v2/mpi/paciente/paciente.routes'; +import { searchMatriculas } from '../../../core/tm/controller/profesional'; import { SnomedCtr } from '../../../core/term/controller/snomed.controller'; import { getObraSocial } from '../../../modules/obraSocial/controller/obraSocial'; import { removeDiacritics } from '../../../utils/utils'; @@ -15,7 +17,7 @@ import { getTurnoById } from '../../turnos/controller/turnosController'; import { elementosRUPAsSet } from '../controllers/elementos-rup.controller'; import { dashboardSolicitudes } from '../controllers/estadisticas'; import * as frecuentescrl from '../controllers/frecuentesProfesional'; -import { hudsPaciente, saveEnHistorial, updateRegistroHistorialSolicitud } from '../controllers/prestacion'; +import { admitePrevalidacion, configPrevalidacion, deriveRolFirmante, formatearMatriculas, historialParaUpdateAtomico, hudsPaciente, saveEnHistorial, updateRegistroHistorialSolicitud, usuarioAuditoria } from '../controllers/prestacion'; import { registrosProfundidad } from '../controllers/rup'; import { buscarYCrearSolicitudes } from '../controllers/solicitudes.controller'; import { IPrestacionDoc } from '../prestaciones.interface'; @@ -26,6 +28,21 @@ import { Agenda } from '../../turnos/schemas/agenda'; const router = express.Router(); +const ERROR_ESTADO_PREVALIDACION = 'Solo se puede pre-validar una prestación en ejecución.'; + +/** + * Snapshot de matrículas para una firma de pre-validación. Reutiliza searchMatriculas + * (vigencia, baja, posgrado y matrícula externa), igual que el informe PDF. + * Es opcional: si falla la lectura, queda null. + */ +async function matriculasFirmante(profesionalId): Promise { + try { + return formatearMatriculas(await searchMatriculas(profesionalId)); + } catch (e) { + return null; + } +} + /** * * Buscar un determinado concepto snomed ya sea en una prestación especifica o en la huds completa de un paciente * @@ -163,8 +180,18 @@ router.post('/solicitudes/dashboard', async (req, res, next) => { }); +// Configuración de la pre-validación (tope de firmas), para que la app no la duplique. Ver ADR 0001. +router.get('/prevalidacion/config', (req, res) => { + return res.json(configPrevalidacion()); +}); + router.get('/prestaciones/servicio-intermedio', async (req: any, res, next) => { - const servicios = Auth.getPermissions(req, 'rup:servicio-intermedio:?').map(id => new Types.ObjectId(id)); + // getPermissions puede devolver el comodín '*' (p.ej. si el usuario tiene + // rup:servicio-intermedio:* o rup:*). ObjectId('*') lanza y tira el proceso, + // así que descartamos lo que no sea un ObjectId válido. + const servicios = Auth.getPermissions(req, 'rup:servicio-intermedio:?') + .filter(id => id !== '*' && Types.ObjectId.isValid(id)) + .map(id => new Types.ObjectId(id)); const query = { inicio: 'servicio-intermedio', servicioIntermedioId: MongoQuery.inArray(servicios), @@ -692,6 +719,120 @@ router.patch('/prestaciones/:id', (req: Request, res, next) => { } break; + case 'prevalidacion': { + // Pre-validación (doble firma). Opcional y aditiva: NO reemplaza ni + // modifica la validación. Ver ADR 0001. + const tipoPrestacionId = data.solicitud.tipoPrestacion.id; + + // Gate de permiso (enforcement real en la API para pre-validar) + if (!Auth.check(req, 'rup:prevalidacion:' + tipoPrestacionId)) { + return next('No tiene permiso para pre-validar este tipo de prestación.'); + } + if (!admitePrevalidacion(data.estadoActual.tipo)) { + return next(ERROR_ESTADO_PREVALIDACION); + } + const profesional = Auth.getProfesional(req); + if (!profesional?.id) { + return next('Solo un profesional puede pre-validar.'); + } + + // Pre-validación y validación son excluyentes (ADR 0001): quien puede validar, valida directo. + const rol = deriveRolFirmante(req, tipoPrestacionId); + if (rol === 'validador') { + return next('Tiene permiso de validación sobre este tipo de prestación: debe validar, no pre-validar.'); + } + + const firma = { + profesional, + rol, // snapshot del rol al firmar + matricula: await matriculasFirmante(profesional.id), // snapshot para el informe + fecha: new Date() + }; + const usuario = usuarioAuditoria(req); + const historial = historialParaUpdateAtomico(data, usuario); + + // La pre-validación NO cambia el estado: la prestación sigue en ejecución y + // acumula firmas. Se lee fresco para dar un mensaje claro (duplicado / tope) y + // luego se hace un $push atómico con anti-duplicado y tope forzados en el filtro, + // así dos residentes firmando a la vez no pierden ninguna firma ni superan el tope. + const fresh: any = await Prestacion.findById(data._id, { estadoActual: 1, firmasPrevalidacion: 1 }); + if (!fresh) { + return next(404); + } + if (!admitePrevalidacion(fresh.estadoActual.tipo)) { + return next(ERROR_ESTADO_PREVALIDACION); + } + const firmas = fresh.firmasPrevalidacion || []; + if (firmas.some(f => String(f.profesional.id) === String(profesional.id))) { + return next('Ya registró su pre-validación en esta prestación.'); + } + if (firmas.length >= rupConfig.maxFirmasPrevalidacion) { + return next(`Se alcanzó el máximo de firmas de pre-validación (${rupConfig.maxFirmasPrevalidacion}).`); + } + const prestacionActualizada: any = await Prestacion.findOneAndUpdate( + { + _id: data._id, + 'estadoActual.tipo': 'ejecucion', + 'firmasPrevalidacion.profesional.id': { $ne: profesional.id }, + $expr: { $lt: [{ $size: { $ifNull: ['$firmasPrevalidacion', []] } }, rupConfig.maxFirmasPrevalidacion] } + }, + { + $push: { + firmasPrevalidacion: firma, + ...(historial ? { 'solicitud.historial': historial } : {}) + }, + $set: { updatedAt: new Date(), updatedBy: usuario } + }, + { new: true } + ); + if (!prestacionActualizada) { + return next('No se pudo registrar la pre-validación, reintente.'); + } + EventCore.emitAsync('rup:prestacion:prevalidate', prestacionActualizada); + if (prestacionActualizada.paciente) { + AppCache.clear(`huds-${prestacionActualizada.paciente.id}`); + } + return res.json(prestacionActualizada); + } + case 'quitarPrevalidacion': { + // Quita una firma de pre-validación del informe (p. ej. el residente se + // equivocó). Análogo a romperValidacion pero para la pre-firma. Ver ADR 0001. + if (!admitePrevalidacion(data.estadoActual.tipo)) { + return next('Solo se puede quitar una pre-validación mientras la prestación está en ejecución.'); + } + const profesional = Auth.getProfesional(req); + const esValidador = Auth.check(req, 'rup:validacion:' + data.solicitud.tipoPrestacion.id); + // Sin firmaProfesionalId se quita la propia firma; quitar la de otro requiere ser validador. + const targetId = req.body.firmaProfesionalId || profesional?.id; + if (!targetId || !Types.ObjectId.isValid(targetId)) { + return next('No se pudo identificar la firma a quitar.'); + } + if (!esValidador && String(targetId) !== String(profesional?.id)) { + return next('Solo puede quitar su propia pre-validación.'); + } + const usuario = usuarioAuditoria(req); + const historial = historialParaUpdateAtomico(data, usuario); + + // $pull atómico, condicionado a que la firma siga existiendo. El estado no cambia: + // la prestación sigue en ejecución con una firma menos. + const prestacionActualizada: any = await Prestacion.findOneAndUpdate( + { _id: data._id, 'estadoActual.tipo': 'ejecucion', 'firmasPrevalidacion.profesional.id': targetId }, + { + $pull: { firmasPrevalidacion: { 'profesional.id': Types.ObjectId(targetId) } }, + $set: { updatedAt: new Date(), updatedBy: usuario }, + ...(historial ? { $push: { 'solicitud.historial': historial } } : {}) + }, + { new: true } + ); + if (!prestacionActualizada) { + return next('No hay una pre-validación de ese profesional para quitar.'); + } + EventCore.emitAsync('rup:prestacion:quitarPrevalidacion', prestacionActualizada); + if (prestacionActualizada.paciente) { + AppCache.clear(`huds-${prestacionActualizada.paciente.id}`); + } + return res.json(prestacionActualizada); + } case 'romperValidacion': if (data.estadoActual.tipo !== 'validada') { return next('Para poder romper la validación, primero debe validar la prestación.'); @@ -707,6 +848,10 @@ router.patch('/prestaciones/:id', (req: Request, res, next) => { idOrigenModifica: data.id }; const prestacionOld = await saveEnHistorial(data, estadoModificada, req); + // Las firmas de pre-validación avalan el contenido validado: al volver a ejecución + // se limpian (quedan en la copia del historial) y, si corresponde, se vuelve a firmar. + // Ver ADR 0001. + data.firmasPrevalidacion = []; data.estados.push({ tipo: 'ejecucion', idOrigenModifica: prestacionOld.id diff --git a/modules/rup/schemas/prestacion.ts b/modules/rup/schemas/prestacion.ts index 2a3c485d77..da80848530 100644 --- a/modules/rup/schemas/prestacion.ts +++ b/modules/rup/schemas/prestacion.ts @@ -8,6 +8,26 @@ import { PrestacionSolicitudHistorialschema } from './prestacion.solicitud.histo import { SemanticTag } from './semantic-tag'; import { PacienteSubSchema } from '../../../core-v2/mpi/paciente/paciente.schema'; +/** + * Firma de pre-validación (doble firma del informe). + * Guarda un snapshot del profesional firmante y del rol que tenía al firmar, + * para que informes ya firmados no cambien si después cambian sus permisos. + * Ver ADR 0001 (docs/rup/0001-doble-firma-informe.md). + */ +export const FirmaPrevalidacionSchema = new Schema({ + profesional: { + id: { type: Schema.Types.ObjectId, required: true }, + nombre: String, + apellido: String, + documento: String + }, + // Rol derivado de permisos al momento de firmar (snapshot): 'validador' | 'prevalidador' + rol: { type: String, required: true }, + // Matrículas vigentes al momento de firmar (snapshot, mismo formato que el pie del informe PDF) + matricula: { type: String, default: null }, + fecha: { type: Date, default: Date.now } +}, { _id: false }); + export const PrestacionSchema = new Schema({ /** * ID de un EVENTO externo (internacion, guardia, embarazo) para agrupar un conjunto de prestaciones referidas a ese evento @@ -171,6 +191,9 @@ export const PrestacionSchema = new Schema({ // Historia de estado de la prestación estados: [PrestacionEstadoSchema], estadoActual: PrestacionEstadoSchema, + // Firmas previas (residentes) de la doble firma. La firma del validador se + // deriva de estadoActual.createdBy del estado 'validada'. Ver ADR 0001. + firmasPrevalidacion: { type: [FirmaPrevalidacionSchema], default: [] }, unidadOrganizativa: SnomedConcept, periodosCensables: [] }, { usePushEach: true } as any); @@ -306,6 +329,8 @@ PrestacionSchema.methods.getRegistros = function (all = false) { PrestacionSchema.plugin(AuditPlugin); PrestacionSchema.index({ 'solicitud.turno': 1 }); PrestacionSchema.index({ 'paciente.id': 1, 'estadoActual.tipo': 1 }); +// Estadísticas por profesional firmante (producción académica del residente) +PrestacionSchema.index({ 'firmasPrevalidacion.profesional.id': 1 }, { sparse: true }); PrestacionSchema.index({ groupId: 1 }, { sparse: true }); PrestacionSchema.index({ 'solicitud.organizacion.id': 1,