From 1c840a25865a90f3046f50b16d61d28fb0c37476 Mon Sep 17 00:00:00 2001 From: Hugo Fernandez Date: Mon, 19 Oct 2026 20:45:27 -0300 Subject: [PATCH 01/11] =?UTF-8?q?feat(RUP):=20doble=20firma=20-=20pre-vali?= =?UTF-8?q?daci=C3=B3n=20de=20informes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agrega el flujo de pre-validación (doble firma) de informes RUP: - permiso rup:prevalidacion: y estado pendienteValidacion - lista firmasPrevalidacion con push atómico, tope 4 y anti-duplicado - operaciones prevalidacion y quitarPrevalidacion en el PATCH de prestaciones - rol del firmante derivado de permisos (deriveRolFirmante), con matrícula - fix: ObjectId sobre comodín en servicio-intermedio - actualiza ADR 0001 --- auth/permisos.ts | 1 + docs/adr/0001-doble-firma-informe.md | 211 +++++++++++++++++++++++ modules/rup/controllers/prestacion.ts | 19 ++ modules/rup/prevalidacion.test.ts | 47 +++++ modules/rup/routes/prestacion.ts | 141 ++++++++++++++- modules/rup/schemas/prestacion.estado.ts | 2 +- modules/rup/schemas/prestacion.ts | 25 +++ 7 files changed, 443 insertions(+), 3 deletions(-) create mode 100644 docs/adr/0001-doble-firma-informe.md create mode 100644 modules/rup/prevalidacion.test.ts 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/docs/adr/0001-doble-firma-informe.md b/docs/adr/0001-doble-firma-informe.md new file mode 100644 index 0000000000..0104497af3 --- /dev/null +++ b/docs/adr/0001-doble-firma-informe.md @@ -0,0 +1,211 @@ +# 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") y el 2026-10-19 (pre-validar requiere el permiso explícito `rup:prevalidacion`; etapa 1 implementada, ver "Historial de cambios"). +- **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:** un profesional que además tenga el permiso Shiro + `rup:validacion:`. +3. Un profesional con `rup:prevalidacion` y **sin** `rup:validacion` solo puede + pre-validar. Por convención administrativa, un usuario no debería tener ambos + permisos; si los tuviera, la derivación de rol da prioridad a validador. + +### 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), la **matrícula** (snapshot, + tomada de `formacionGrado[].matriculacion[].matriculaNumero` al firmar) 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. +- Se agrega el estado `pendienteValidacion`. La pre-validación se admite cuando la prestación + está en `ejecucion` o en `pendienteValidacion`, de modo que varios residentes puedan sumar + firmas; la primera pre-validación transiciona a `pendienteValidacion` y las siguientes solo + agregan su firma (transición idempotente, `$push` atómico con reintento ante carrera). + +Flujo: + +``` +ejecucion ──(pre-validación)──► pendienteValidacion ──(valida)──► validada [doble firma] +ejecucion ────────────────────────(valida directo)──────────────► validada [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`). Solo con la prestación en `pendienteValidacion`. + `$pull` atómico; si era la última firma, la prestación vuelve a `ejecucion`. +- 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. +- **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:** en esta etapa se agrega enforcement **solo en la UI** del flujo + ambulatorio: el botón "Validar" de `prestacionValidacion` ahora se muestra únicamente con + `rup:validacion:`. El backend (`estadoPush` con `validada`) sigue sin exigir el + permiso, 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). +- Comportamiento de las modificaciones de informes ya validados (`romperValidacion`) con el + nuevo modelo: qué pasa con las firmas existentes al volver a ejecució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. | diff --git a/modules/rup/controllers/prestacion.ts b/modules/rup/controllers/prestacion.ts index 96d7bb7e18..6f5c81b019 100644 --- a/modules/rup/controllers/prestacion.ts +++ b/modules/rup/controllers/prestacion.ts @@ -11,6 +11,23 @@ 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'; +} + /** * Libera la referencia al turno dentro de la solicitud * @@ -60,6 +77,8 @@ export function updateRegistroHistorialSolicitud(solicitud, datos) { suspenderTurno: 'Turno suspendido', asignada: 'Profesional asignado', validada: 'Validada', + prevalidacion: 'Pre-validada', + quitarPrevalidacion: 'Ejecutada', 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..a6bb9953c4 --- /dev/null +++ b/modules/rup/prevalidacion.test.ts @@ -0,0 +1,47 @@ +import { getFakeRequest } from '@andes/unit-test'; +import { deriveRolFirmante } 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'); + }); +}); diff --git a/modules/rup/routes/prestacion.ts b/modules/rup/routes/prestacion.ts index bfa5a2a5da..c2f0936aaf 100755 --- a/modules/rup/routes/prestacion.ts +++ b/modules/rup/routes/prestacion.ts @@ -6,6 +6,7 @@ import * as moment from 'moment'; import { Types } from 'mongoose'; import { AppCache } from '../../../connections'; import { PacienteCtr } from '../../../core-v2/mpi/paciente/paciente.routes'; +import { Profesional } from '../../../core/tm/schemas/profesional'; import { SnomedCtr } from '../../../core/term/controller/snomed.controller'; import { getObraSocial } from '../../../modules/obraSocial/controller/obraSocial'; import { removeDiacritics } from '../../../utils/utils'; @@ -15,7 +16,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 { deriveRolFirmante, hudsPaciente, saveEnHistorial, updateRegistroHistorialSolicitud } from '../controllers/prestacion'; import { registrosProfundidad } from '../controllers/rup'; import { buscarYCrearSolicitudes } from '../controllers/solicitudes.controller'; import { IPrestacionDoc } from '../prestaciones.interface'; @@ -164,7 +165,12 @@ router.post('/solicitudes/dashboard', async (req, res, next) => { 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 +698,137 @@ 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.'); + } + // Solo se puede pre-firmar en ejecución o mientras está pendiente de validación + if (data.estadoActual.tipo !== 'ejecucion' && data.estadoActual.tipo !== 'pendienteValidacion') { + return next('Solo se puede pre-validar una prestación en ejecución o pendiente de validación.'); + } + const profesional = Auth.getProfesional(req); + if (!profesional || !profesional.id) { + return next('Solo un profesional puede pre-validar.'); + } + + // Matrícula vigente del profesional (snapshot para el informe). Se toma la + // última matriculación de grado cargada; si no hay, queda null. + let matricula = null; + try { + const prof: any = await Profesional.findById(profesional.id, { formacionGrado: 1 }); + const fg = (prof && prof.formacionGrado || []).find(f => f.matriculacion && f.matriculacion.length); + if (fg) { + const m = fg.matriculacion[fg.matriculacion.length - 1]; + matricula = m && m.matriculaNumero != null ? String(m.matriculaNumero) : null; + } + } catch (e) { /* matrícula opcional: si falla la lectura, queda null */ } + + const firma = { + profesional, + rol: deriveRolFirmante(req, tipoPrestacionId), // snapshot del rol al firmar + matricula, + fecha: new Date() + }; + // createdBy del nuevo estado, mismo shape que arma el AuditPlugin + const createdByEstado = Object.assign({}, (req as any).user.usuario, { organizacion: (req as any).user.organizacion }); + const estadoPendiente = { tipo: 'pendienteValidacion', createdAt: new Date(), createdBy: createdByEstado }; + + // Push atómico: tope de 4 y anti-duplicado forzados en el filtro. + // Si está en 'ejecucion', además transiciona a 'pendienteValidacion' una sola vez. + // Reintenta si otro residente cambió el estado entre la lectura y la escritura + // (así ninguna firma se pierde por concurrencia). + let prestacionActualizada: any = null; + for (let intento = 0; intento < 3 && !prestacionActualizada; intento++) { + const fresh: any = await Prestacion.findById(data._id, { estadoActual: 1, firmasPrevalidacion: 1 }); + if (!fresh) { + return next(404); + } + const tipoActual = fresh.estadoActual.tipo; + if (tipoActual !== 'ejecucion' && tipoActual !== 'pendienteValidacion') { + return next('Solo se puede pre-validar una prestación en ejecución o pendiente de validación.'); + } + 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 >= 4) { + return next('Se alcanzó el máximo de firmas de pre-validación (4).'); + } + const filtro: any = { + _id: data._id, + 'estadoActual.tipo': tipoActual, + 'firmasPrevalidacion.profesional.id': { $ne: profesional.id }, + $expr: { $lt: [{ $size: { $ifNull: ['$firmasPrevalidacion', []] } }, 4] } + }; + const update: any = tipoActual === 'ejecucion' + ? { $push: { firmasPrevalidacion: firma, estados: estadoPendiente }, $set: { estadoActual: estadoPendiente } } + : { $push: { firmasPrevalidacion: firma } }; + prestacionActualizada = await Prestacion.findOneAndUpdate(filtro, update, { 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 (data.estadoActual.tipo !== 'pendienteValidacion') { + return next('Solo se puede quitar una pre-validación mientras la prestación está pendiente de validación.'); + } + const prof = Auth.getProfesional(req); + const esValidador = Auth.check(req, 'rup:validacion:' + data.solicitud.tipoPrestacion.id); + // Por defecto se quita la propia firma; un validador puede quitar la de otro. + const targetId = (esValidador && req.body.firmaProfesionalId) ? req.body.firmaProfesionalId : (prof && prof.id); + if (!targetId) { + return next('No se pudo identificar la firma a quitar.'); + } + const firmasActuales = data.firmasPrevalidacion || []; + const existe = firmasActuales.some(f => String(f.profesional.id) === String(targetId)); + if (!existe) { + return next('No hay una pre-validación de ese profesional para quitar.'); + } + // Solo el propio firmante o un validador pueden quitar + if (!esValidador && String(targetId) !== String(prof && prof.id)) { + return next('Solo puede quitar su propia pre-validación.'); + } + const quedan = firmasActuales.filter(f => String(f.profesional.id) !== String(targetId)).length; + let update: any; + if (quedan === 0) { + // Sin más pre-firmas, la prestación vuelve a ejecución + const createdByEjec = Object.assign({}, (req as any).user.usuario, { organizacion: (req as any).user.organizacion }); + const estadoEjec = { tipo: 'ejecucion', createdAt: new Date(), createdBy: createdByEjec }; + update = { + $pull: { firmasPrevalidacion: { 'profesional.id': Types.ObjectId(targetId) } }, + $push: { estados: estadoEjec }, + $set: { estadoActual: estadoEjec } + }; + } else { + update = { $pull: { firmasPrevalidacion: { 'profesional.id': Types.ObjectId(targetId) } } }; + } + const sinFirma = await Prestacion.findOneAndUpdate( + { _id: data._id, 'estadoActual.tipo': 'pendienteValidacion' }, + update, + { new: true } + ); + if (!sinFirma) { + return next('No se pudo quitar la pre-validación, reintente.'); + } + EventCore.emitAsync('rup:prestacion:quitarPrevalidacion', sinFirma); + if (sinFirma.paciente) { + AppCache.clear(`huds-${sinFirma.paciente.id}`); + } + return res.json(sinFirma); + } case 'romperValidacion': if (data.estadoActual.tipo !== 'validada') { return next('Para poder romper la validación, primero debe validar la prestación.'); diff --git a/modules/rup/schemas/prestacion.estado.ts b/modules/rup/schemas/prestacion.estado.ts index b54eec01d9..e07e35c713 100644 --- a/modules/rup/schemas/prestacion.estado.ts +++ b/modules/rup/schemas/prestacion.estado.ts @@ -5,7 +5,7 @@ import { AuditPlugin } from '@andes/mongoose-plugin-audit'; export const PrestacionEstadoSchema = new mongoose.Schema({ tipo: { type: String, - enum: ['anulada', 'pendiente', 'ejecucion', 'auditoria', 'aceptada', 'rechazada', 'validada', 'desvinculada', 'modificada', 'asignada', 'vencida', 'resuelta'], + enum: ['anulada', 'pendiente', 'ejecucion', 'auditoria', 'aceptada', 'rechazada', 'validada', 'desvinculada', 'modificada', 'asignada', 'vencida', 'resuelta', 'pendienteValidacion'], required: true, }, idOrigenModifica: { diff --git a/modules/rup/schemas/prestacion.ts b/modules/rup/schemas/prestacion.ts index 2a3c485d77..6e772d871a 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/adr/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ícula del profesional al momento de firmar (snapshot, para el informe) + 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, From c10652b7326850bba88e956d869f7a5836fbc5a2 Mon Sep 17 00:00:00 2001 From: Hugo Fernandez Date: Tue, 20 Oct 2026 09:56:18 -0300 Subject: [PATCH 02/11] style(RUP): usa object spread en lugar de Object.assign (lint) --- modules/rup/routes/prestacion.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/rup/routes/prestacion.ts b/modules/rup/routes/prestacion.ts index c2f0936aaf..a52578a4d3 100755 --- a/modules/rup/routes/prestacion.ts +++ b/modules/rup/routes/prestacion.ts @@ -735,7 +735,7 @@ router.patch('/prestaciones/:id', (req: Request, res, next) => { fecha: new Date() }; // createdBy del nuevo estado, mismo shape que arma el AuditPlugin - const createdByEstado = Object.assign({}, (req as any).user.usuario, { organizacion: (req as any).user.organizacion }); + const createdByEstado = { ...(req as any).user.usuario, organizacion: (req as any).user.organizacion }; const estadoPendiente = { tipo: 'pendienteValidacion', createdAt: new Date(), createdBy: createdByEstado }; // Push atómico: tope de 4 y anti-duplicado forzados en el filtro. @@ -805,7 +805,7 @@ router.patch('/prestaciones/:id', (req: Request, res, next) => { let update: any; if (quedan === 0) { // Sin más pre-firmas, la prestación vuelve a ejecución - const createdByEjec = Object.assign({}, (req as any).user.usuario, { organizacion: (req as any).user.organizacion }); + const createdByEjec = { ...(req as any).user.usuario, organizacion: (req as any).user.organizacion }; const estadoEjec = { tipo: 'ejecucion', createdAt: new Date(), createdBy: createdByEjec }; update = { $pull: { firmasPrevalidacion: { 'profesional.id': Types.ObjectId(targetId) } }, From 77d17342a8c8b80ab7bb8de2bf14db81f3be4f2f Mon Sep 17 00:00:00 2001 From: Hugo Fernandez Date: Wed, 21 Oct 2026 18:44:27 -0300 Subject: [PATCH 03/11] =?UTF-8?q?refactor(RUP):=20mueve=20el=20tope=20de?= =?UTF-8?q?=20firmas=20de=20pre-validaci=C3=B3n=20a=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.ts | 6 ++++++ modules/rup/routes/prestacion.ts | 9 +++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/config.ts b/config.ts index 8a75d27a24..0838cf1143 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/adr/0001-doble-firma-informe.md + maxFirmasPrevalidacion: 4 +}; + // Configuracion MPI export const algoritmo = 'Levenshtein'; diff --git a/modules/rup/routes/prestacion.ts b/modules/rup/routes/prestacion.ts index a52578a4d3..1fe39b943f 100755 --- a/modules/rup/routes/prestacion.ts +++ b/modules/rup/routes/prestacion.ts @@ -4,6 +4,7 @@ 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 { Profesional } from '../../../core/tm/schemas/profesional'; @@ -738,7 +739,7 @@ router.patch('/prestaciones/:id', (req: Request, res, next) => { const createdByEstado = { ...(req as any).user.usuario, organizacion: (req as any).user.organizacion }; const estadoPendiente = { tipo: 'pendienteValidacion', createdAt: new Date(), createdBy: createdByEstado }; - // Push atómico: tope de 4 y anti-duplicado forzados en el filtro. + // Push atómico: tope de firmas (config) y anti-duplicado forzados en el filtro. // Si está en 'ejecucion', además transiciona a 'pendienteValidacion' una sola vez. // Reintenta si otro residente cambió el estado entre la lectura y la escritura // (así ninguna firma se pierde por concurrencia). @@ -756,14 +757,14 @@ router.patch('/prestaciones/:id', (req: Request, res, next) => { 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 >= 4) { - return next('Se alcanzó el máximo de firmas de pre-validación (4).'); + if (firmas.length >= rupConfig.maxFirmasPrevalidacion) { + return next(`Se alcanzó el máximo de firmas de pre-validación (${rupConfig.maxFirmasPrevalidacion}).`); } const filtro: any = { _id: data._id, 'estadoActual.tipo': tipoActual, 'firmasPrevalidacion.profesional.id': { $ne: profesional.id }, - $expr: { $lt: [{ $size: { $ifNull: ['$firmasPrevalidacion', []] } }, 4] } + $expr: { $lt: [{ $size: { $ifNull: ['$firmasPrevalidacion', []] } }, rupConfig.maxFirmasPrevalidacion] } }; const update: any = tipoActual === 'ejecucion' ? { $push: { firmasPrevalidacion: firma, estados: estadoPendiente }, $set: { estadoActual: estadoPendiente } } From 1e3ad38f60678573198e87575ecc3a3a59add4c3 Mon Sep 17 00:00:00 2001 From: Hugo Fernandez Date: Wed, 21 Oct 2026 18:48:22 -0300 Subject: [PATCH 04/11] =?UTF-8?q?refactor(RUP):=20extrae=20helpers=20de=20?= =?UTF-8?q?pre-validaci=C3=B3n=20al=20controller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit admitePrevalidacion, formatearMatriculas, usuarioAuditoria e historialParaUpdateAtomico, con tests unitarios. --- modules/rup/controllers/prestacion.ts | 44 +++++++++++++++++++++++++ modules/rup/prevalidacion.test.ts | 47 ++++++++++++++++++++++++++- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/modules/rup/controllers/prestacion.ts b/modules/rup/controllers/prestacion.ts index 6f5c81b019..9510f010b6 100644 --- a/modules/rup/controllers/prestacion.ts +++ b/modules/rup/controllers/prestacion.ts @@ -28,6 +28,50 @@ export function deriveRolFirmante(req, tipoPrestacionId: string): 'validador' | return Auth.check(req, 'rup:validacion:' + tipoPrestacionId) ? 'validador' : 'prevalidador'; } +/** + * Estados en los que una prestación admite agregar una firma de pre-validación (ADR 0001). + */ +export function admitePrevalidacion(tipoEstado: string): boolean { + return tipoEstado === 'ejecucion' || tipoEstado === 'pendienteValidacion'; +} + +/** + * 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 * diff --git a/modules/rup/prevalidacion.test.ts b/modules/rup/prevalidacion.test.ts index a6bb9953c4..3acfcb8e94 100644 --- a/modules/rup/prevalidacion.test.ts +++ b/modules/rup/prevalidacion.test.ts @@ -1,5 +1,5 @@ import { getFakeRequest } from '@andes/unit-test'; -import { deriveRolFirmante } from './controllers/prestacion'; +import { admitePrevalidacion, deriveRolFirmante, formatearMatriculas, historialParaUpdateAtomico } from './controllers/prestacion'; /** * Tests de la derivación de rol del firmante (ADR 0001). @@ -45,3 +45,48 @@ describe('deriveRolFirmante', () => { expect(deriveRolFirmante(req, TIPO)).toBe('prevalidador'); }); }); + +describe('admitePrevalidacion', () => { + it('admite ejecución y pendiente de validación', () => { + expect(admitePrevalidacion('ejecucion')).toBe(true); + expect(admitePrevalidacion('pendienteValidacion')).toBe(true); + }); + + it('no admite otros estados', () => { + expect(admitePrevalidacion('validada')).toBe(false); + expect(admitePrevalidacion('pendiente')).toBe(false); + expect(admitePrevalidacion('anulada')).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(); + }); +}); From 353d3ee4d5cafeac6b1726b2140f5e596bbdf140 Mon Sep 17 00:00:00 2001 From: Hugo Fernandez Date: Wed, 21 Oct 2026 18:48:22 -0300 Subject: [PATCH 05/11] fix(RUP): robustece prevalidacion y quitarPrevalidacion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - La matrícula de la firma reutiliza searchMatriculas (vigencia, baja, posgrado y matrícula externa), con el mismo formato que el informe PDF. - quitarPrevalidacion rechaza quitar una firma ajena sin rup:validacion (antes ignoraba firmaProfesionalId y quitaba la propia en silencio). - quitarPrevalidacion: $pull condicionado a que la firma exista y vuelta a ejecucion solo si la lista sigue vacía (evita perder firmas concurrentes). - Persiste la entrada de historial de solicitud (prestaciones top) y updatedAt/updatedBy, que se perdían por no pasar por data.save(). --- modules/rup/routes/prestacion.ts | 130 ++++++++++++++++-------------- modules/rup/schemas/prestacion.ts | 2 +- 2 files changed, 71 insertions(+), 61 deletions(-) diff --git a/modules/rup/routes/prestacion.ts b/modules/rup/routes/prestacion.ts index 1fe39b943f..7030ecb465 100755 --- a/modules/rup/routes/prestacion.ts +++ b/modules/rup/routes/prestacion.ts @@ -7,7 +7,7 @@ import { Types } from 'mongoose'; import { rup as rupConfig } from '../../../config'; import { AppCache } from '../../../connections'; import { PacienteCtr } from '../../../core-v2/mpi/paciente/paciente.routes'; -import { Profesional } from '../../../core/tm/schemas/profesional'; +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'; @@ -17,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 { deriveRolFirmante, hudsPaciente, saveEnHistorial, updateRegistroHistorialSolicitud } from '../controllers/prestacion'; +import { admitePrevalidacion, 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'; @@ -28,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 o pendiente de validació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 * @@ -708,36 +723,23 @@ router.patch('/prestaciones/:id', (req: Request, res, next) => { if (!Auth.check(req, 'rup:prevalidacion:' + tipoPrestacionId)) { return next('No tiene permiso para pre-validar este tipo de prestación.'); } - // Solo se puede pre-firmar en ejecución o mientras está pendiente de validación - if (data.estadoActual.tipo !== 'ejecucion' && data.estadoActual.tipo !== 'pendienteValidacion') { - return next('Solo se puede pre-validar una prestación en ejecución o pendiente de validación.'); + if (!admitePrevalidacion(data.estadoActual.tipo)) { + return next(ERROR_ESTADO_PREVALIDACION); } const profesional = Auth.getProfesional(req); - if (!profesional || !profesional.id) { + if (!profesional?.id) { return next('Solo un profesional puede pre-validar.'); } - // Matrícula vigente del profesional (snapshot para el informe). Se toma la - // última matriculación de grado cargada; si no hay, queda null. - let matricula = null; - try { - const prof: any = await Profesional.findById(profesional.id, { formacionGrado: 1 }); - const fg = (prof && prof.formacionGrado || []).find(f => f.matriculacion && f.matriculacion.length); - if (fg) { - const m = fg.matriculacion[fg.matriculacion.length - 1]; - matricula = m && m.matriculaNumero != null ? String(m.matriculaNumero) : null; - } - } catch (e) { /* matrícula opcional: si falla la lectura, queda null */ } - const firma = { profesional, rol: deriveRolFirmante(req, tipoPrestacionId), // snapshot del rol al firmar - matricula, + matricula: await matriculasFirmante(profesional.id), // snapshot para el informe fecha: new Date() }; - // createdBy del nuevo estado, mismo shape que arma el AuditPlugin - const createdByEstado = { ...(req as any).user.usuario, organizacion: (req as any).user.organizacion }; - const estadoPendiente = { tipo: 'pendienteValidacion', createdAt: new Date(), createdBy: createdByEstado }; + const usuario = usuarioAuditoria(req); + const estadoPendiente = { tipo: 'pendienteValidacion', createdAt: new Date(), createdBy: usuario }; + const historial = historialParaUpdateAtomico(data, usuario); // Push atómico: tope de firmas (config) y anti-duplicado forzados en el filtro. // Si está en 'ejecucion', además transiciona a 'pendienteValidacion' una sola vez. @@ -750,8 +752,8 @@ router.patch('/prestaciones/:id', (req: Request, res, next) => { return next(404); } const tipoActual = fresh.estadoActual.tipo; - if (tipoActual !== 'ejecucion' && tipoActual !== 'pendienteValidacion') { - return next('Solo se puede pre-validar una prestación en ejecución o pendiente de validación.'); + if (!admitePrevalidacion(tipoActual)) { + return next(ERROR_ESTADO_PREVALIDACION); } const firmas = fresh.firmasPrevalidacion || []; if (firmas.some(f => String(f.profesional.id) === String(profesional.id))) { @@ -766,9 +768,19 @@ router.patch('/prestaciones/:id', (req: Request, res, next) => { 'firmasPrevalidacion.profesional.id': { $ne: profesional.id }, $expr: { $lt: [{ $size: { $ifNull: ['$firmasPrevalidacion', []] } }, rupConfig.maxFirmasPrevalidacion] } }; - const update: any = tipoActual === 'ejecucion' - ? { $push: { firmasPrevalidacion: firma, estados: estadoPendiente }, $set: { estadoActual: estadoPendiente } } - : { $push: { firmasPrevalidacion: firma } }; + const transiciona = tipoActual === 'ejecucion'; + const update: any = { + $push: { + firmasPrevalidacion: firma, + ...(transiciona ? { estados: estadoPendiente } : {}), + ...(historial ? { 'solicitud.historial': historial } : {}) + }, + $set: { + updatedAt: new Date(), + updatedBy: usuario, + ...(transiciona ? { estadoActual: estadoPendiente } : {}) + } + }; prestacionActualizada = await Prestacion.findOneAndUpdate(filtro, update, { new: true }); } if (!prestacionActualizada) { @@ -786,49 +798,47 @@ router.patch('/prestaciones/:id', (req: Request, res, next) => { if (data.estadoActual.tipo !== 'pendienteValidacion') { return next('Solo se puede quitar una pre-validación mientras la prestación está pendiente de validación.'); } - const prof = Auth.getProfesional(req); + const profesional = Auth.getProfesional(req); const esValidador = Auth.check(req, 'rup:validacion:' + data.solicitud.tipoPrestacion.id); - // Por defecto se quita la propia firma; un validador puede quitar la de otro. - const targetId = (esValidador && req.body.firmaProfesionalId) ? req.body.firmaProfesionalId : (prof && prof.id); - if (!targetId) { + // 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.'); } - const firmasActuales = data.firmasPrevalidacion || []; - const existe = firmasActuales.some(f => String(f.profesional.id) === String(targetId)); - if (!existe) { - return next('No hay una pre-validación de ese profesional para quitar.'); - } - // Solo el propio firmante o un validador pueden quitar - if (!esValidador && String(targetId) !== String(prof && prof.id)) { + if (!esValidador && String(targetId) !== String(profesional?.id)) { return next('Solo puede quitar su propia pre-validación.'); } - const quedan = firmasActuales.filter(f => String(f.profesional.id) !== String(targetId)).length; - let update: any; - if (quedan === 0) { - // Sin más pre-firmas, la prestación vuelve a ejecución - const createdByEjec = { ...(req as any).user.usuario, organizacion: (req as any).user.organizacion }; - const estadoEjec = { tipo: 'ejecucion', createdAt: new Date(), createdBy: createdByEjec }; - update = { + const usuario = usuarioAuditoria(req); + const historial = historialParaUpdateAtomico(data, usuario); + + // $pull atómico, condicionado a que la firma siga existiendo. + let prestacionActualizada: any = await Prestacion.findOneAndUpdate( + { _id: data._id, 'estadoActual.tipo': 'pendienteValidacion', 'firmasPrevalidacion.profesional.id': targetId }, + { $pull: { firmasPrevalidacion: { 'profesional.id': Types.ObjectId(targetId) } }, - $push: { estados: estadoEjec }, - $set: { estadoActual: estadoEjec } - }; - } else { - update = { $pull: { firmasPrevalidacion: { 'profesional.id': Types.ObjectId(targetId) } } }; - } - const sinFirma = await Prestacion.findOneAndUpdate( - { _id: data._id, 'estadoActual.tipo': 'pendienteValidacion' }, - update, + $set: { updatedAt: new Date(), updatedBy: usuario }, + ...(historial ? { $push: { 'solicitud.historial': historial } } : {}) + }, { new: true } ); - if (!sinFirma) { - return next('No se pudo quitar la pre-validación, reintente.'); + if (!prestacionActualizada) { + return next('No hay una pre-validación de ese profesional para quitar.'); + } + // Si era la última firma, vuelve a ejecución. Se condiciona a que la lista siga + // vacía: si otro residente firmó en el medio, queda pendiente de validación. + if (!prestacionActualizada.firmasPrevalidacion.length) { + const estadoEjecucion = { tipo: 'ejecucion', createdAt: new Date(), createdBy: usuario }; + prestacionActualizada = await Prestacion.findOneAndUpdate( + { _id: data._id, 'estadoActual.tipo': 'pendienteValidacion', firmasPrevalidacion: { $size: 0 } }, + { $push: { estados: estadoEjecucion }, $set: { estadoActual: estadoEjecucion } }, + { new: true } + ) || await Prestacion.findById(data._id); } - EventCore.emitAsync('rup:prestacion:quitarPrevalidacion', sinFirma); - if (sinFirma.paciente) { - AppCache.clear(`huds-${sinFirma.paciente.id}`); + EventCore.emitAsync('rup:prestacion:quitarPrevalidacion', prestacionActualizada); + if (prestacionActualizada.paciente) { + AppCache.clear(`huds-${prestacionActualizada.paciente.id}`); } - return res.json(sinFirma); + return res.json(prestacionActualizada); } case 'romperValidacion': if (data.estadoActual.tipo !== 'validada') { diff --git a/modules/rup/schemas/prestacion.ts b/modules/rup/schemas/prestacion.ts index 6e772d871a..b0bade3075 100644 --- a/modules/rup/schemas/prestacion.ts +++ b/modules/rup/schemas/prestacion.ts @@ -23,7 +23,7 @@ export const FirmaPrevalidacionSchema = new Schema({ }, // Rol derivado de permisos al momento de firmar (snapshot): 'validador' | 'prevalidador' rol: { type: String, required: true }, - // Matrícula del profesional al momento de firmar (snapshot, para el informe) + // 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 }); From 227ccc69a0306f3dcb523120498dbe70f44f5746 Mon Sep 17 00:00:00 2001 From: Hugo Fernandez Date: Wed, 21 Oct 2026 18:48:22 -0300 Subject: [PATCH 06/11] =?UTF-8?q?fix(RUP):=20rechaza=20pre-validaci=C3=B3n?= =?UTF-8?q?=20de=20usuarios=20con=20rup:validacion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-validación y validación son excluyentes (ADR 0001); antes la exclusión solo existía en la UI y la API aceptaba la firma como 'validador'. --- modules/rup/routes/prestacion.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/modules/rup/routes/prestacion.ts b/modules/rup/routes/prestacion.ts index 7030ecb465..2d48c12691 100755 --- a/modules/rup/routes/prestacion.ts +++ b/modules/rup/routes/prestacion.ts @@ -731,9 +731,15 @@ router.patch('/prestaciones/:id', (req: Request, res, next) => { 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: deriveRolFirmante(req, tipoPrestacionId), // snapshot del rol al firmar + rol, // snapshot del rol al firmar matricula: await matriculasFirmante(profesional.id), // snapshot para el informe fecha: new Date() }; From b0414fcb82470e9f27d36f9a5cbed34ce907c67d Mon Sep 17 00:00:00 2001 From: Hugo Fernandez Date: Wed, 21 Oct 2026 18:48:39 -0300 Subject: [PATCH 07/11] docs(rup): mueve ADR a docs/rup --- config.ts | 2 +- docs/{adr => rup}/0001-doble-firma-informe.md | 0 modules/rup/schemas/prestacion.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename docs/{adr => rup}/0001-doble-firma-informe.md (100%) diff --git a/config.ts b/config.ts index 0838cf1143..0699642393 100644 --- a/config.ts +++ b/config.ts @@ -185,7 +185,7 @@ export const maxLimit = 1000; // Configuración RUP export const rup = { - // Tope de firmas de pre-validación (residentes) por informe. Ver docs/adr/0001-doble-firma-informe.md + // Tope de firmas de pre-validación (residentes) por informe. Ver docs/rup/0001-doble-firma-informe.md maxFirmasPrevalidacion: 4 }; diff --git a/docs/adr/0001-doble-firma-informe.md b/docs/rup/0001-doble-firma-informe.md similarity index 100% rename from docs/adr/0001-doble-firma-informe.md rename to docs/rup/0001-doble-firma-informe.md diff --git a/modules/rup/schemas/prestacion.ts b/modules/rup/schemas/prestacion.ts index b0bade3075..da80848530 100644 --- a/modules/rup/schemas/prestacion.ts +++ b/modules/rup/schemas/prestacion.ts @@ -12,7 +12,7 @@ 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/adr/0001-doble-firma-informe.md). + * Ver ADR 0001 (docs/rup/0001-doble-firma-informe.md). */ export const FirmaPrevalidacionSchema = new Schema({ profesional: { From cc0810006b3aaf7731893c05d82f45f5b9575227 Mon Sep 17 00:00:00 2001 From: Hugo Fernandez Date: Wed, 21 Oct 2026 18:49:22 -0300 Subject: [PATCH 08/11] =?UTF-8?q?docs(rup):=20agrega=20metadata=20al=20ADR?= =?UTF-8?q?=20y=20documenta=20los=20ajustes=20de=20la=20revisi=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/rup/0001-doble-firma-informe.md | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/docs/rup/0001-doble-firma-informe.md b/docs/rup/0001-doble-firma-informe.md index 0104497af3..8140b08220 100644 --- a/docs/rup/0001-doble-firma-informe.md +++ b/docs/rup/0001-doble-firma-informe.md @@ -1,3 +1,13 @@ +--- +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") y el 2026-10-19 (pre-validar requiere el permiso explícito `rup:prevalidacion`; etapa 1 implementada, ver "Historial de cambios"). @@ -49,7 +59,8 @@ por `documento == username`. `rup:validacion:`. 3. Un profesional con `rup:prevalidacion` y **sin** `rup:validacion` solo puede pre-validar. Por convención administrativa, un usuario no debería tener ambos - permisos; si los tuviera, la derivación de rol da prioridad a validador. + permisos; 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) @@ -74,8 +85,9 @@ tocar a los consumidores. ### Modelo de datos - `firmasPrevalidacion`: **lista** de firmas previas. Cada una guarda el profesional - (`id`, `nombre`, `apellido`, `documento`), el rol (snapshot), la **matrícula** (snapshot, - tomada de `formacionGrado[].matriculacion[].matriculaNumero` al firmar) y la fecha. + (`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 @@ -97,8 +109,10 @@ ejecucion ─────────────────────── - **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`). Solo con la prestación en `pendienteValidacion`. - `$pull` atómico; si era la última firma, la prestación vuelve a `ejecucion`. + firmante o un validador (`rup:validacion`); quitar la firma de otro sin ese permiso se + rechaza. Solo con la prestación en `pendienteValidacion`. `$pull` atómico condicionado a + que la firma exista; si la lista queda vacía, la prestación vuelve a `ejecucion` (también + de forma condicionada, para no pisar una firma concurrente). - 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. @@ -106,7 +120,8 @@ ejecucion ─────────────────────── ### 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. + cumplir en la API, no solo en la interfaz. El valor vive en `config.ts` + (`rup.maxFirmasPrevalidacion`). - **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. @@ -209,3 +224,4 @@ Pendiente de definir: | 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. | From 96941d7c8c6e6286484f13c0483810acfbed9c3a Mon Sep 17 00:00:00 2001 From: Hugo Fernandez Date: Wed, 21 Oct 2026 18:52:25 -0300 Subject: [PATCH 09/11] =?UTF-8?q?feat(RUP):=20expone=20el=20tope=20de=20fi?= =?UTF-8?q?rmas=20de=20pre-validaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/rup/0001-doble-firma-informe.md | 3 ++- modules/rup/controllers/prestacion.ts | 8 ++++++++ modules/rup/prevalidacion.test.ts | 10 +++++++++- modules/rup/routes/prestacion.ts | 7 ++++++- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/rup/0001-doble-firma-informe.md b/docs/rup/0001-doble-firma-informe.md index 8140b08220..12e7f29839 100644 --- a/docs/rup/0001-doble-firma-informe.md +++ b/docs/rup/0001-doble-firma-informe.md @@ -121,7 +121,8 @@ ejecucion ─────────────────────── - **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`). + (`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. diff --git a/modules/rup/controllers/prestacion.ts b/modules/rup/controllers/prestacion.ts index 9510f010b6..e54ad77cb6 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'; @@ -28,6 +29,13 @@ export function deriveRolFirmante(req, tipoPrestacionId: string): 'validador' | 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 una firma de pre-validación (ADR 0001). */ diff --git a/modules/rup/prevalidacion.test.ts b/modules/rup/prevalidacion.test.ts index 3acfcb8e94..64c853e01f 100644 --- a/modules/rup/prevalidacion.test.ts +++ b/modules/rup/prevalidacion.test.ts @@ -1,5 +1,6 @@ import { getFakeRequest } from '@andes/unit-test'; -import { admitePrevalidacion, deriveRolFirmante, formatearMatriculas, historialParaUpdateAtomico } from './controllers/prestacion'; +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). @@ -90,3 +91,10 @@ describe('historialParaUpdateAtomico', () => { 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 2d48c12691..3e6a0e17f2 100755 --- a/modules/rup/routes/prestacion.ts +++ b/modules/rup/routes/prestacion.ts @@ -17,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 { admitePrevalidacion, deriveRolFirmante, formatearMatriculas, historialParaUpdateAtomico, hudsPaciente, saveEnHistorial, updateRegistroHistorialSolicitud, usuarioAuditoria } 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'; @@ -180,6 +180,11 @@ 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) => { // getPermissions puede devolver el comodín '*' (p.ej. si el usuario tiene // rup:servicio-intermedio:* o rup:*). ObjectId('*') lanza y tira el proceso, From ed117a71b9989c0bcd1fe3db737e1a96279ea4e0 Mon Sep 17 00:00:00 2001 From: Hugo Fernandez Date: Wed, 21 Oct 2026 19:06:43 -0300 Subject: [PATCH 10/11] =?UTF-8?q?fix(RUP):=20limpia=20las=20firmas=20de=20?= =?UTF-8?q?pre-validaci=C3=B3n=20al=20romper=20la=20validaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/rup/0001-doble-firma-informe.md | 6 ++++-- modules/rup/routes/prestacion.ts | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/rup/0001-doble-firma-informe.md b/docs/rup/0001-doble-firma-informe.md index 12e7f29839..cdb3416c34 100644 --- a/docs/rup/0001-doble-firma-informe.md +++ b/docs/rup/0001-doble-firma-informe.md @@ -113,6 +113,9 @@ ejecucion ─────────────────────── rechaza. Solo con la prestación en `pendienteValidacion`. `$pull` atómico condicionado a que la firma exista; si la lista queda vacía, la prestación vuelve a `ejecucion` (también de forma condicionada, para no pisar una firma concurrente). +- **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. @@ -209,8 +212,6 @@ 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). -- Comportamiento de las modificaciones de informes ya validados (`romperValidacion`) con el - nuevo modelo: qué pasa con las firmas existentes al volver a ejecución. ## Historial de cambios del documento @@ -226,3 +227,4 @@ Pendiente de definir: | 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). | diff --git a/modules/rup/routes/prestacion.ts b/modules/rup/routes/prestacion.ts index 3e6a0e17f2..d314f37f97 100755 --- a/modules/rup/routes/prestacion.ts +++ b/modules/rup/routes/prestacion.ts @@ -866,6 +866,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 From 64a2b025e4a03cfb1c150e07671a4d59df203fb8 Mon Sep 17 00:00:00 2001 From: Hugo Fernandez Date: Sat, 31 Oct 2026 16:32:54 -0300 Subject: [PATCH 11/11] =?UTF-8?q?refactor(RUP):=20quita=20el=20estado=20pe?= =?UTF-8?q?ndienteValidacion=20de=20la=20pre-validaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La pre-validación ya no cambia el estado de la prestación: sigue en ejecución y acumula firmas en firmasPrevalidacion. Se elimina pendienteValidacion del enum de estados, quitarPrevalidacion opera en ejecución y no transiciona, y el push de firmas deja de necesitar el reintento por cambio de estado. Se actualiza el ADR 0001 con la enmienda del 2026-10-31: eliminación del estado y de la cola de pendientes, y la regla real del botón Validar (se oculta solo a quien tiene rup:prevalidacion; rup:validacion nunca fue requisito para validar). --- docs/rup/0001-doble-firma-informe.md | 55 +++++++++------ modules/rup/controllers/prestacion.ts | 7 +- modules/rup/prevalidacion.test.ts | 4 +- modules/rup/routes/prestacion.ts | 86 ++++++++++-------------- modules/rup/schemas/prestacion.estado.ts | 2 +- 5 files changed, 75 insertions(+), 79 deletions(-) diff --git a/docs/rup/0001-doble-firma-informe.md b/docs/rup/0001-doble-firma-informe.md index cdb3416c34..a5c9d7f226 100644 --- a/docs/rup/0001-doble-firma-informe.md +++ b/docs/rup/0001-doble-firma-informe.md @@ -10,7 +10,7 @@ scope: # 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") y el 2026-10-19 (pre-validar requiere el permiso explícito `rup:prevalidacion`; etapa 1 implementada, ver "Historial de cambios"). +- **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` @@ -55,12 +55,17 @@ por `documento == username`. `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:** un profesional que además tenga el permiso Shiro - `rup:validacion:`. -3. Un profesional con `rup:prevalidacion` y **sin** `rup:validacion` solo puede - pre-validar. Por convención administrativa, un usuario no debería tener ambos - permisos; 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. +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) @@ -95,24 +100,27 @@ tocar a los consumidores. 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. -- Se agrega el estado `pendienteValidacion`. La pre-validación se admite cuando la prestación - está en `ejecucion` o en `pendienteValidacion`, de modo que varios residentes puedan sumar - firmas; la primera pre-validación transiciona a `pendienteValidacion` y las siguientes solo - agregan su firma (transición idempotente, `$push` atómico con reintento ante carrera). +- **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 ──(pre-validación)──► pendienteValidacion ──(valida)──► validada [doble firma] -ejecucion ────────────────────────(valida directo)──────────────► validada [firma única] +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 `pendienteValidacion`. `$pull` atómico condicionado a - que la firma exista; si la lista queda vacía, la prestación vuelve a `ejecucion` (también - de forma condicionada, para no pisar una firma concurrente). + 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. @@ -181,11 +189,15 @@ ejecucion ─────────────────────── **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:** en esta etapa se agrega enforcement **solo en la UI** del flujo - ambulatorio: el botón "Validar" de `prestacionValidacion` ahora se muestra únicamente con - `rup:validacion:`. El backend (`estadoPush` con `validada`) sigue sin exigir el - permiso, 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. + - **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. @@ -228,3 +240,4 @@ Pendiente de definir: | 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 e54ad77cb6..e2303be5ac 100644 --- a/modules/rup/controllers/prestacion.ts +++ b/modules/rup/controllers/prestacion.ts @@ -37,10 +37,11 @@ export function configPrevalidacion() { } /** - * Estados en los que una prestación admite agregar una firma de pre-validación (ADR 0001). + * 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' || tipoEstado === 'pendienteValidacion'; + return tipoEstado === 'ejecucion'; } /** @@ -130,7 +131,7 @@ export function updateRegistroHistorialSolicitud(solicitud, datos) { asignada: 'Profesional asignado', validada: 'Validada', prevalidacion: 'Pre-validada', - quitarPrevalidacion: 'Ejecutada', + 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 index 64c853e01f..51702be70f 100644 --- a/modules/rup/prevalidacion.test.ts +++ b/modules/rup/prevalidacion.test.ts @@ -48,15 +48,15 @@ describe('deriveRolFirmante', () => { }); describe('admitePrevalidacion', () => { - it('admite ejecución y pendiente de validación', () => { + it('admite solo ejecución (la pre-validación no cambia el estado)', () => { expect(admitePrevalidacion('ejecucion')).toBe(true); - expect(admitePrevalidacion('pendienteValidacion')).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); }); }); diff --git a/modules/rup/routes/prestacion.ts b/modules/rup/routes/prestacion.ts index d314f37f97..9d51bde482 100755 --- a/modules/rup/routes/prestacion.ts +++ b/modules/rup/routes/prestacion.ts @@ -28,7 +28,7 @@ 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 o pendiente de validación.'; +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 @@ -749,51 +749,42 @@ router.patch('/prestaciones/:id', (req: Request, res, next) => { fecha: new Date() }; const usuario = usuarioAuditoria(req); - const estadoPendiente = { tipo: 'pendienteValidacion', createdAt: new Date(), createdBy: usuario }; const historial = historialParaUpdateAtomico(data, usuario); - // Push atómico: tope de firmas (config) y anti-duplicado forzados en el filtro. - // Si está en 'ejecucion', además transiciona a 'pendienteValidacion' una sola vez. - // Reintenta si otro residente cambió el estado entre la lectura y la escritura - // (así ninguna firma se pierde por concurrencia). - let prestacionActualizada: any = null; - for (let intento = 0; intento < 3 && !prestacionActualizada; intento++) { - const fresh: any = await Prestacion.findById(data._id, { estadoActual: 1, firmasPrevalidacion: 1 }); - if (!fresh) { - return next(404); - } - const tipoActual = fresh.estadoActual.tipo; - if (!admitePrevalidacion(tipoActual)) { - 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 filtro: any = { + // 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': tipoActual, + 'estadoActual.tipo': 'ejecucion', 'firmasPrevalidacion.profesional.id': { $ne: profesional.id }, $expr: { $lt: [{ $size: { $ifNull: ['$firmasPrevalidacion', []] } }, rupConfig.maxFirmasPrevalidacion] } - }; - const transiciona = tipoActual === 'ejecucion'; - const update: any = { + }, + { $push: { firmasPrevalidacion: firma, - ...(transiciona ? { estados: estadoPendiente } : {}), ...(historial ? { 'solicitud.historial': historial } : {}) }, - $set: { - updatedAt: new Date(), - updatedBy: usuario, - ...(transiciona ? { estadoActual: estadoPendiente } : {}) - } - }; - prestacionActualizada = await Prestacion.findOneAndUpdate(filtro, update, { new: true }); - } + $set: { updatedAt: new Date(), updatedBy: usuario } + }, + { new: true } + ); if (!prestacionActualizada) { return next('No se pudo registrar la pre-validación, reintente.'); } @@ -806,8 +797,8 @@ router.patch('/prestaciones/:id', (req: Request, res, next) => { 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 (data.estadoActual.tipo !== 'pendienteValidacion') { - return next('Solo se puede quitar una pre-validación mientras la prestación está pendiente de validación.'); + 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); @@ -822,9 +813,10 @@ router.patch('/prestaciones/:id', (req: Request, res, next) => { const usuario = usuarioAuditoria(req); const historial = historialParaUpdateAtomico(data, usuario); - // $pull atómico, condicionado a que la firma siga existiendo. - let prestacionActualizada: any = await Prestacion.findOneAndUpdate( - { _id: data._id, 'estadoActual.tipo': 'pendienteValidacion', 'firmasPrevalidacion.profesional.id': targetId }, + // $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 }, @@ -835,16 +827,6 @@ router.patch('/prestaciones/:id', (req: Request, res, next) => { if (!prestacionActualizada) { return next('No hay una pre-validación de ese profesional para quitar.'); } - // Si era la última firma, vuelve a ejecución. Se condiciona a que la lista siga - // vacía: si otro residente firmó en el medio, queda pendiente de validación. - if (!prestacionActualizada.firmasPrevalidacion.length) { - const estadoEjecucion = { tipo: 'ejecucion', createdAt: new Date(), createdBy: usuario }; - prestacionActualizada = await Prestacion.findOneAndUpdate( - { _id: data._id, 'estadoActual.tipo': 'pendienteValidacion', firmasPrevalidacion: { $size: 0 } }, - { $push: { estados: estadoEjecucion }, $set: { estadoActual: estadoEjecucion } }, - { new: true } - ) || await Prestacion.findById(data._id); - } EventCore.emitAsync('rup:prestacion:quitarPrevalidacion', prestacionActualizada); if (prestacionActualizada.paciente) { AppCache.clear(`huds-${prestacionActualizada.paciente.id}`); diff --git a/modules/rup/schemas/prestacion.estado.ts b/modules/rup/schemas/prestacion.estado.ts index e07e35c713..b54eec01d9 100644 --- a/modules/rup/schemas/prestacion.estado.ts +++ b/modules/rup/schemas/prestacion.estado.ts @@ -5,7 +5,7 @@ import { AuditPlugin } from '@andes/mongoose-plugin-audit'; export const PrestacionEstadoSchema = new mongoose.Schema({ tipo: { type: String, - enum: ['anulada', 'pendiente', 'ejecucion', 'auditoria', 'aceptada', 'rechazada', 'validada', 'desvinculada', 'modificada', 'asignada', 'vencida', 'resuelta', 'pendienteValidacion'], + enum: ['anulada', 'pendiente', 'ejecucion', 'auditoria', 'aceptada', 'rechazada', 'validada', 'desvinculada', 'modificada', 'asignada', 'vencida', 'resuelta'], required: true, }, idOrigenModifica: {