diff --git a/initialize.ts b/initialize.ts index 63bd7a141a..53081d99ee 100644 --- a/initialize.ts +++ b/initialize.ts @@ -135,6 +135,7 @@ export function initAPI(app: Express) { ); } app.use('/api/modules/turnos', require('./modules/turnos').InstitucionRouter); + app.use('/api/modules/turnos', require('./modules/turnos').ListaEsperaRouter); const { PacienteAppRouter, SendMessageCacheRouter } = require('./modules/mobileApp'); app.use('/api/modules/mobileApp', SendMessageCacheRouter); diff --git a/modules/turnos/index.ts b/modules/turnos/index.ts index 4c16597a15..1027925230 100644 --- a/modules/turnos/index.ts +++ b/modules/turnos/index.ts @@ -1 +1,2 @@ export { InstitucionRouter } from './institucion.routes'; +export { ListaEsperaRouter } from './listaEspera.routes'; diff --git a/modules/turnos/listaEspera.controller.ts b/modules/turnos/listaEspera.controller.ts new file mode 100644 index 0000000000..f0c2236bcc --- /dev/null +++ b/modules/turnos/listaEspera.controller.ts @@ -0,0 +1,294 @@ +import * as moment from 'moment'; +import * as mongoose from 'mongoose'; +import { getHistorialPaciente } from '../../core-v2/mpi/paciente/paciente.controller'; +import { Agenda } from './schemas/agenda'; +import { demanda, listaEspera } from './schemas/listaEspera'; +import { ListaEsperaCtr } from './listaEspera.routes'; +import { defaultLimit, maxLimit } from './../../config'; + +/** + * Arma las condiciones de matcheo (aggregate $match) a partir de los query + * params de búsqueda de listaEspera. + */ +export function armarQueryBusqueda(req) { + let opciones: any = {}; + + if (req.query.conceptId) { + opciones['tipoPrestacion.conceptId'] = req.query.conceptId; + } + + if (req.query.estado) { + opciones['estado'] = req.query.estado; + } + + if (req.query.fechaDesde) { + const fechaDesde = moment(new Date(req.query.fechaDesde)).startOf('day').toDate(); + + if (req.query.fechaHasta) { + const fechaHasta = moment(new Date(req.query.fechaHasta)).endOf('day').toDate(); + opciones['demandas.fecha'] = { $gte: fechaDesde, $lte: fechaHasta }; + } else { + opciones['demandas.fecha'] = { $gte: fechaDesde }; + } + } + + if (req.query.prestacion) { + const terminos = req.query.prestacion?.split(',').map(term => term.trim()); + + opciones['$or'] = terminos.map(term => ({ + 'tipoPrestacion.term': RegExp('^.*' + term + '.*$', 'i') + })); + } + + const motivoFiltro = req.query.motivo + ? { motivo: RegExp('^.*' + req.query.motivo + '.*$', 'i') } + : {}; + + const organizacionFiltro = req.query.organizacion + ? { 'organizacion.id': { $in: req.query.organizacion.split(',').map((id) => mongoose.Types.ObjectId(id)) } } + : {}; + + if (req.query.motivo || req.query.organizacion) { + opciones = { + ...opciones, + demandas: { + $elemMatch: { + ...motivoFiltro, + ...organizacionFiltro + } + } + }; + } + + return opciones; +} + +/** + * Búsqueda de demanda insatisfecha (lista de espera). + * + * Se resuelve con un aggregate custom (no con el search estándar de + * ResourceBase) por el $lookup a paciente y los filtros combinados sobre + * el array `demandas`. + */ +export async function buscarListaEspera(req) { + const opciones = armarQueryBusqueda(req); + + // apiOptions() no aplica defaultLimit/maxLimit por su cuenta (devuelve + // limit: null si no vino en el query), así que se replica la misma + // lógica que tenía el endpoint original. + const options = req.apiOptions(); + const skip: number = options.skip || 0; + const limit: number = Math.min(options.limit || defaultLimit, maxLimit); + + const pacienteAggregate = [ + { + $match: opciones, + }, + { + $lookup: { + from: 'paciente', + let: { pacienteId: '$paciente' }, + pipeline: [ + { + $match: { + $expr: { $eq: ['$_id', '$$pacienteId.id'] } + } + }, + ...(req.query.paciente ? [ + { + $match: { + $or: [ + { nombre: { $regex: req.query.paciente, $options: 'i' } }, + { apellido: { $regex: req.query.paciente, $options: 'i' } }, + { documento: { $regex: req.query.paciente, $options: 'i' } } + ] + } + } + ] : []) + ], + as: 'paciente' + } + }, + { + $unwind: { path: '$paciente', preserveNullAndEmptyArrays: true } + }, + { + $match: { + paciente: { $ne: null } + } + }, + { + $skip: skip, + }, + { + $limit: limit, + } + ]; + + return listaEspera.aggregate(pacienteAggregate).exec(); +} + +/** + * Si el paciente ya tiene un turno futuro asignado para la misma + * prestación, devuelve ese turno. Se usa para bloquear la carga de una + * demanda insatisfecha duplicada. + */ +export async function buscarTurnoFuturoExistente(req) { + const historial = await getHistorialPaciente({ + ...req, query: { + pacienteId: req.body.paciente.id, + turnosProximos: true, + estado: 'asignado', + conceptId: req.body.tipoPrestacion.conceptId + } + }); + + const turnos = historial.length ? historial.filter(item => moment(item.horaInicio).isAfter(moment())) : []; + return turnos[0] || null; +} + +/** + * Carga de demanda insatisfecha: si ya existe un registro "pendiente" para + * el mismo paciente/prestación, agrega la demanda al array existente; si + * no, crea un nuevo documento. + */ +export async function agregarDemanda(req) { + const params = { + 'paciente.id': req.body.paciente.id, + 'tipoPrestacion.conceptId': req.body.tipoPrestacion.conceptId, + estado: 'pendiente' + }; + + const unaDemanda = { + profesional: req.body.demandas[0].Profesional, + organizacion: req.body.demandas[0].organizacion, + motivo: req.body.demandas[0].motivo, + fecha: moment().toDate(), + origen: req.body.demandas[0].origen + }; + + const listaDocument: any = await listaEspera.findOne(params); + + if (listaDocument?.demandas) { + const newDemanda = new demanda(unaDemanda); + listaDocument.demandas.push(newDemanda); + + // se pasa el documento de mongoose directamente (no .toObject()). + // La auditoría (documento raíz y cada `demanda`) la resuelve el + // AuditPlugin aplicado en ambos schemas al hacer save(). + return ListaEsperaCtr.update(listaDocument._id, listaDocument, req); + } + + const newListaDocument = new listaEspera(req.body); + return ListaEsperaCtr.create(newListaDocument, req); +} + +/** + * Arma el sub-objeto `turno` a partir de una agenda + id de turno, usado + * al resolver una demanda con un turno otorgado. + */ +export async function armarTurnoResolucion(idAgenda, idTurno) { + const agenda: any = await Agenda.findById(idAgenda).exec(); + if (!agenda) { + throw new Error('No se ha encontrado la agenda.'); + } + + const turnoAgenda = agenda.bloques.flatMap(bloque => + bloque.turnos.filter(t => t._id.toString() === idTurno) + )[0]; + + if (!turnoAgenda) { + throw new Error('No se ha encontrado el turno'); + } + + return { + id: turnoAgenda._id, + horaInicio: turnoAgenda.horaInicio, + tipo: turnoAgenda.tipoTurno, + emitidoPor: turnoAgenda.emitidoPor, + fechaHoraDacion: turnoAgenda.fechaHoraDacion, + profesionales: agenda.profesionales, + idAgenda, + organizacion: { + id: agenda.organizacion.id, + nombre: agenda.organizacion.nombre + }, + }; +} + +/* Se están enviando pacientes a la lista de espera desde una agenda suspendida */ +export function listaEsperaSuspensionAgenda(req, agenda) { + const listaEsperaArray = []; + + if (req.body.pacientes.length > 0) { + for (let i = 0; i < req.body.pacientes.length; i++) { + const newListaEspera = {}; + newListaEspera['fecha'] = moment().format(); + newListaEspera['estado'] = 'Agenda Suspendida'; + newListaEspera['tipoPrestacion'] = req.body.pacientes[i].tipoPrestacion; + newListaEspera['profesional'] = agenda.profesionales[0]; + newListaEspera['paciente'] = req.body.pacientes[i].paciente; + + listaEsperaArray.push(newListaEspera); + } + } else { + const newListaEspera = {}; + newListaEspera['fecha'] = moment().format(); + newListaEspera['estado'] = 'Turno Cancelado'; + newListaEspera['tipoPrestacion'] = req.body.pacientes.tipoPrestacion; + newListaEspera['profesional'] = agenda.profesionales[0]; + newListaEspera['paciente'] = req.body.pacientes.paciente; + listaEsperaArray.push(newListaEspera); + } + + return listaEsperaArray; +} + +/** + * Según la operación recibida, genera y persiste los registros de + * listaEspera correspondientes a una agenda suspendida. + */ +type Resultado = + | { status: 'fulfilled'; value: T } + | { status: 'rejected'; reason: any }; + +/** + * Equivalente a Promise.allSettled, sin depender de lib es2020 (el + * tsconfig del proyecto no lo tiene tipado). + */ +async function settle(promise: Promise): Promise> { + try { + const value = await promise; + return { status: 'fulfilled', value }; + } catch (reason) { + return { status: 'rejected', reason }; + } +} + +export async function procesarOperacionAgenda(req, agenda) { + let listaEsperaPaciente: any[] = []; + + switch (req.body.op) { + case 'listaEsperaSuspensionAgenda': + listaEsperaPaciente = listaEsperaSuspensionAgenda(req, agenda); + break; + } + + const resultados = await Promise.all( + listaEsperaPaciente.map((listaEsperaData) => + settle(ListaEsperaCtr.create(new listaEspera(listaEsperaData), req)) + ) + ); + + const fallidos = resultados.filter(r => r.status === 'rejected'); + if (fallidos.length) { + // no corta el batch (igual que el comportamiento original), pero + // deja registro de qué falló en vez de tragárselo en silencio + fallidos.forEach(f => { + // eslint-disable-next-line no-console + console.error('Error creando listaEspera por suspensión de agenda:', (f as any).reason); + }); + } + + return resultados; +} diff --git a/modules/turnos/listaEspera.routes.ts b/modules/turnos/listaEspera.routes.ts new file mode 100644 index 0000000000..808c2d9608 --- /dev/null +++ b/modules/turnos/listaEspera.routes.ts @@ -0,0 +1,149 @@ +import { asyncHandler, Request, Response } from '@andes/api-tool'; +import { MongoQuery, ResourceBase } from '@andes/core'; +import { Auth } from '../../auth/auth.class'; +import { Agenda } from './schemas/agenda'; +import { listaEspera } from './schemas/listaEspera'; +import { + agregarDemanda, + armarTurnoResolucion, + buscarListaEspera, + buscarTurnoFuturoExistente, + procesarOperacionAgenda, +} from './listaEspera.controller'; + +/** + * Resource base de listaEspera (demanda insatisfecha). + * + * La mayoría de los verbos tienen reglas de negocio propias (ver más abajo), + * por eso sólo se habilita 'delete' con el comportamiento por default de + * ResourceBase. El resto de las rutas se agregan manualmente sobre el + * router, siguiendo el mismo patrón que paciente.routes.ts. + */ +class ListaEsperaResource extends ResourceBase { + Model = listaEspera; + resourceName = 'listaEspera'; + middlewares = [Auth.authenticate()]; + routesEnable = ['delete']; + // NOTA: dejo estos dos por si en algún momento se quiere exponer un + // listado simple sin el aggregate (ej. para otros consumidores internos). + // El GET principal sigue siendo un aggregate custom (ver `get` abajo) + // porque necesita el $lookup a paciente, $elemMatch sobre demandas, etc. + searchFileds = { + conceptId: MongoQuery.equalMatch.withField('tipoPrestacion.conceptId'), + estado: MongoQuery.equalMatch, + }; +} + +export const ListaEsperaCtr = new ListaEsperaResource({}); +export const ListaEsperaRouter = ListaEsperaCtr.makeRoutes(); + +/** + * @api {get} /listaEspera/:id Obtiene un registro de lista de espera por id + */ +export const find = async (req: Request, res: Response) => { + const data = await listaEspera.findById(req.params.id); + return res.json(data); +}; + +/** + * @api {get} /listaEspera Búsqueda de demanda insatisfecha (lista de espera) + */ +export const get = async (req: Request, res: Response) => { + const data = await buscarListaEspera(req); + return res.json(data); +}; + +/** + * @api {post} /listaEspera Carga de demanda insatisfecha + * + * Regla de negocio: si el paciente ya tiene un turno futuro asignado para + * la misma prestación, no se permite cargar la demanda. + */ +export const post = async (req: Request, res: Response, next) => { + try { + const turnoExistente = await buscarTurnoFuturoExistente(req); + if (turnoExistente) { + // si existe un turno a futuro, no se debería poder cargar la demanda insatisfecha + return res.status(422).json({ code: 'turno_existente', data: turnoExistente }); + } + + const listaSaved = await agregarDemanda(req); + return res.json({ data: { listado: listaSaved } }); + } catch (error) { + return next(error); + } +}; + +/** + * @api {put} /listaEspera/:id Actualización completa de un registro + */ +export const put = async (req: Request, res: Response, next) => { + const data: any = await listaEspera.findById(req.params.id); + if (!data) { + return next(new Error('No se ha encontrado el registro.')); + } + data.set(req.body); + const updated = await ListaEsperaCtr.update(data._id, data, req); + return res.json(updated); +}; + +/** + * @api {patch} /listaEspera/:id/:datoMod Actualización parcial por sub-recurso + * + * `:datoMod` indica qué porción del documento se está modificando: + * demandas | estado | llamados + */ +export const patch = async (req: Request, res: Response, next) => { + const data: any = await listaEspera.findById(req.params.id).exec(); + if (!data) { + return next(new Error('No se ha encontrado el registro.')); + } + + const datoMod = req.params.datoMod; + try { + if (datoMod === 'demandas') { + data.demandas = req.body; + } else if (datoMod === 'estado') { + data.estado = req.body.estado; + data.resolucion = { + fecha: req.body.fecha, + motivo: req.body.motivo, + }; + if (req.body.observacion) { + data.resolucion.observacion = req.body.observacion; + } + if (req.body.turno) { + data.resolucion.turno = await armarTurnoResolucion(req.body.turno.idAgenda, req.body.turno.id); + } + } else if (datoMod === 'llamados') { + data.llamados = req.body; + } + } catch (error) { + return next(error); + } + + const updated = await ListaEsperaCtr.update(data._id, data, req); + return res.json(updated); +}; + +/** + * @api {post} /listaEspera/IdAgenda/:id + * Envío masivo a lista de espera de los pacientes de una agenda suspendida. + */ +export const postIdAgenda = async (req: Request, res: Response, next) => { + const agenda: any = await Agenda.findById(req.params.id); + if (!agenda) { + return next(new Error('No se ha encontrado la agenda.')); + } + + await procesarOperacionAgenda(req, agenda); + return res.json(agenda); +}; + +ListaEsperaRouter.use(Auth.authenticate()); +ListaEsperaRouter.get('/listaEspera', asyncHandler(get)); +ListaEsperaRouter.get('/listaEspera/:id', asyncHandler(find)); +ListaEsperaRouter.post('/listaEspera', asyncHandler(post)); +ListaEsperaRouter.put('/listaEspera/:id', asyncHandler(put)); +ListaEsperaRouter.patch('/listaEspera/:id/:datoMod', asyncHandler(patch)); +ListaEsperaRouter.post('/listaEspera/IdAgenda/:id', asyncHandler(postIdAgenda)); diff --git a/modules/turnos/routes/listaEspera.ts b/modules/turnos/routes/listaEspera.ts deleted file mode 100644 index e81bef1130..0000000000 --- a/modules/turnos/routes/listaEspera.ts +++ /dev/null @@ -1,312 +0,0 @@ -import * as express from 'express'; -import * as moment from 'moment'; -import * as mongoose from 'mongoose'; -import { Auth } from '../../../auth/auth.class'; -import { getHistorialPaciente } from '../../../core-v2/mpi/paciente/paciente.controller'; -import { Agenda } from '../schemas/agenda'; -import { demanda, listaEspera } from '../schemas/listaEspera'; -import { defaultLimit, maxLimit } from './../../../config'; - -const async = require('async'); -const router = express.Router(); - -router.get('/listaEspera/:id*?', (req, res, next) => { - let opciones = {}; - - if (req.params.id) { - listaEspera.findById(req.params.id, (err, data) => { - if (err) { - return next(err); - } - res.json(data); - }); - } else { - if (req.query.conceptId) { - opciones['tipoPrestacion.conceptId'] = req.query.conceptId; - } - - if (req.query.estado) { - opciones['estado'] = req.query.estado; - } - - if (req.query.fechaDesde) { - const fechaDesde = moment(new Date(req.query.fechaDesde)).startOf('day').toDate(); - - if (req.query.fechaHasta) { - const fechaHasta = moment(new Date(req.query.fechaHasta)).endOf('day').toDate(); - opciones['demandas.fecha'] = { $gte: fechaDesde, $lte: fechaHasta }; - } else { - opciones['demandas.fecha'] = { $gte: fechaDesde }; - } - } - - if (req.query.prestacion) { - const terminos = req.query.prestacion?.split(',').map(term => term.trim()); - - opciones['$or'] = terminos.map(term => ({ - 'tipoPrestacion.term': RegExp('^.*' + term + '.*$', 'i') - })); - } - - const motivoFiltro = req.query.motivo - ? { motivo: RegExp('^.*' + req.query.motivo + '.*$', 'i') } - : {}; - - const organizacionFiltro = req.query.organizacion - ? { 'organizacion.id': { $in: req.query.organizacion.split(',').map((id) => mongoose.Types.ObjectId(id)) } } - : {}; - - if (req.query.motivo || req.query.organizacion) { - opciones = { - ...opciones, - demandas: { - $elemMatch: { - ...motivoFiltro, - ...organizacionFiltro - } - } - }; - } - - const skip: number = parseInt(req.query.skip || 0, 10); - const limit: number = Math.min(parseInt(req.query.limit || defaultLimit, 10), maxLimit); - - const pacienteAggregate = [ - { - $match: opciones, - }, - { - $lookup: { - from: 'paciente', - let: { pacienteId: '$paciente' }, - pipeline: [ - { - $match: { - $expr: { $eq: ['$_id', '$$pacienteId.id'] } - } - }, - ...(req.query.paciente ? [ - { - $match: { - $or: [ - { nombre: { $regex: req.query.paciente, $options: 'i' } }, - { apellido: { $regex: req.query.paciente, $options: 'i' } }, - { documento: { $regex: req.query.paciente, $options: 'i' } } - ] - } - } - ] : []) - ], - as: 'paciente' - } - }, - { - $unwind: { path: '$paciente', preserveNullAndEmptyArrays: true } - }, - { - $match: { - paciente: { $ne: null } - } - }, - { - $skip: skip, - }, - { - $limit: limit, - } - ]; - - const query = listaEspera.aggregate(pacienteAggregate); - - query.exec((err, data) => { - if (err) { return next(err); } - - res.json(data); - }); - } -}); - -router.post('/listaEspera', async (req, res, next) => { - const historial = await getHistorialPaciente({ - ...req, query: { - pacienteId: req.body.paciente.id, turnosProximos: true, estado: 'asignado', conceptId: req.body.tipoPrestacion.conceptId - } - }); - - const turno = historial.length ? historial.filter(item => moment(item.horaInicio).isAfter(moment())) : null; - - if (turno?.length) { - // si existe un turno a futuro, no se debería poder cargar la demanda insatisfecha - return res.status(422).json({ code: 'turno_existente', data: turno[0] }); - } - - const params = { - 'paciente.id': req.body.paciente.id, - 'tipoPrestacion.conceptId': req.body.tipoPrestacion.conceptId, - estado: 'pendiente' - }; - - const unaDemanda = { - profesional: req.body.demandas[0].Profesional, - organizacion: req.body.demandas[0].organizacion, - motivo: req.body.demandas[0].motivo, - fecha: moment().toDate(), - origen: req.body.demandas[0].origen - }; - - try { - const listaDocument: any = await listaEspera.findOne(params, (data: any) => { return data; }); - - let listaSaved; - if (listaDocument?.demandas) { - const newDemanda = new demanda(unaDemanda); - Auth.audit(newDemanda, req); - listaDocument.demandas.push(newDemanda); - - Auth.audit(listaDocument, req); - listaSaved = await listaDocument.save(); - } else { - const newListaDocument = new listaEspera(req.body); - Auth.audit(newListaDocument, req); - listaSaved = await newListaDocument.save(); - } - - res.json({ data: { listado: listaSaved } }); - } catch (error) { - return next(error); - } -}); - -router.put('/listaEspera/:id', (req, res, next) => { - listaEspera.findByIdAndUpdate(req.params.id, req.body, { new: true }, (err, data) => { - if (err) { - return next(err); - } - res.json(data); - }); -}); - -router.patch('/listaEspera/:id/:datoMod', async (req, res, next) => { - try { - const data: any = await listaEspera.findById(req.params.id).exec(); - if (!data) { - return next(new Error('No se ha encontrado el registro.')); - } - - const datoMod = req.params.datoMod; - if (datoMod === 'demandas') { - data.demandas = req.body; - } else if (datoMod === 'estado') { - data.estado = req.body.estado; - data.resolucion = { - fecha: req.body.fecha, - motivo: req.body.motivo, - }; - if (req.body.observacion) { - data.resolucion.observacion = req.body.observacion; - } - if (req.body.turno) { - const agenda: any = await Agenda.findById(req.body.turno.idAgenda).exec(); - if (!agenda) { - return next(new Error('No se ha encontrado la agenda.')); - } - - const turnoAgenda = agenda.bloques.flatMap(bloque => - bloque.turnos.filter(t => t._id.toString() === req.body.turno.id) - )[0]; - - if (!turnoAgenda) { - return next(new Error('No se ha encontrado el turno')); - } - - const turno = { - id: turnoAgenda._id, - horaInicio: turnoAgenda.horaInicio, - tipo: turnoAgenda.tipoTurno, - emitidoPor: turnoAgenda.emitidoPor, - fechaHoraDacion: turnoAgenda.fechaHoraDacion, - profesionales: agenda.profesionales, - idAgenda: req.body.turno.idAgenda, - organizacion: { - id: agenda.organizacion.id, - nombre: agenda.organizacion.nombre - }, - }; - data.resolucion.turno = turno; - } - } else if (datoMod === 'llamados') { - data.llamados = req.body; - } - - Auth.audit(data, req); - await data.save(); - res.json(data); - } catch (error) { - next(error); - } -}); - -router.delete('/listaEspera/:id', (req, res, next) => { - listaEspera.findByIdAndRemove(req.params.id, req.body, (err, data) => { - if (err) { return next(err); } - res.json(data); - }); -}); - -/* Si viene un id es porque se están enviando pacientes a la lista de espera desde una agenda suspendida*/ -router.post('/listaEspera/IdAgenda/:id', (req, res, next) => { - Agenda.findById(req.params.id, (err, data) => { - if (err) { - return next(err); - } - let listaEsperaPaciente: any[] = []; - - switch (req.body.op) { - case 'listaEsperaSuspensionAgenda': - listaEsperaPaciente = listaEsperaSuspensionAgenda(req, data, next); - break; - } - async.each(listaEsperaPaciente, (listaEsperaData, callback) => { - const newItem = new listaEspera(listaEsperaData); - - newItem.save((err1, item) => { - callback(); - }); - - }, (err2) => { - if (err2) { - return next(err2); - } - return res.json(data); - }); - }); -}); - -function listaEsperaSuspensionAgenda(req, data, next) { - const listaEsperaArray = []; - - if (req.body.pacientes.length > 0) { - for (let i = 0; i < req.body.pacientes.length; i++) { - const newListaEspera = {}; - newListaEspera['fecha'] = moment().format(); - newListaEspera['estado'] = 'Agenda Suspendida'; - newListaEspera['tipoPrestacion'] = req.body.pacientes[i].tipoPrestacion; - newListaEspera['profesional'] = data.profesionales[0]; - newListaEspera['paciente'] = req.body.pacientes[i].paciente; - - listaEsperaArray.push(newListaEspera); - } - } else { - const newListaEspera = {}; - newListaEspera['fecha'] = moment().format(); - newListaEspera['estado'] = 'Turno Cancelado'; - newListaEspera['tipoPrestacion'] = req.body.pacientes.tipoPrestacion; - newListaEspera['profesional'] = data.profesionales[0]; - newListaEspera['paciente'] = req.body.pacientes.paciente; - listaEsperaArray.push(newListaEspera); - } - - return listaEsperaArray; -} - -export = router;