From 65f589903c617716dd08054e897b63ca953eb075 Mon Sep 17 00:00:00 2001 From: AgustinRodriguez-Andes <63318331+agustin1996ra@users.noreply.github.com> Date: Mon, 14 Sep 2026 09:57:42 -0300 Subject: [PATCH] Script renovacion idRecetas --- modules/recetas/receta-schema.ts | 4 + scripts/analizar-idRecetas.ts | 208 ++++++++ scripts/analizar-recetas.ts | 199 ++++++++ scripts/reglas-recetas.ts | 813 +++++++++++++++++++++++++++++++ scripts/renovar-idRecetas.ts | 196 ++++++++ 5 files changed, 1420 insertions(+) create mode 100644 scripts/analizar-idRecetas.ts create mode 100644 scripts/analizar-recetas.ts create mode 100644 scripts/reglas-recetas.ts create mode 100644 scripts/renovar-idRecetas.ts diff --git a/modules/recetas/receta-schema.ts b/modules/recetas/receta-schema.ts index e9d1a9a35c..dcbd7cd820 100644 --- a/modules/recetas/receta-schema.ts +++ b/modules/recetas/receta-schema.ts @@ -161,6 +161,10 @@ export const recetaSchema = new mongoose.Schema({ type: String, required: false }, + idRecetaV1: { + type: String, + required: false + }, organizacion: { id: mongoose.SchemaTypes.ObjectId, nombre: String, diff --git a/scripts/analizar-idRecetas.ts b/scripts/analizar-idRecetas.ts new file mode 100644 index 0000000000..ce943f17b2 --- /dev/null +++ b/scripts/analizar-idRecetas.ts @@ -0,0 +1,208 @@ +/** + * Análisis de idRecetas con formato viejo. + * + * Lee la base de datos y reporta cuántas recetas tienen: + * - Sin idReceta + * - Formato viejo (20 caracteres: YYYYMMDDHHmmssmmmRRR) + * - Formato nuevo (13 caracteres: YYMMSSSSSSSSSP) + * - Formato desconocido + * + * Ejecución (desde la raíz del repo api/): + * - Con el launcher del repo: node scripts/index.js analizar-idRecetas [--estados=vigente,pendiente] + * - Standalone: node scripts/analizar-idRecetas.js [--estados=vigente,pendiente] + * + * Ejemplo: + * node scripts/index.js analizar-idRecetas + * node scripts/index.js analizar-idRecetas --estados=vigente,pendiente,vencida + */ + +import { Receta } from '../modules/recetas/receta-schema'; + +interface Estadisticas { + total: number; + sinId: number; + formatoViejo: number; + formatoNuevo: number; + formatoDesconocido: number; + ejemplosViejos: Array<{ + _id: string; + idReceta: string; + fechaRegistro: Date | null; + estado: string; + }>; + ejemplosDesconocidos: Array<{ + _id: string; + idReceta: string; + fechaRegistro: Date | null; + estado: string; + }>; +} + +function imprimirEncabezado(texto: string): void { + // eslint-disable-next-line no-console + console.log(''); + // eslint-disable-next-line no-console + console.log('=================================================================='); + // eslint-disable-next-line no-console + console.log(` ${texto}`); + // eslint-disable-next-line no-console + console.log('=================================================================='); +} + +function analizarFormato(idReceta: string): 'viejo' | 'nuevo' | 'desconocido' { + if (/^\d{20}$/.test(idReceta)) { + return 'viejo'; + } + if (/^\d{13}$/.test(idReceta)) { + return 'nuevo'; + } + return 'desconocido'; +} + +async function analizarIdRecetas(estados: string[]): Promise { + const query = { + 'estadoActual.tipo': { $in: estados } + }; + + const estadisticas: Estadisticas = { + total: 0, + sinId: 0, + formatoViejo: 0, + formatoNuevo: 0, + formatoDesconocido: 0, + ejemplosViejos: [], + ejemplosDesconocidos: [] + }; + + // Usar cursor con select y lean para reducir uso de memoria + // select: solo trae los campos necesarios + // lean: devuelve objetos plain JS (no documentos Mongoose, reduce memoria ~3x) + // cursor: procesa de a 100 documentos por vez en lugar de cargar todos + const cursor = Receta.find(query) + .select('idReceta estadoActual fechaRegistro') + .lean() + .cursor({ batchSize: 100 }); + + for await (const receta of cursor) { + estadisticas.total++; + + if (!receta.idReceta) { + estadisticas.sinId++; + } else { + const formato = analizarFormato(receta.idReceta); + + switch (formato) { + case 'viejo': + estadisticas.formatoViejo++; + if (estadisticas.ejemplosViejos.length < 10) { + estadisticas.ejemplosViejos.push({ + _id: String(receta._id), + idReceta: receta.idReceta, + fechaRegistro: receta.fechaRegistro || null, + estado: receta.estadoActual?.tipo || '?' + }); + } + break; + case 'nuevo': + estadisticas.formatoNuevo++; + break; + case 'desconocido': + estadisticas.formatoDesconocido++; + if (estadisticas.ejemplosDesconocidos.length < 10) { + estadisticas.ejemplosDesconocidos.push({ + _id: String(receta._id), + idReceta: receta.idReceta, + fechaRegistro: receta.fechaRegistro || null, + estado: receta.estadoActual?.tipo || '?' + }); + } + break; + } + } + } + + return estadisticas; +} + +function imprimirResultados(estadisticas: Estadisticas, estados: string[]): void { + imprimirEncabezado('ANÁLISIS DE IDRECETAS'); + + // eslint-disable-next-line no-console + console.log(`Estados consultados: ${estados.join(', ')}`); + // eslint-disable-next-line no-console + console.log(`Total de recetas: ${estadisticas.total}\n`); + + // eslint-disable-next-line no-console + console.log('--- Distribución por formato ---'); + // eslint-disable-next-line no-console + console.log(` Sin idReceta: ${estadisticas.sinId}`); + // eslint-disable-next-line no-console + console.log(` Formato viejo (20 chars): ${estadisticas.formatoViejo}`); + // eslint-disable-next-line no-console + console.log(` Formato nuevo (13 chars): ${estadisticas.formatoNuevo}`); + // eslint-disable-next-line no-console + console.log(` Formato desconocido: ${estadisticas.formatoDesconocido}`); + + const totalAMigrar = estadisticas.sinId + estadisticas.formatoViejo; + // eslint-disable-next-line no-console + console.log(`\n >>> Total a migrar: ${totalAMigrar}`); + + if (estadisticas.ejemplosViejos.length > 0) { + // eslint-disable-next-line no-console + console.log('\n--- Ejemplos de formato viejo (hasta 10) ---'); + estadisticas.ejemplosViejos.forEach(e => { + // eslint-disable-next-line no-console + console.log(` - ${e._id}: ${e.idReceta} (${e.estado}, ${e.fechaRegistro})`); + }); + } + + if (estadisticas.ejemplosDesconocidos.length > 0) { + // eslint-disable-next-line no-console + console.log('\n--- Ejemplos de formato desconocido (hasta 10) ---'); + estadisticas.ejemplosDesconocidos.forEach(e => { + // eslint-disable-next-line no-console + console.log(` - ${e._id}: ${e.idReceta} (${e.estado}, ${e.fechaRegistro})`); + }); + } +} + +async function run(done?: () => void): Promise { + let args = process.argv.slice(2); + const archivoEjecutado = process.argv[1] || ''; + // Cuando se corre vía scripts/index.js o jobs/manual.js, el primer + // argumento es el nombre del script y hay que descartarlo. + const viaLauncher = archivoEjecutado.endsWith('index.js') || archivoEjecutado.endsWith('manual.js'); + if (viaLauncher && args.length) { + args = args.slice(1); + } + + let estados = ['vigente', 'pendiente']; + for (const arg of args) { + if (arg.startsWith('--estados=')) { + estados = arg.split('=')[1].split(',').map(e => e.trim()); + } + } + + try { + const estadisticas = await analizarIdRecetas(estados); + imprimirResultados(estadisticas, estados); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error durante el análisis:', error); + } + + if (done) { + done(); + } +} + +export = run; + +// Permite ejecución directa: node scripts/analizar-idRecetas.js +if (require.main === module) { + run().catch(err => { + // eslint-disable-next-line no-console + console.error(err); + process.exit(1); + }); +} diff --git a/scripts/analizar-recetas.ts b/scripts/analizar-recetas.ts new file mode 100644 index 0000000000..60ef1d207a --- /dev/null +++ b/scripts/analizar-recetas.ts @@ -0,0 +1,199 @@ +/** + * Parser de recetas de ANDES. + * + * Lee un export de MongoDB (EJSON) de la colección `receta` y produce: + * 1. Un resumen general de las recetas (estados, dispensas, origen, + * tratamientos, rango de fechas). + * 2. Los hallazgos de la librería de reglas (recetas mal creadas o + * inconsistentes). + * 3. Una simulación de qué recetas vería recetAR para dispensar. + * + * Ejecución (desde la raíz del repo `api/`): + * - Standalone (sin conexión a la base): node scripts/analizar-recetas.js [--out ] + * - Con el launcher del repo: node scripts/index.js analizar-recetas [--out ] + * + * Ejemplo: + * node scripts/analizar-recetas.js ../querys-descargadas/andes.receta.11711233.json + */ + +import * as fs from 'fs'; +import { + aplicarReglas, + analizarVisibilidad, + construirContexto, + fmtFecha, + Hallazgo, + parsearRecetas, + resumenAnalisis +} from './reglas-recetas'; + +interface Opciones { + archivo: string | null; + salida: string | null; +} + +function parsearArgumentos(args: string[]): Opciones { + const opciones: Opciones = { archivo: null, salida: null }; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--out' || arg === '-o') { + opciones.salida = args[i + 1] || null; + i++; + } else if (arg.startsWith('-')) { + // flag no reconocido, se ignora + } else if (!opciones.archivo) { + opciones.archivo = arg; + } + } + return opciones; +} + +function imprimirEncabezado(texto: string): void { + // eslint-disable-next-line no-console + console.log(''); + // eslint-disable-next-line no-console + console.log('=================================================================='); + // eslint-disable-next-line no-console + console.log(` ${texto}`); + // eslint-disable-next-line no-console + console.log('=================================================================='); +} + +function imprimirSeccion(titulo: string, cuerpo: string): void { + // eslint-disable-next-line no-console + console.log(''); + // eslint-disable-next-line no-console + console.log(`[${titulo}]`); + // eslint-disable-next-line no-console + console.log(cuerpo); +} + +function imprimirResumen(recetas: any[], opciones: Opciones): void { + const resumen = resumenAnalisis(recetas); + imprimirSeccion('Resumen general', [ + `Archivo: ${opciones.archivo}`, + `Total de recetas: ${resumen.total}`, + `Por estadoActual: ${Object.entries(resumen.porEstado).map(([k, v]) => `${k}=${v}`).join(', ')}`, + `Por estadoDispensaActual: ${Object.entries(resumen.porEstadoDispensa).map(([k, v]) => `${k}=${v}`).join(', ')}`, + `Por origenExterno.app: ${Object.entries(resumen.porOrigen).map(([k, v]) => `${k}=${v}`).join(', ')}`, + `Rango fechaRegistro: ${resumen.fechaRegistroMin} -> ${resumen.fechaRegistroMax}`, + `Con idReceta: ${resumen.conIdReceta} de ${resumen.total}`, + `Con dispensa[]: ${resumen.conDispensa} de ${resumen.total}`, + `Con appNotificada: ${resumen.conAppNotificada} de ${resumen.total}`, + `Dispensables ahora (vigente + sin-dispensa): ${resumen.dispensablesAhora.length ? resumen.dispensablesAhora.join(', ') : 'ninguna'}` + ].join('\n')); + + imprimirSeccion('Tratamientos (grupos por idRegistro + conceptId)', resumen.tratamientos.map(t => + `- ${t.medicamento} (idRegistro=${t.idRegistro.slice(0, 12)}, conceptId=${t.conceptId})` + + `\n tratamientoProlongado=${t.tratamientoProlongado}, tiempoTratamiento.id=${t.tiempoTratamientoId}, recetas=${t.cantRecetas}` + + `\n ordenes: ${t.ordenes.join(',')}` + + `\n estados: ${t.estados.join(',')}` + + `\n estadosDispensa: ${t.estadosDispensa.join(',')}` + + `\n fechaRegistro: ${t.fechasRegistro.join(',')}` + ).join('\n')); +} + +function imprimirVisibilidad(recetas: any[], hoy: Date): void { + const resultado = analizarVisibilidad(recetas, hoy); + imprimirSeccion(`Visibilidad recetAR (estado=${resultado.estadoConsultado}, ventana ${resultado.desde} -> ${resultado.hasta})`, [ + 'Recetas que recetAR VERÍA para dispensar:', + ...(resultado.visibles.length ? resultado.visibles.map(v => + ` - ${v.idReceta} (${v.medicamento}) estado=${v.estado}, dispensa=${v.estadoDispensa}, fechaRegistro=${v.fechaRegistro}` + ) : [' - ninguna']), + '', + 'Recetas que NO aparecerían (y motivo):', + ...(resultado.noVisibles.length ? resultado.noVisibles.map(v => + ` - ${v.idReceta} (${v.medicamento}) estado=${v.estado}, dispensa=${v.estadoDispensa}, fechaRegistro=${v.fechaRegistro} -> ${v.motivo}` + ) : [' - ninguna']) + ].join('\n')); +} + +function imprimirHallazgos(hallazgos: Hallazgo[]): void { + const conteoPorRegla: Record = {}; + hallazgos.forEach(h => { + conteoPorRegla[h.regla] = (conteoPorRegla[h.regla] || 0) + 1; + }); + + imprimirSeccion(`Hallazgos (${hallazgos.length})`, [ + 'Resumen por regla:', + ...Object.entries(conteoPorRegla).sort((a, b) => b[1] - a[1]).map(([regla, n]) => ` ${regla}: ${n}`), + '', + 'Detalle:', + ...hallazgos.map(h => + ` [${h.severidad}] ${h.regla} - ${h.nombre} | idReceta=${h.idReceta} (${h.recetaId}) | ${h.medicamento} | fechaRegistro=${h.fechaRegistro}` + + `\n ${h.detalle}` + ) + ].join('\n')); +} + +async function analizar(opciones: Opciones): Promise { + if (!opciones.archivo) { + // eslint-disable-next-line no-console + console.log('Uso: node scripts/analizar-recetas.js [--out ]'); + return; + } + if (!fs.existsSync(opciones.archivo)) { + // eslint-disable-next-line no-console + console.error(`No existe el archivo: ${opciones.archivo}`); + process.exitCode = 1; + return; + } + + const contenido = fs.readFileSync(opciones.archivo, 'utf8'); + const recetas = parsearRecetas(contenido); + const hoy = new Date(); + const contexto = construirContexto(recetas, hoy); + const hallazgos = aplicarReglas(recetas, contexto); + const resumen = resumenAnalisis(recetas); + const visibilidad = analizarVisibilidad(recetas, hoy); + + imprimirEncabezado('ANÁLISIS DE RECETAS DE ANDES'); + imprimirResumen(recetas, opciones); + imprimirVisibilidad(recetas, hoy); + imprimirHallazgos(hallazgos); + + const salida = opciones.salida || (opciones.archivo.replace(/\.json$/i, '') + '-resultado.json'); + const resultado = { + archivo: opciones.archivo, + fechaAnalisis: fmtFecha(hoy), + resumen, + visibilidad, + hallazgos, + conteoPorRegla: hallazgos.reduce>((acc, h) => { + acc[h.regla] = (acc[h.regla] || 0) + 1; + return acc; + }, {}) + }; + fs.writeFileSync(salida, JSON.stringify(resultado, null, 2), 'utf8'); + // eslint-disable-next-line no-console + console.log(''); + // eslint-disable-next-line no-console + console.log(`Resultado guardado en: ${salida}`); +} + +async function run(done?: () => void): Promise { + let args = process.argv.slice(2); + const archivoEjecutado = process.argv[1] || ''; + // Cuando se corre vía scripts/index.js o jobs/manual.js, el primer + // argumento es el nombre del script y hay que descartarlo. + const viaLauncher = archivoEjecutado.endsWith('index.js') || archivoEjecutado.endsWith('manual.js'); + if (viaLauncher && args.length) { + args = args.slice(1); + } + const opciones = parsearArgumentos(args); + await analizar(opciones); + if (done) { + done(); + } +} + +export = run; + +// Permite ejecución directa: node scripts/analizar-recetas.js +if (require.main === module) { + run().catch(err => { + // eslint-disable-next-line no-console + console.error(err); + process.exit(1); + }); +} diff --git a/scripts/reglas-recetas.ts b/scripts/reglas-recetas.ts new file mode 100644 index 0000000000..3746f74446 --- /dev/null +++ b/scripts/reglas-recetas.ts @@ -0,0 +1,813 @@ +/** + * Librería de análisis de recetas de ANDES. + * + * Normaliza un export de MongoDB (EJSON) de la colección `receta` y lo + * procesa con una serie de reglas para detectar recetas mal creadas o + * con datos inconsistentes. El objetivo es poder diagnosticar casos en + * los que una receta no aparece para dispensar (recetAR) o se ve "rara" + * en ANDES. + * + * Las reglas están pensadas como una "librería de casos de error": + * cada una tiene un id, una severidad, una descripción y una sugerencia + * de reparación, para que un futuro script (p.ej. reparar-recetas) pueda + * reutilizarlas. + */ + +export interface Receta { + _id?: any; + idReceta?: string; + organizacion?: any; + profesional?: any; + fechaRegistro?: Date; + fechaPrestacion?: Date; + idPrestacion?: string; + idRegistro?: string; + diagnostico?: any; + medicamento?: any; + dispensa?: any[]; + estados?: any[]; + estadoActual?: any; + estadosDispensa?: any[]; + estadoDispensaActual?: any; + paciente?: any; + appNotificada?: any[]; + origenExterno?: any; + createdAt?: Date; + updatedAt?: Date; + createdBy?: any; + updatedBy?: any; + __v?: number; + renovacion?: string; +} + +export interface Hallazgo { + regla: string; + categoria: string; + severidad: 'info' | 'warning' | 'error'; + nombre: string; + recetaId: string; + idReceta: string; + medicamento: string; + fechaRegistro: string; + detalle: string; +} + +export interface Grupo { + idRegistro: string; + conceptId: string; + medicamento: string; + tratamientoProlongado: boolean; + tiempoTratamientoId: string | null; + recetas: Receta[]; +} + +export interface ContextoRegla { + hoy: Date; + grupos: Grupo[]; + gruposPorReceta: Map; +} + +export interface Regla { + id: string; + categoria: string; + nombre: string; + severidad: 'info' | 'warning' | 'error'; + descripcion: string; + sugerenciaReparacion: string; + evaluar: (receta: Receta, contexto: ContextoRegla) => string | string[] | null; +} + +export interface TratamientoResumen { + idRegistro: string; + conceptId: string; + medicamento: string; + tratamientoProlongado: boolean; + tiempoTratamientoId: string | null; + cantRecetas: number; + ordenes: number[]; + estados: string[]; + estadosDispensa: string[]; + fechasRegistro: string[]; +} + +export interface ResumenAnalisis { + total: number; + porEstado: Record; + porEstadoDispensa: Record; + porOrigen: Record; + fechaRegistroMin: string; + fechaRegistroMax: string; + conIdReceta: number; + conDispensa: number; + conAppNotificada: number; + tratamientos: TratamientoResumen[]; + dispensablesAhora: string[]; +} + +export interface ResultadoVisibilidad { + desde: string; + hasta: string; + estadoConsultado: string; + visibles: Array>; + noVisibles: Array>; +} + +const MS_DIA = 86400000; + +export function aFecha(valor: any): Date | null { + if (!valor) { + return null; + } + if (valor instanceof Date) { + return Number.isNaN(valor.getTime()) ? null : valor; + } + const fecha = new Date(valor); + return Number.isNaN(fecha.getTime()) ? null : fecha; +} + +export function fmtFecha(valor: any): string { + const fecha = aFecha(valor); + if (!fecha) { + return '-'; + } + return fecha.toISOString(); +} + +/** + * Convierte un export EJSON (de mongoexport/Compass) a estructuras JS + * planas. Maneja {$oid}, {$date} y {$numberLong}, tanto anidados como + * a nivel de arreglo. + */ +export function normalizarEJSON(valor: any): any { + if (Array.isArray(valor)) { + return valor.map(item => normalizarEJSON(item)); + } + if (valor !== null && typeof valor === 'object') { + const claves = Object.keys(valor); + if (claves.length === 1 && claves[0] === '$oid') { + return valor.$oid; + } + if (claves.length === 1 && claves[0] === '$numberLong') { + return parseInt(valor.$numberLong, 10); + } + if (claves.includes('$date')) { + const valorFecha = valor.$date; + if (typeof valorFecha === 'string' || typeof valorFecha === 'number') { + return new Date(valorFecha); + } + if (valorFecha && typeof valorFecha === 'object' && valorFecha.$numberLong) { + return new Date(parseInt(valorFecha.$numberLong, 10)); + } + return null; + } + const resultado: any = {}; + claves.forEach(clave => { + resultado[clave] = normalizarEJSON(valor[clave]); + }); + return resultado; + } + return valor; +} + +export function parsearRecetas(contenido: string): Receta[] { + const crudo = JSON.parse(contenido); + const arreglo = Array.isArray(crudo) ? crudo : [crudo]; + return arreglo.map(item => normalizarEJSON(item) as Receta); +} + +export function idRecetaDe(receta: Receta): string { + return receta.idReceta || (receta._id ? String(receta._id) : '?'); +} + +export function medicamentoDe(receta: Receta): string { + const concepto = receta.medicamento && receta.medicamento.concepto; + return (concepto && (concepto.term || concepto.fsn)) || '?'; +} + +export function construirGrupos(recetas: Receta[]): Grupo[] { + const mapa = new Map(); + recetas.forEach(receta => { + const idRegistro = receta.idRegistro || ''; + const conceptId = (receta.medicamento && receta.medicamento.concepto && receta.medicamento.concepto.conceptId) || ''; + const clave = `${idRegistro}|${conceptId}`; + if (!mapa.has(clave)) { + const tiempoTratamiento = receta.medicamento && receta.medicamento.tiempoTratamiento; + mapa.set(clave, { + idRegistro, + conceptId, + medicamento: medicamentoDe(receta), + tratamientoProlongado: !!(receta.medicamento && receta.medicamento.tratamientoProlongado), + tiempoTratamientoId: (tiempoTratamiento && tiempoTratamiento.id !== null && tiempoTratamiento.id !== undefined) ? String(tiempoTratamiento.id) : null, + recetas: [] + }); + } + (mapa.get(clave) as Grupo).recetas.push(receta); + }); + return Array.from(mapa.values()); +} + +function resumenTratamiento(grupo: Grupo): TratamientoResumen { + return { + idRegistro: grupo.idRegistro, + conceptId: grupo.conceptId, + medicamento: grupo.medicamento, + tratamientoProlongado: grupo.tratamientoProlongado, + tiempoTratamientoId: grupo.tiempoTratamientoId, + cantRecetas: grupo.recetas.length, + ordenes: grupo.recetas.map(receta => receta.medicamento && receta.medicamento.ordenTratamiento).sort((a, b) => a - b), + estados: grupo.recetas.map(receta => (receta.estadoActual && receta.estadoActual.tipo) || '?'), + estadosDispensa: grupo.recetas.map(receta => (receta.estadoDispensaActual && receta.estadoDispensaActual.tipo) || '?'), + fechasRegistro: grupo.recetas.map(receta => fmtFecha(receta.fechaRegistro)) + }; +} + +export function resumenAnalisis(recetas: Receta[]): ResumenAnalisis { + const porEstado: Record = {}; + const porEstadoDispensa: Record = {}; + const porOrigen: Record = {}; + let conIdReceta = 0; + let conDispensa = 0; + let conAppNotificada = 0; + let min: Date | null = null; + let max: Date | null = null; + + recetas.forEach(receta => { + const estado = (receta.estadoActual && receta.estadoActual.tipo) || 'sin-estado'; + porEstado[estado] = (porEstado[estado] || 0) + 1; + const estadoDispensa = (receta.estadoDispensaActual && receta.estadoDispensaActual.tipo) || 'sin-estado'; + porEstadoDispensa[estadoDispensa] = (porEstadoDispensa[estadoDispensa] || 0) + 1; + const origen = (receta.origenExterno && receta.origenExterno.app) || 'andes'; + porOrigen[origen] = (porOrigen[origen] || 0) + 1; + if (receta.idReceta) { + conIdReceta++; + } + if (receta.dispensa && receta.dispensa.length) { + conDispensa++; + } + if (receta.appNotificada && receta.appNotificada.length) { + conAppNotificada++; + } + const fechaRegistro = aFecha(receta.fechaRegistro); + if (fechaRegistro) { + if (!min || fechaRegistro < min) { + min = fechaRegistro; + } + if (!max || fechaRegistro > max) { + max = fechaRegistro; + } + } + }); + + const dispensables = recetas.filter(receta => + receta.estadoActual && receta.estadoActual.tipo === 'vigente' && + receta.estadoDispensaActual && receta.estadoDispensaActual.tipo === 'sin-dispensa' + ); + + return { + total: recetas.length, + porEstado, + porEstadoDispensa, + porOrigen, + fechaRegistroMin: fmtFecha(min), + fechaRegistroMax: fmtFecha(max), + conIdReceta, + conDispensa, + conAppNotificada, + tratamientos: construirGrupos(recetas).map(resumenTratamiento), + dispensablesAhora: dispensables.map(idRecetaDe) + }; +} + +/** + * Simula la consulta que hace recetAR a ANDES para saber qué recetas + * aparecerían para dispensar. RecetAR consulta /modules/recetas/filtros + * con estado=vigente y, por defecto, la ventana de fechaRegistro es + * [hoy - diasVigencia, hoy] (ver buscarRecetasConFiltros en el módulo + * recetas de ANDES). + */ +export function analizarVisibilidad(recetas: Receta[], hoy: Date, diasVigencia = 30): ResultadoVisibilidad { + const desde = new Date(hoy); + desde.setHours(0, 0, 0, 0); + desde.setDate(desde.getDate() - diasVigencia); + const hasta = new Date(hoy); + hasta.setHours(23, 59, 59, 999); + + const visibles: Array> = []; + const noVisibles: Array> = []; + + recetas.forEach(receta => { + const estado = (receta.estadoActual && receta.estadoActual.tipo) || '?'; + const estadoDispensa = (receta.estadoDispensaActual && receta.estadoDispensaActual.tipo) || '?'; + const fechaRegistro = aFecha(receta.fechaRegistro); + const enVentana = !!fechaRegistro && fechaRegistro >= desde && fechaRegistro <= hasta; + const coincideEstado = estado === 'vigente' || estadoDispensa === 'vigente'; + const info: Record = { + idReceta: idRecetaDe(receta), + recetaId: String(receta._id), + estado, + estadoDispensa, + fechaRegistro: fmtFecha(fechaRegistro), + medicamento: medicamentoDe(receta) + }; + if (coincideEstado && enVentana) { + visibles.push(info); + } else { + const motivo = !enVentana ? 'fuera de ventana de fechas' : 'no coincide con estado=vigente'; + noVisibles.push({ ...info, motivo }); + } + }); + + return { + desde: fmtFecha(desde), + hasta: fmtFecha(hasta), + estadoConsultado: 'vigente', + visibles, + noVisibles + }; +} + +export function construirContexto(recetas: Receta[], hoy: Date): ContextoRegla { + const grupos = construirGrupos(recetas); + const gruposPorReceta = new Map(); + grupos.forEach(grupo => { + grupo.recetas.forEach(receta => { + gruposPorReceta.set(String(receta._id), grupo.recetas); + }); + }); + return { hoy, grupos, gruposPorReceta }; +} + +export function aplicarReglas(recetas: Receta[], contexto: ContextoRegla): Hallazgo[] { + const hallazgos: Hallazgo[] = []; + recetas.forEach(receta => { + reglas.forEach(regla => { + let detalle: string | string[] | null; + try { + detalle = regla.evaluar(receta, contexto); + } catch (err) { + detalle = `Error al evaluar: ${(err as Error).message}`; + } + if (!detalle) { + return; + } + const detalles = Array.isArray(detalle) ? detalle : [detalle]; + detalles.forEach(texto => { + hallazgos.push({ + regla: regla.id, + categoria: regla.categoria, + severidad: regla.severidad, + nombre: regla.nombre, + recetaId: String(receta._id), + idReceta: idRecetaDe(receta), + medicamento: medicamentoDe(receta), + fechaRegistro: fmtFecha(receta.fechaRegistro), + detalle: texto + }); + }); + }); + }); + return hallazgos; +} + +export const reglas: Regla[] = [ + { + id: 'ESTRUCTURA-01', + categoria: 'Estructura', + nombre: 'estadoActual desincronizado con estados[]', + severidad: 'error', + descripcion: 'El campo estadoActual no coincide con el último elemento de estados[]. El hook pre("save") del schema mantiene ambos sincronizados; si difieren la receta quedó mal persistida.', + sugerenciaReparacion: 'Reasignar estadoActual = estados[estados.length - 1] y guardar.', + evaluar: (receta) => { + const estados = receta.estados || []; + const ultimo = estados[estados.length - 1]; + if (!receta.estadoActual) { + return 'Falta estadoActual.'; + } + if (!ultimo) { + return null; + } + const difiereTipo = receta.estadoActual.tipo !== ultimo.tipo; + const difiereId = receta.estadoActual._id && ultimo._id && String(receta.estadoActual._id) !== String(ultimo._id); + if (difiereTipo || difiereId) { + return `estadoActual=${receta.estadoActual.tipo} vs estados[]=${ultimo.tipo}.`; + } + return null; + } + }, + { + id: 'ESTRUCTURA-02', + categoria: 'Estructura', + nombre: 'estadoDispensaActual desincronizado con estadosDispensa[]', + severidad: 'error', + descripcion: 'El campo estadoDispensaActual no coincide con el último elemento de estadosDispensa[].', + sugerenciaReparacion: 'Reasignar estadoDispensaActual = estadosDispensa[estadosDispensa.length - 1] y guardar.', + evaluar: (receta) => { + const estadosDispensa = receta.estadosDispensa || []; + const ultimo = estadosDispensa[estadosDispensa.length - 1]; + if (!receta.estadoDispensaActual) { + return 'Falta estadoDispensaActual.'; + } + if (!ultimo) { + return null; + } + const difiereTipo = receta.estadoDispensaActual.tipo !== ultimo.tipo; + const difiereId = receta.estadoDispensaActual._id && ultimo._id && String(receta.estadoDispensaActual._id) !== String(ultimo._id); + if (difiereTipo || difiereId) { + return `estadoDispensaActual=${receta.estadoDispensaActual.tipo} vs estadosDispensa[]=${ultimo.tipo}.`; + } + return null; + } + }, + { + id: 'ESTRUCTURA-03', + categoria: 'Estructura', + nombre: 'Receta sin idReceta', + severidad: 'warning', + descripcion: 'idReceta es el identificador legible de la receta. Se autogenera al buscar; su ausencia sugiere que la receta nunca fue listada o quedó incompleta.', + sugerenciaReparacion: 'Ejecutar script renovar-idRecetas para generar idReceta con el nuevo formato.', + evaluar: (receta) => (receta.idReceta ? null : 'Receta sin idReceta.') + }, + { + id: 'IDRECETA-01', + categoria: 'ID Receta', + nombre: 'idReceta con formato viejo (20 caracteres)', + severidad: 'warning', + descripcion: 'El idReceta tiene el formato anterior YYYYMMDDHHmmssmmmRRR (20 caracteres). Debería migrarse al formato nuevo YYMMSSSSSSSSSP (13 caracteres).', + sugerenciaReparacion: 'Ejecutar script renovar-idRecetas para migrar al formato nuevo.', + evaluar: (receta) => { + if (!receta.idReceta) { + return null; + } + const id = receta.idReceta; + if (/^\d{20}$/.test(id)) { + return `idReceta="${id}" tiene formato viejo de 20 caracteres.`; + } + return null; + } + }, + { + id: 'ESTRUCTURA-04', + categoria: 'Estructura', + nombre: 'Arrays de estados vacíos', + severidad: 'error', + descripcion: 'estados[] y/o estadosDispensa[] deben tener al menos un elemento al momento de crear la receta.', + sugerenciaReparacion: 'Restaurar el estado inicial (vigente/sin-dispensa) o la transición faltante.', + evaluar: (receta) => { + const faltas: string[] = []; + if (!receta.estados || receta.estados.length === 0) { + faltas.push('estados[] vacío'); + } + if (!receta.estadosDispensa || receta.estadosDispensa.length === 0) { + faltas.push('estadosDispensa[] vacío'); + } + return faltas.length ? faltas.join('; ') : null; + } + }, + { + id: 'NEGOCIO-01', + categoria: 'Negocio', + nombre: 'Receta finalizada sin dispensa', + severidad: 'error', + descripcion: 'Una receta solo debería finalizar al dispensarse. finalizada + sin-dispensa es una contradicción.', + sugerenciaReparacion: 'Verificar si hubo dispensa en el sistema externo; si no, restaurar el estado previo (vigente).', + evaluar: (receta) => { + const estado = receta.estadoActual && receta.estadoActual.tipo; + const estadoDispensa = receta.estadoDispensaActual && receta.estadoDispensaActual.tipo; + if (estado === 'finalizada' && estadoDispensa === 'sin-dispensa') { + return 'Receta finalizada sin dispensa registrada.'; + } + return null; + } + }, + { + id: 'NEGOCIO-02', + categoria: 'Negocio', + nombre: 'Dispensada sin entrada en dispensa[]', + severidad: 'error', + descripcion: 'estadoDispensaActual indica dispensa pero no hay entrada coincidente en dispensa[].', + sugerenciaReparacion: 'Reconstruir el arreglo dispensa[] a partir del origen externo o cancelar la dispensa fantasma.', + evaluar: (receta) => { + const estadoDispensa = receta.estadoDispensaActual && receta.estadoDispensaActual.tipo; + if (estadoDispensa !== 'dispensada' && estadoDispensa !== 'dispensa-parcial') { + return null; + } + const dispensas = receta.dispensa || []; + if (dispensas.length === 0) { + return `Estado ${estadoDispensa} pero dispensa[] vacío.`; + } + const idDispensaApp = receta.estadoDispensaActual && receta.estadoDispensaActual.idDispensaApp; + if (idDispensaApp && !dispensas.some(d => String(d.idDispensaApp) === String(idDispensaApp))) { + return `estadoDispensaActual.idDispensaApp=${idDispensaApp} no coincide con dispensa[].`; + } + return null; + } + }, + { + id: 'NEGOCIO-03', + categoria: 'Negocio', + nombre: 'Dispensa sin medicamentos', + severidad: 'info', + descripcion: 'Entradas de dispensa[] con el arreglo medicamentos vacío. Puede ser normal si el sistema externo no envía el desglose, pero dificulta saber qué se dispensó.', + sugerenciaReparacion: 'Completar medicamentos con los datos del medicamento de la receta si corresponde.', + evaluar: (receta) => { + const vacias = (receta.dispensa || []).filter(d => !d.medicamentos || d.medicamentos.length === 0); + return vacias.length ? `dispensa[] con ${vacias.length} entrada(s) sin medicamentos.` : null; + } + }, + { + id: 'NEGOCIO-04', + categoria: 'Negocio', + nombre: 'Estado vigente/vencida con dispensa', + severidad: 'error', + descripcion: 'Una receta vigente o vencida no debería tener dispensa registrada.', + sugerenciaReparacion: 'Conciliar con el sistema externo el estado real de la dispensa.', + evaluar: (receta) => { + const estado = receta.estadoActual && receta.estadoActual.tipo; + const estadoDispensa = receta.estadoDispensaActual && receta.estadoDispensaActual.tipo; + if ((estado === 'vigente' || estado === 'vencida') && (estadoDispensa === 'dispensada' || estadoDispensa === 'dispensa-parcial')) { + return `${estado} con estado de dispensa ${estadoDispensa}.`; + } + return null; + } + }, + { + id: 'GRUPO-01', + categoria: 'Tratamiento', + nombre: 'ordenTratamiento faltante o duplicado en el grupo', + severidad: 'error', + descripcion: 'En un tratamiento prolongado las recetas del mismo idRegistro+conceptId deben tener ordenTratamiento 0..N-1 sin repetir.', + sugerenciaReparacion: 'Renumerar ordenTratamiento en 0..N-1 preservando el orden por fechaRegistro.', + evaluar: (receta, contexto) => { + const grupo = contexto.gruposPorReceta.get(String(receta._id)); + if (!grupo || grupo.length < 2) { + return null; + } + const ordenes = grupo + .map(r => r.medicamento && r.medicamento.ordenTratamiento) + .filter(n => typeof n === 'number') + .sort((a, b) => a - b); + const esperados = Array.from({ length: grupo.length }, (_, i) => i); + const faltantes = esperados.filter(n => !ordenes.includes(n)); + const duplicados = ordenes.filter((n, i) => ordenes.indexOf(n) !== i); + const mensajes: string[] = []; + if (faltantes.length) { + mensajes.push(`ordenTratamiento faltantes: ${faltantes.join(',')}`); + } + if (duplicados.length) { + mensajes.push(`ordenTratamiento duplicados: ${duplicados.join(',')}`); + } + return mensajes.length ? mensajes.join('; ') : null; + } + }, + { + id: 'GRUPO-02', + categoria: 'Tratamiento', + nombre: 'Cantidad de recetas del grupo distinta a tiempoTratamiento', + severidad: 'warning', + descripcion: 'Para un tratamiento prolongado se crean tantas recetas como meses indica tiempoTratamiento.id.', + sugerenciaReparacion: 'Crear o eliminar las recetas faltantes/supernumerarias del tratamiento.', + evaluar: (receta, contexto) => { + const grupo = contexto.gruposPorReceta.get(String(receta._id)); + if (!grupo) { + return null; + } + const medicamento = receta.medicamento || {}; + const tiempoTratamiento = medicamento.tiempoTratamiento; + if (!medicamento.tratamientoProlongado || !tiempoTratamiento || tiempoTratamiento.id === null || tiempoTratamiento.id === undefined) { + return null; + } + const esperado = parseInt(String(tiempoTratamiento.id), 10); + if (!Number.isNaN(esperado) && grupo.length !== esperado) { + return `Grupo con ${grupo.length} receta(s) pero tiempoTratamiento.id=${esperado}.`; + } + return null; + } + }, + { + id: 'GRUPO-03', + categoria: 'Tratamiento', + nombre: 'Pendiente con fechaRegistro ya pasada (receta "pegada")', + severidad: 'warning', + descripcion: 'El job actualizarEstadosRecetas pasa las pendientes a vigentes cuando su fechaRegistro vence. Una pendiente con fechaRegistro pasada quedó "pegada" y no se ve para dispensar.', + sugerenciaReparacion: 'Aplicar la transición pendiente -> vigente manualmente (o esperar el job si no corrió).', + evaluar: (receta, contexto) => { + if (!receta.estadoActual || receta.estadoActual.tipo !== 'pendiente') { + return null; + } + const fechaRegistro = aFecha(receta.fechaRegistro); + if (!fechaRegistro) { + return null; + } + const dias = (contexto.hoy.getTime() - fechaRegistro.getTime()) / MS_DIA; + if (dias > 2) { + return `Pendiente con fechaRegistro ${fmtFecha(fechaRegistro)} hace ${Math.floor(dias)} días (debería haber pasado a vigente).`; + } + return null; + } + }, + { + id: 'GRUPO-04', + categoria: 'Tratamiento', + nombre: 'Más de una receta vigente en el grupo', + severidad: 'warning', + descripcion: 'Solo la primera receta (orden 0) de un tratamiento debería estar vigente a la vez.', + sugerenciaReparacion: 'Revisar los estados del grupo y dejar una única receta vigente.', + evaluar: (receta, contexto) => { + const grupo = contexto.gruposPorReceta.get(String(receta._id)); + if (!grupo || grupo.length < 2) { + return null; + } + const vigentes = grupo.filter(r => r.estadoActual && r.estadoActual.tipo === 'vigente'); + if (vigentes.length > 1) { + return `Grupo con ${vigentes.length} recetas vigentes simultáneas.`; + } + return null; + } + }, + { + id: 'FECHAS-01', + categoria: 'Fechas', + nombre: 'fechaRegistro futura fuera de lo esperado', + severidad: 'warning', + descripcion: 'En un tratamiento prolongado las recetas se registran +30 días cada una. Una fechaRegistro mucho más allá de lo esperado (o más de 180 días posterior a createdAt) es anómala.', + sugerenciaReparacion: 'Corregir fechaRegistro de la receta.', + evaluar: (receta, contexto) => { + const grupo = contexto.gruposPorReceta.get(String(receta._id)); + const fechaRegistro = aFecha(receta.fechaRegistro); + if (grupo && grupo.length > 1 && fechaRegistro) { + const fechas = grupo + .map(r => aFecha(r.fechaRegistro)) + .filter((f): f is Date => !!f) + .map(f => f.getTime()); + if (fechas.length) { + const base = Math.min(...fechas); + const esperadoMax = base + (grupo.length - 1) * 30 * MS_DIA + 2 * MS_DIA; + if (fechaRegistro.getTime() > esperadoMax) { + return `fechaRegistro ${fmtFecha(fechaRegistro)} supera lo esperado para el tratamiento (base + ${(grupo.length - 1) * 30} días).`; + } + } + } + const createdAt = aFecha(receta.createdAt); + if (fechaRegistro && createdAt && (fechaRegistro.getTime() - createdAt.getTime()) > 180 * MS_DIA) { + return `fechaRegistro ${fmtFecha(fechaRegistro)} más de 180 días posterior a createdAt ${fmtFecha(createdAt)}.`; + } + return null; + } + }, + { + id: 'FECHAS-02', + categoria: 'Fechas', + nombre: 'fechaPrestacion faltante o posterior a fechaRegistro', + severidad: 'warning', + descripcion: 'fechaPrestacion debe existir y no ser posterior a fechaRegistro.', + sugerenciaReparacion: 'Completar o corregir fechaPrestacion.', + evaluar: (receta) => { + if (!receta.fechaPrestacion) { + return 'Falta fechaPrestacion.'; + } + const fechaRegistro = aFecha(receta.fechaRegistro); + const fechaPrestacion = aFecha(receta.fechaPrestacion); + if (fechaRegistro && fechaPrestacion && fechaPrestacion.getTime() > fechaRegistro.getTime()) { + return `fechaPrestacion ${fmtFecha(fechaPrestacion)} posterior a fechaRegistro ${fmtFecha(fechaRegistro)}.`; + } + return null; + } + }, + { + id: 'FECHAS-03', + categoria: 'Fechas', + nombre: 'createdAt posterior a updatedAt', + severidad: 'info', + descripcion: 'Inconsistencia en la auditoría del documento.', + sugerenciaReparacion: 'Revisar si hubo manipulación manual del documento.', + evaluar: (receta) => { + const createdAt = aFecha(receta.createdAt); + const updatedAt = aFecha(receta.updatedAt); + if (createdAt && updatedAt && createdAt.getTime() > updatedAt.getTime()) { + return `createdAt ${fmtFecha(createdAt)} posterior a updatedAt ${fmtFecha(updatedAt)}.`; + } + return null; + } + }, + { + id: 'DATOS-01', + categoria: 'Datos', + nombre: 'Medicamento incompleto', + severidad: 'error', + descripcion: 'El medicamento requiere conceptId, cantidad y cantEnvases para poder dispensarse.', + sugerenciaReparacion: 'Completar los datos del medicamento según la prescripción original.', + evaluar: (receta) => { + const medicamento = receta.medicamento; + if (!medicamento) { + return 'Falta medicamento.'; + } + const faltas: string[] = []; + if (!medicamento.concepto || !medicamento.concepto.conceptId) { + faltas.push('conceptId'); + } + if (!medicamento.cantidad) { + faltas.push('cantidad'); + } + if (!medicamento.cantEnvases) { + faltas.push('cantEnvases'); + } + if (!medicamento.concepto || (!medicamento.concepto.term && !medicamento.concepto.fsn)) { + faltas.push('concepto.term/fsn'); + } + return faltas.length ? `Medicamento incompleto: ${faltas.join(', ')}.` : null; + } + }, + { + id: 'DATOS-02', + categoria: 'Datos', + nombre: 'Profesional incompleto', + severidad: 'warning', + descripcion: 'El profesional debe tener documento, matrícula y profesión para validar la receta.', + sugerenciaReparacion: 'Actualizar el profesional con los datos de su ficha en ANDES.', + evaluar: (receta) => { + const profesional = receta.profesional; + if (!profesional) { + return 'Falta profesional.'; + } + const faltas: string[] = []; + if (!profesional.documento) { + faltas.push('documento'); + } + if (!profesional.matricula) { + faltas.push('matricula'); + } + if (!profesional.profesion) { + faltas.push('profesion'); + } + return faltas.length ? `Profesional incompleto: ${faltas.join(', ')}.` : null; + } + }, + { + id: 'DATOS-03', + categoria: 'Datos', + nombre: 'Faltan idPrestacion / idRegistro / organizacion', + severidad: 'error', + descripcion: 'Son campos obligatorios del schema de receta.', + sugerenciaReparacion: 'Completar los campos faltantes con los datos del registro de origen.', + evaluar: (receta) => { + const faltas: string[] = []; + if (!receta.idPrestacion) { + faltas.push('idPrestacion'); + } + if (!receta.idRegistro) { + faltas.push('idRegistro'); + } + if (!receta.organizacion) { + faltas.push('organizacion'); + } + return faltas.length ? `Faltan: ${faltas.join(', ')}.` : null; + } + }, + { + id: 'NOTIF-01', + categoria: 'Notificación', + nombre: 'appNotificada presente en receta finalizada/vencida', + severidad: 'info', + descripcion: 'Una receta finalizada o vencida no debería seguir notificada a un sistema para dispensar.', + sugerenciaReparacion: 'Limpiar appNotificada de las recetas no vigentes.', + evaluar: (receta) => { + const estado = receta.estadoActual && receta.estadoActual.tipo; + const apps = receta.appNotificada || []; + if ((estado === 'finalizada' || estado === 'vencida') && apps.length) { + return `Receta ${estado} con appNotificada (${apps.map(a => a.app).join(',')}).`; + } + return null; + } + }, + { + id: 'NOTIF-02', + categoria: 'Notificación', + nombre: 'estadoActual.organizacionExterna vacío', + severidad: 'info', + descripcion: 'Campo que solo existe en el schema y no se setea en el código de ANDES; un objeto vacío sugiere un alta/patch externo con estructura incompleta.', + sugerenciaReparacion: 'Quitar el objeto vacío o completarlo con id/nombre de la organización externa.', + evaluar: (receta) => { + const oe = receta.estadoActual && receta.estadoActual.organizacionExterna; + if (oe && typeof oe === 'object' && Object.keys(oe).length === 0) { + return 'estadoActual.organizacionExterna presente pero vacío ({}).'; + } + return null; + } + }, + { + id: 'NOTIF-03', + categoria: 'Notificación', + nombre: 'Vigente sin dispensar sin appNotificada', + severidad: 'warning', + descripcion: 'La app (recetAR) solo ve las recetas que consultó (se registran en appNotificada). Una vigente sin appNotificada no aparecerá para dispensar hasta que algún sistema la consulte.', + sugerenciaReparacion: 'Consultar la receta desde recetAR o verificar el origen de la receta.', + evaluar: (receta) => { + const estado = receta.estadoActual && receta.estadoActual.tipo; + const estadoDispensa = receta.estadoDispensaActual && receta.estadoDispensaActual.tipo; + if (estado === 'vigente' && estadoDispensa === 'sin-dispensa' && (!receta.appNotificada || receta.appNotificada.length === 0)) { + return 'Receta vigente sin dispensar sin appNotificada (ningún sistema la ha consultado/notificado).'; + } + return null; + } + } +]; diff --git a/scripts/renovar-idRecetas.ts b/scripts/renovar-idRecetas.ts new file mode 100644 index 0000000000..3bb0e5f081 --- /dev/null +++ b/scripts/renovar-idRecetas.ts @@ -0,0 +1,196 @@ +/** + * Script de renovación de idRecetas. + * + * Migra recetas vigentes/pendientes con formato viejo (20 caracteres) o sin idReceta + * al nuevo formato (13 caracteres: YYMMSSSSSSSSSP). + * + * Ejecución (desde la raíz del repo api/): + * - Con el launcher del repo: node scripts/index.js renovar-idRecetas [--dry-run] [--estados=vigente,pendiente] + * - Standalone: node scripts/renovar-idRecetas.js [--dry-run] [--estados=vigente,pendiente] + * + * Ejemplo: + * node scripts/index.js renovar-idRecetas --dry-run + * node scripts/index.js renovar-idRecetas --estados=vigente + * node scripts/index.js renovar-idRecetas + */ + +import * as fs from 'fs'; +import { Auth } from '../auth/auth.class'; +import { userScheduler } from '../config.private'; +import { Receta } from '../modules/recetas/receta-schema'; +import { generarIdSecuencial } from '../modules/recetas/recetasController'; + +interface Opciones { + dryRun: boolean; + estados: string[]; +} + +interface RegistroMigracion { + recetaId: string; + idRecetaAnterior: string | null; + idRecetaNuevo: string; + fecha: string; +} + +interface Stats { + total: number; + migradas: number; + sinIdReceta: number; + formatoViejo: number; + errores: number; +} + +function parsearArgumentos(args: string[]): Opciones { + const opciones: Opciones = { dryRun: false, estados: ['vigente', 'pendiente'] }; + for (const arg of args) { + if (arg === '--dry-run') { + opciones.dryRun = true; + } else if (arg.startsWith('--estados=')) { + opciones.estados = arg.split('=')[1].split(',').map(e => e.trim()); + } + } + return opciones; +} + +function imprimirEncabezado(texto: string): void { + // eslint-disable-next-line no-console + console.log(''); + // eslint-disable-next-line no-console + console.log('=================================================================='); + // eslint-disable-next-line no-console + console.log(` ${texto}`); + // eslint-disable-next-line no-console + console.log('=================================================================='); +} + +async function renovarIdRecetas(opciones: Opciones): Promise { + const stats: Stats = { total: 0, migradas: 0, sinIdReceta: 0, formatoViejo: 0, errores: 0 }; + const registros: RegistroMigracion[] = []; + + // Buscar recetas vigentes/pendientes sin idReceta o con formato viejo + const query = { + 'estadoActual.tipo': { $in: opciones.estados }, + $or: [ + { idReceta: { $exists: false } }, + { idReceta: null }, + { idReceta: { $regex: /^\d{20}$/ } } // Formato viejo: 20 caracteres + ] + }; + + // Usar cursor con select para reducir uso de memoria + // select: solo trae los campos necesarios + // cursor: procesa de a 100 documentos por vez en lugar de cargar todos + // NOTA: No se usa lean() porque necesitamos .save() para persistir cambios + const cursor = Receta.find(query) + .select('idReceta estadoActual fechaRegistro createdAt') + .cursor({ batchSize: 100 }); + + imprimirEncabezado('RENOVACIÓN DE IDRECETAS'); + // eslint-disable-next-line no-console + console.log(`Modo: ${opciones.dryRun ? 'DRY-RUN (sin cambios)' : 'PRODUCCIÓN'}`); + // eslint-disable-next-line no-console + console.log(`Estados: ${opciones.estados.join(', ')}`); + // eslint-disable-next-line no-console + console.log('Procesando recetas...\n'); + + for await (const receta of cursor) { + stats.total++; + try { + const idAnterior = receta.idReceta || null; + + if (!idAnterior) { + stats.sinIdReceta++; + } else { + stats.formatoViejo++; + } + + // Generar nuevo idReceta + const nuevoId = await generarIdSecuencial(receta.createdAt || new Date(), 0); + + const registro: RegistroMigracion = { + recetaId: String(receta._id), + idRecetaAnterior: idAnterior, + idRecetaNuevo: nuevoId, + fecha: new Date().toISOString() + }; + registros.push(registro); + + if (!opciones.dryRun) { + // Guardar id anterior en idRecetaV1 solo si existía + if (idAnterior) { + receta.set('idRecetaV1', idAnterior); + } + receta.idReceta = nuevoId; + Auth.audit(receta, userScheduler as any); + await receta.save(); + } + + stats.migradas++; + if (stats.migradas % 100 === 0 || stats.total <= 10) { + // eslint-disable-next-line no-console + console.log(`[${opciones.dryRun ? 'DRY-RUN' : 'OK'}] Procesadas: ${stats.migradas} recetas`); + } + } catch (error) { + stats.errores++; + // eslint-disable-next-line no-console + console.error(`[ERROR] ${receta._id}: ${(error as Error).message}`); + } + } + + // Guardar archivo de respaldo + const archivoSalida = `scripts/renovar-idRecetas-${Date.now()}.json`; + fs.writeFileSync(archivoSalida, JSON.stringify({ opciones, stats, registros }, null, 2)); + // eslint-disable-next-line no-console + console.log(`\nArchivo de respaldo: ${archivoSalida}`); + + // Estadísticas finales + // eslint-disable-next-line no-console + console.log('\n=== Estadísticas ==='); + // eslint-disable-next-line no-console + console.log(`Total: ${stats.total}`); + // eslint-disable-next-line no-console + console.log(`Migradas: ${stats.migradas}`); + // eslint-disable-next-line no-console + console.log(`Sin idReceta previo: ${stats.sinIdReceta}`); + // eslint-disable-next-line no-console + console.log(`Con formato viejo: ${stats.formatoViejo}`); + // eslint-disable-next-line no-console + console.log(`Errores: ${stats.errores}`); + + return stats; +} + +async function run(done?: () => void): Promise { + let args = process.argv.slice(2); + const archivoEjecutado = process.argv[1] || ''; + // Cuando se corre vía scripts/index.js o jobs/manual.js, el primer + // argumento es el nombre del script y hay que descartarlo. + const viaLauncher = archivoEjecutado.endsWith('index.js') || archivoEjecutado.endsWith('manual.js'); + if (viaLauncher && args.length) { + args = args.slice(1); + } + + const opciones = parsearArgumentos(args); + + try { + await renovarIdRecetas(opciones); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error durante la renovación:', error); + } + + if (done) { + done(); + } +} + +export = run; + +// Permite ejecución directa: node scripts/renovar-idRecetas.js +if (require.main === module) { + run().catch(err => { + // eslint-disable-next-line no-console + console.error(err); + process.exit(1); + }); +}