cambios en el server

This commit is contained in:
Jhonathan Guevara 2026-01-15 20:31:20 -05:00
parent 0dbbd11bcd
commit 6d43a61d76
Signed by: jhonathan_guevara
GPG Key ID: 619239F12DCBE55B
10 changed files with 553 additions and 12 deletions

BIN
PLANTILLAAUT.xlsx Normal file

Binary file not shown.

View File

@ -1358,6 +1358,63 @@ app.post(
} }
); );
app.post(
'/api/cargar-autorizados-masivos',
verificarToken,
esAdministrador,
upload.single('archivo'),
async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No se recibio archivo Excel' });
}
const jobId = createJobId();
const job = {
id: jobId,
type: 'autorizados-masivos',
status: 'queued',
createdAt: new Date().toISOString(),
ownerId: req.usuario.id_usuario,
};
jobs.set(jobId, job);
const jobTmpDir = path.join(os.tmpdir(), 'saludut_jobs', jobId);
await fsPromises.mkdir(jobTmpDir, { recursive: true });
const inputPath = path.join(jobTmpDir, `autorizados_${jobId}.xlsx`);
await fsPromises.writeFile(inputPath, req.file.buffer);
enqueueJob(job, async () => {
try {
return await procesarExcelAutorizacionesMasivas(inputPath, req.usuario, {
estadoAutorizacion: 'autorizado',
allowMissingAmbito: true,
defaultAmbito: 'extramural',
allowNumeroOrdenForAnyAmbito: true,
fechaAutorizacionKeys: [
'FECHADEREGISTRO',
'FECHAREGISTRO',
'FECHADEAUTORIZACION',
'FECHA',
],
});
} finally {
await safeUnlink(inputPath);
await safeRemoveDir(jobTmpDir);
}
});
return res.status(202).json(sanitizeJob(job));
} catch (error) {
console.error('Error creando job de autorizados masivos:', error);
return res.status(500).json({
error: 'Error creando el job para procesar autorizados masivos',
});
}
}
);
app.post( app.post(
'/api/autorizaciones/autorrellenar', '/api/autorizaciones/autorrellenar',
verificarToken, verificarToken,
@ -2444,7 +2501,7 @@ async function procesarExcelReps(inputFilePath) {
}; };
} }
async function procesarExcelAutorizacionesMasivas(inputFilePath, usuario) { async function procesarExcelAutorizacionesMasivas(inputFilePath, usuario, options = {}) {
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(inputFilePath); await workbook.xlsx.readFile(inputFilePath);
const sheet = workbook.worksheets[0]; const sheet = workbook.worksheets[0];
@ -2453,6 +2510,14 @@ async function procesarExcelAutorizacionesMasivas(inputFilePath, usuario) {
throw new Error('No se encontro una hoja en el Excel de autorizaciones'); throw new Error('No se encontro una hoja en el Excel de autorizaciones');
} }
const {
estadoAutorizacion = 'pendiente',
allowMissingAmbito = false,
defaultAmbito = '',
allowNumeroOrdenForAnyAmbito = false,
fechaAutorizacionKeys = [],
} = options;
const headerRow = sheet.getRow(1); const headerRow = sheet.getRow(1);
const headers = {}; const headers = {};
headerRow.eachCell((cell, colNumber) => { headerRow.eachCell((cell, colNumber) => {
@ -2544,6 +2609,7 @@ async function procesarExcelAutorizacionesMasivas(inputFilePath, usuario) {
'NUMERODEDOCUMENTOPPL', 'NUMERODEDOCUMENTOPPL',
'NUMERODOCUMENTOPPL', 'NUMERODOCUMENTOPPL',
'DOCUMENTOPPL', 'DOCUMENTOPPL',
'NUMERODEDOCUMENTO',
'NUMERODOCUMENTO', 'NUMERODOCUMENTO',
]); ]);
const cupsRaw = getValueMulti(row, [ const cupsRaw = getValueMulti(row, [
@ -2653,15 +2719,19 @@ async function procesarExcelAutorizacionesMasivas(inputFilePath, usuario) {
ambitoAtencion = 'extramural'; ambitoAtencion = 'extramural';
} }
if (!ambitoAtencion) { if (!ambitoAtencion) {
resumen.omitidas += 1; if (allowMissingAmbito) {
resumen.sin_ambito += 1; ambitoAtencion = defaultAmbito ? defaultAmbito : null;
if (resumen.errores.length < 50) { } else {
resumen.errores.push({ resumen.omitidas += 1;
fila: i, resumen.sin_ambito += 1;
error: 'Ambito intramural/extramural invalido', if (resumen.errores.length < 50) {
}); resumen.errores.push({
fila: i,
error: 'Ambito intramural/extramural invalido',
});
}
continue;
} }
continue;
} }
const numeroOrdenRaw = getValueMulti(row, [ const numeroOrdenRaw = getValueMulti(row, [
@ -2670,10 +2740,15 @@ async function procesarExcelAutorizacionesMasivas(inputFilePath, usuario) {
'NUMEROORDEN', 'NUMEROORDEN',
'NROORDEN', 'NROORDEN',
'NUMERODELAORDEN', 'NUMERODELAORDEN',
'CODIGPODEREGISTRO',
'CODIGODEREGISTRO',
'CODIGOREGISTRO',
]); ]);
const numeroOrden = String(numeroOrdenRaw || '').trim(); const numeroOrden = String(numeroOrdenRaw || '').trim();
const numeroOrdenFinal = const numeroOrdenFinal =
ambitoAtencion === 'intramural' && numeroOrden ? numeroOrden : null; (ambitoAtencion === 'intramural' || allowNumeroOrdenForAnyAmbito) && numeroOrden
? numeroOrden
: null;
const estadoEntregaRaw = getValueMulti(row, [ const estadoEntregaRaw = getValueMulti(row, [
'ESTADODEENTREGA', 'ESTADODEENTREGA',
@ -2684,6 +2759,13 @@ async function procesarExcelAutorizacionesMasivas(inputFilePath, usuario) {
const estadoEntrega = const estadoEntrega =
parseEstadoEntrega(estadoEntregaRaw) || 'pendiente_entrega'; parseEstadoEntrega(estadoEntregaRaw) || 'pendiente_entrega';
const fechaAutorizacionRaw = fechaAutorizacionKeys.length
? getValueMulti(row, fechaAutorizacionKeys)
: '';
const fechaAutorizacion = fechaAutorizacionRaw
? String(fechaAutorizacionRaw).trim()
: null;
const cupCodigo = extractCupCodigo(cupsRaw || procedimiento); const cupCodigo = extractCupCodigo(cupsRaw || procedimiento);
if (!cupCodigo) { if (!cupCodigo) {
resumen.omitidas += 1; resumen.omitidas += 1;
@ -2720,6 +2802,8 @@ async function procesarExcelAutorizacionesMasivas(inputFilePath, usuario) {
'NOMBREIPSREMITIDO', 'NOMBREIPSREMITIDO',
'NOMBREDEIPSREMITIDO', 'NOMBREDEIPSREMITIDO',
'IPSREMITIDO', 'IPSREMITIDO',
'IPSSUJERIDA',
'IPSSUGERIDA',
]); ]);
const departamentoExcel = const departamentoExcel =
getValue(row, 'DEPARTAMENTO') || getValue(row, 'DEPARTAMENTO') ||
@ -2962,7 +3046,7 @@ async function procesarExcelAutorizacionesMasivas(inputFilePath, usuario) {
` `
INSERT INTO autorizacion INSERT INTO autorizacion
(interno, id_ips, numero_documento_autorizante, id_usuario_solicitante, fecha_autorizacion, observacion, cup_codigo, cie10_codigo, cie10_descripcion, tipo_autorizacion, tipo_servicio, ambito_atencion, numero_orden, estado_entrega, estado_autorizacion) (interno, id_ips, numero_documento_autorizante, id_usuario_solicitante, fecha_autorizacion, observacion, cup_codigo, cie10_codigo, cie10_descripcion, tipo_autorizacion, tipo_servicio, ambito_atencion, numero_orden, estado_entrega, estado_autorizacion)
VALUES ($1, $2, $3, $4, COALESCE($5::timestamp AT TIME ZONE '${BOGOTA_TIMEZONE}', now()), $6, $7, $8, $9, $10, $11, $12, $13, $14, 'pendiente') VALUES ($1, $2, $3, $4, COALESCE($5::timestamp AT TIME ZONE '${BOGOTA_TIMEZONE}', now()), $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING numero_autorizacion, fecha_autorizacion, version; RETURNING numero_autorizacion, fecha_autorizacion, version;
`, `,
[ [
@ -2970,7 +3054,7 @@ async function procesarExcelAutorizacionesMasivas(inputFilePath, usuario) {
ipsInfo.id_ips, ipsInfo.id_ips,
autorizanteDefault, autorizanteDefault,
solicitanteId, solicitanteId,
null, fechaAutorizacion,
observacionFinal, observacionFinal,
cupCodigo, cupCodigo,
cie10Codigo, cie10Codigo,
@ -2980,6 +3064,7 @@ async function procesarExcelAutorizacionesMasivas(inputFilePath, usuario) {
ambitoAtencion, ambitoAtencion,
numeroOrdenFinal, numeroOrdenFinal,
estadoEntrega, estadoEntrega,
estadoAutorizacion,
] ]
); );

Binary file not shown.

View File

@ -9,6 +9,7 @@ import { UsuariosComponent } from './components/usuarios/usuarios';
import { CargarCupsComponent } from './components/cargar-cups/cargar-cups'; import { CargarCupsComponent } from './components/cargar-cups/cargar-cups';
import { EstadisticasAutorizacionesComponent } from './components/estadisticas-autorizaciones/estadisticas-autorizaciones'; import { EstadisticasAutorizacionesComponent } from './components/estadisticas-autorizaciones/estadisticas-autorizaciones';
import { CargarAutorizacionesMasivasComponent } from './components/cargar-autorizaciones-masivas/cargar-autorizaciones-masivas'; import { CargarAutorizacionesMasivasComponent } from './components/cargar-autorizaciones-masivas/cargar-autorizaciones-masivas';
import { CargarAutorizadosMasivosComponent } from './components/cargar-autorizados-masivos/cargar-autorizados-masivos';
import { CargarIpsRepsComponent } from './components/cargar-ips-reps/cargar-ips-reps'; import { CargarIpsRepsComponent } from './components/cargar-ips-reps/cargar-ips-reps';
import { CargarPacientesComponent } from './components/cargar-pacientes/cargar-pacientes'; import { CargarPacientesComponent } from './components/cargar-pacientes/cargar-pacientes';
@ -67,6 +68,12 @@ export const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
}, },
{
path: 'cargar-autorizados-masivos',
component: CargarAutorizadosMasivosComponent,
canActivate: [AuthGuard, AdminGuard],
},
{ {
path: 'cargar-ips-reps', path: 'cargar-ips-reps',
component: CargarIpsRepsComponent, component: CargarIpsRepsComponent,

View File

@ -0,0 +1,117 @@
/* ============================
Carga masiva autorizados
============================ */
.masivas-container {
display: flex;
flex-direction: column;
gap: 20px;
}
.masivas-card {
padding: 20px;
}
.masivas-card h2 {
margin: 0 0 6px;
font-size: 1.5rem;
}
.subtitle {
margin: 0 0 16px;
color: var(--color-text-muted);
font-size: 0.95rem;
}
.form-row {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
}
.form-row label {
font-weight: 600;
font-size: 0.9rem;
}
.form-row input[type="file"] {
font-size: 0.9rem;
}
.file-name {
font-size: 0.85rem;
color: var(--color-text-main);
}
.form-row.acciones {
margin-top: 8px;
}
.template-row {
align-items: flex-start;
}
.template-note {
font-size: 0.85rem;
color: var(--color-text-muted);
}
.resumen-card h2 {
margin: 0 0 12px;
font-size: 1.4rem;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
}
.summary-item {
background: var(--color-cup-bg);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.summary-label {
font-size: 0.8rem;
color: var(--color-text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.summary-value {
font-size: 1.2rem;
font-weight: 700;
color: var(--color-text-main);
}
.summary-note {
margin-top: 12px;
padding: 10px 12px;
border-radius: 8px;
background: var(--color-primary-soft);
border: 1px solid var(--color-border);
font-size: 0.9rem;
}
.error-list {
margin-top: 16px;
}
.error-list h3 {
margin: 0 0 8px;
font-size: 1rem;
}
.error-table {
border: 1px solid var(--color-border);
border-radius: 8px;
max-height: 260px;
overflow: auto;
}

View File

@ -0,0 +1,132 @@
<div class="page-shell">
<div class="content-container masivas-container">
<app-header
title="Carga masiva de autorizados"
subtitle="Sube la plantilla Excel y registra autorizaciones autorizadas"
badgeText="SALUD UT"
[showUserInfo]="isLoggedIn()"
[userName]="getCurrentUser()?.nombre_completo"
[userRole]="getCurrentUser()?.nombre_rol"
[showLogout]="isLoggedIn()"
(logout)="logout()"
[showBack]="true"
backLabel="Volver"
(back)="volverDashboard()"
></app-header>
<div class="card masivas-card">
<h2>Subir plantilla</h2>
<p class="subtitle">
Usa la plantilla oficial de autorizados. La fecha de registro se guarda como fecha de autorizacion.
</p>
<div class="form-row template-row">
<a class="btn btn-secondary" href="assets/PLANTILLAAUT.xlsx" download>
Descargar plantilla de autorizados
</a>
<span class="template-note">Plantilla oficial para carga masiva.</span>
</div>
<div class="form-row">
<label>Archivo Excel:</label>
<input
#archivoInput
type="file"
accept=".xlsx,.xls"
(change)="onArchivoSelected($event)"
/>
<span class="file-name" *ngIf="archivoFile">{{ archivoFile.name }}</span>
</div>
<div class="form-row acciones">
<button
class="btn btn-primary"
(click)="procesarMasivo(archivoInput)"
[disabled]="isLoading || !archivoFile"
>
{{ isLoading ? 'Procesando...' : 'Procesar autorizados' }}
</button>
</div>
<div class="status ok" *ngIf="statusMessage">{{ statusMessage }}</div>
<div class="status error" *ngIf="errorMessage">{{ errorMessage }}</div>
</div>
<div class="card resumen-card" *ngIf="resumen">
<h2>Resumen de la carga</h2>
<div class="summary-grid">
<div class="summary-item">
<span class="summary-label">Total filas</span>
<span class="summary-value">{{ resumen.total || 0 }}</span>
</div>
<div class="summary-item">
<span class="summary-label">Creadas</span>
<span class="summary-value">{{ resumen.creadas || 0 }}</span>
</div>
<div class="summary-item">
<span class="summary-label">Omitidas</span>
<span class="summary-value">{{ resumen.omitidas || resumen.omitidos || 0 }}</span>
</div>
<div class="summary-item">
<span class="summary-label">Duplicadas</span>
<span class="summary-value">{{ resumen.duplicados || 0 }}</span>
</div>
<div class="summary-item">
<span class="summary-label">Sin paciente</span>
<span class="summary-value">{{ resumen.sin_paciente || 0 }}</span>
</div>
<div class="summary-item">
<span class="summary-label">Sin CUPS</span>
<span class="summary-value">{{ resumen.sin_cups || 0 }}</span>
</div>
<div class="summary-item">
<span class="summary-label">Sin IPS</span>
<span class="summary-value">{{ resumen.sin_ips || 0 }}</span>
</div>
<div class="summary-item">
<span class="summary-label">Sin diagnostico</span>
<span class="summary-value">{{ resumen.sin_diagnostico || 0 }}</span>
</div>
<div class="summary-item">
<span class="summary-label">Sin ambito</span>
<span class="summary-value">{{ resumen.sin_ambito || 0 }}</span>
</div>
<div class="summary-item">
<span class="summary-label">CUPS no cubiertos</span>
<span class="summary-value">{{ resumen.cups_no_cubiertos || 0 }}</span>
</div>
<div class="summary-item">
<span class="summary-label">IPS sin convenio</span>
<span class="summary-value">{{ resumen.ips_sin_convenio || 0 }}</span>
</div>
</div>
<div
class="summary-note"
*ngIf="(resumen.cups_no_cubiertos || 0) > 0 || (resumen.ips_sin_convenio || 0) > 0"
>
Se detectaron CUPS no cubiertos o IPS sin convenio. Revisa estas autorizaciones.
</div>
<div class="error-list" *ngIf="errores.length">
<h3>Errores (max 50)</h3>
<div class="error-table">
<table class="table">
<thead>
<tr>
<th>Fila</th>
<th>Error</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let e of errores">
<td>{{ e.fila || '-' }}</td>
<td>{{ e.error || 'Error en fila' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,124 @@
import { ChangeDetectorRef, Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { AppHeaderComponent } from '../shared/app-header/app-header';
import { AuthService } from '../../services/auth';
import { PacienteService } from '../../services/paciente';
import { JobsService } from '../../services/jobs';
import { JobResult, JobRowError } from '../../services/job-types';
@Component({
selector: 'app-cargar-autorizados-masivos',
standalone: true,
imports: [CommonModule, AppHeaderComponent],
templateUrl: './cargar-autorizados-masivos.html',
styleUrls: ['./cargar-autorizados-masivos.css']
})
export class CargarAutorizadosMasivosComponent {
archivoFile: File | null = null;
isLoading = false;
statusMessage: string | null = null;
errorMessage: string | null = null;
resumen: JobResult | null = null;
errores: JobRowError[] = [];
constructor(
private authService: AuthService,
private pacienteService: PacienteService,
private jobsService: JobsService,
private router: Router,
private cdr: ChangeDetectorRef
) {}
volverDashboard(): void {
this.router.navigate(['/dashboard']);
}
logout(): void {
this.authService.logout();
}
isLoggedIn(): boolean {
return this.authService.isLoggedIn();
}
getCurrentUser(): any {
return this.authService.getCurrentUser();
}
onArchivoSelected(event: Event): void {
const input = event.target as HTMLInputElement;
this.archivoFile = input.files?.[0] || null;
this.limpiarMensajes();
this.resumen = null;
this.errores = [];
}
procesarMasivo(input?: HTMLInputElement): void {
this.limpiarMensajes();
this.resumen = null;
this.errores = [];
if (!this.archivoFile) {
this.errorMessage = 'Debes seleccionar el archivo Excel de autorizados.';
return;
}
this.isLoading = true;
this.statusMessage = 'Subiendo archivo...';
const formData = new FormData();
formData.append('archivo', this.archivoFile);
this.pacienteService.cargarAutorizadosMasivos(formData).subscribe({
next: (job) => {
this.statusMessage = 'Archivo en cola. Procesando...';
if (input) {
input.value = '';
}
this.jobsService.pollJob(job.id).subscribe({
next: (estado) => {
if (estado.status === 'completed') {
this.resumen = estado.result || null;
this.errores = Array.isArray(estado.result?.errores)
? estado.result?.errores || []
: [];
this.statusMessage =
estado.result?.mensaje || 'Carga masiva finalizada.';
this.isLoading = false;
}
if (estado.status === 'failed') {
this.errorMessage =
estado.error?.message ||
'Error procesando autorizados masivos.';
this.isLoading = false;
}
this.cdr.detectChanges();
},
error: (error) => {
console.error(error);
this.errorMessage =
'Error consultando el estado de la carga masiva.';
this.isLoading = false;
this.cdr.detectChanges();
}
});
},
error: (err) => {
console.error(err);
this.errorMessage =
err?.error?.error || 'Error subiendo el Excel de autorizados.';
this.isLoading = false;
this.cdr.detectChanges();
}
});
}
private limpiarMensajes(): void {
this.statusMessage = null;
this.errorMessage = null;
}
}

View File

@ -132,6 +132,41 @@
<p>Subir plantilla Excel para crear autorizaciones pendientes</p> <p>Subir plantilla Excel para crear autorizaciones pendientes</p>
</div> </div>
<!-- Carga masiva de autorizados (solo admin) -->
<div
class="action-card"
*ngIf="puedeCargarAutorizadosMasivos()"
(click)="irACargarAutorizadosMasivos()"
>
<span class="action-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" role="img" aria-label="">
<path
d="M4 5h16v10H4z"
fill="none"
stroke="currentColor"
stroke-width="2"
></path>
<path
d="M8 19h8"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
></path>
<path
d="M9 9h6M9 12h6"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
></path>
</svg>
</span>
<h4>Carga masiva de autorizados</h4>
<p>Subir plantilla Excel de autorizados</p>
<div class="admin-badge">Solo admin</div>
</div>
<!-- Cargar IPS y REPS (solo administradores) --> <!-- Cargar IPS y REPS (solo administradores) -->
<div <div
class="action-card" class="action-card"
@ -279,6 +314,26 @@
</div> </div>
</div> </div>
<div
class="permission-item"
[class.has-permission]="puedeCargarAutorizadosMasivos()"
[class.no-permission]="!puedeCargarAutorizadosMasivos()"
>
<span
class="permission-icon"
*ngIf="puedeCargarAutorizadosMasivos(); else noPermAutorizados"
>
&check;
</span>
<ng-template #noPermAutorizados>
<span class="permission-icon">&times;</span>
</ng-template>
<div class="permission-body">
<span class="permission-title">Cargar autorizados masivos</span>
<span class="permission-meta">Subir plantilla de autorizados</span>
</div>
</div>
<div <div
class="permission-item" class="permission-item"
[class.has-permission]="puedeVerAutorizacionesPorFecha()" [class.has-permission]="puedeVerAutorizacionesPorFecha()"

View File

@ -104,6 +104,10 @@ export class DashboardComponent implements OnInit, OnDestroy {
this.router.navigate(['/cargar-autorizaciones-masivas']); this.router.navigate(['/cargar-autorizaciones-masivas']);
} }
irACargarAutorizadosMasivos(): void {
this.router.navigate(['/cargar-autorizados-masivos']);
}
irACargarIpsReps(): void { irACargarIpsReps(): void {
this.router.navigate(['/cargar-ips-reps']); this.router.navigate(['/cargar-ips-reps']);
} }
@ -179,6 +183,10 @@ export class DashboardComponent implements OnInit, OnDestroy {
return this.authService.puedeGenerarAutorizaciones(); return this.authService.puedeGenerarAutorizaciones();
} }
puedeCargarAutorizadosMasivos(): boolean {
return this.authService.isAdministrador();
}
puedeVerAutorizacionesPorFecha(): boolean { puedeVerAutorizacionesPorFecha(): boolean {
return this.authService.isLoggedIn(); return this.authService.isLoggedIn();
} }

View File

@ -386,6 +386,19 @@ export class PacienteService {
); );
} }
cargarAutorizadosMasivos(formData: FormData): Observable<JobResponse> {
const token = this.authService.getToken();
const headers = new HttpHeaders({
'Authorization': `Bearer ${token}`
});
return this.http.post<JobResponse>(
`${this.API_URL}/cargar-autorizados-masivos`,
formData,
{ headers }
);
}
crearAutorizante(payload: CrearAutorizantePayload): Observable<any> { crearAutorizante(payload: CrearAutorizantePayload): Observable<any> {
return this.http.post( return this.http.post(
`${this.API_URL}/autorizantes`, `${this.API_URL}/autorizantes`,