// src/excel/excel.service.ts import { Injectable, BadRequestException, Logger } from '@nestjs/common'; import { Workbook } from 'exceljs'; import { DataSource, In, Repository } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; import { CarreraUsuario, TipoUsuario, Usuario, UsuariosDelSistema, UsuarioTipoUsuario, } from '../entities/entities'; import { Genero } from '../entities/entities'; import { Carrera } from '../entities/entities'; import { MovimientoService } from 'src/movimiento/movimiento.service'; import { ServActivos } from 'src/usuarios/entities/servActivos.entitie'; import { MailService } from 'src/mail/mail.service'; export interface UsuarioRow { cuenta: string; nombreCompleto: string; clave: string; nomCarr: string; gen: string; // generación fechnac: string; // YYYYMMDD apellidopa: string; apellidoma: string; nombres: string; sexo: string; // m/f o texto tipo: string; correo: string; rfc: string; } @Injectable() export class ExcelService { private readonly logger = new Logger(ExcelService.name); constructor( @InjectRepository(Usuario) private readonly usuarioRepo: Repository, @InjectRepository(Genero) private readonly generoRepo: Repository, @InjectRepository(Carrera) private readonly carreraRepo: Repository, @InjectRepository(CarreraUsuario) private readonly carreraUsuarioRepo: Repository, @InjectRepository(UsuarioTipoUsuario) private readonly usuarioTipoUsuarioRepo: Repository, @InjectRepository(TipoUsuario) private readonly tipoUsuarioRepo: Repository, @InjectRepository(ServActivos) private readonly servActivosRepo: Repository, @InjectRepository(UsuariosDelSistema) private readonly usuariosDelSistemaRepo: Repository, private readonly movimientoService: MovimientoService, private readonly mailService: MailService, private readonly dataSource: DataSource, // … inyecta otros repositorios si los usarás ) { } /** Lee el buffer del Excel y devuelve un arreglo de filas tipadas */ private async parseFile(buffer: Buffer): Promise { const wb = new Workbook(); await wb.xlsx.load(buffer); const sheet = wb.worksheets[0]; const rows: UsuarioRow[] = []; // Asume que la primera fila es encabezados sheet.eachRow((row, idx) => { if (idx === 1) return; // salto encabezados // 1) Asegurarnos de que row.values no sea null/undefined if (!row.values) { this.logger.warn(`Fila ${idx + 1}: row.values vacío, se omite.`); return; } // 2) row.values[0] es null, así que slice(1) sí existe const [ cuenta, nombreCompleto, clave, nomCarr, gen, fechnac, apellidopa, apellidoma, nombres, sexo, tipo, correo, rfc, ] = (row.values as any[]).slice(1); rows.push({ cuenta, nombreCompleto, clave, nomCarr, gen, fechnac, apellidopa, apellidoma, nombres, sexo, tipo, correo, rfc, }); }); return rows; } /** Valida duplicados, celdas vacías y patrones básicos */ validateRows(rows: UsuarioRow[]) { const errors: string[] = []; const seen = new Set(); const rowsGood: UsuarioRow[] = []; let flagError: boolean = false; rows.forEach((r, i) => { const rowNum = i + 2; // por el encabezado // Campos que no pueden quedar vacíos ['cuenta', 'tipo'].forEach((field) => { const raw = (r as any)[field]; const str = raw != null ? raw.toString().trim() : ''; if (!str) { errors.push(`Fila ${rowNum}: campo "${field}" está vacío.`); flagError = true; } }); // El número de cuenta debe ser de nueve dígitos para los alumnos if ( r.cuenta && !/^\d{9}$/.test(r.cuenta.toString().trim()) && r.tipo.trim() === 'Licenciatura' ) { errors.push( `Fila ${rowNum}: cuenta "${r.cuenta}" debe tener 9 dígitos.`, ); flagError = true; } // Duplicados de "cuenta" const cuentaStr = r.cuenta != null ? r.cuenta.toString().trim() : ''; if (seen.has(cuentaStr)) { errors.push(`Fila ${rowNum}: cuenta duplicada "${cuentaStr}".`); flagError = true; } else { seen.add(cuentaStr); } const genStr = r.gen != null ? r.gen.toString().trim() : ''; if (genStr && !/^[0-9]{4}$/.test(genStr)) { errors.push(`Fila ${rowNum}: generación inválida "${genStr}".`); flagError = true; } // Fecha en formato YYYYMMDD const fechaStr = r.fechnac != null ? r.fechnac.toString().trim() : ''; if (!/^[0-9]{8}$/.test(fechaStr)) { errors.push( `Fila ${rowNum}: fecha de nacimiento inválida "${fechaStr}".`, ); flagError = true; } // RFC alfanumérico const rfcStr = r.rfc != null ? r.rfc.toString().trim() : ''; if (rfcStr && !/^[A-Z0-9]+$/.test(rfcStr)) { errors.push(`Fila ${rowNum}: RFC contiene caracteres no válidos.`); flagError = true; } if (!flagError) { rowsGood.push(r); } else { flagError = false; // reset para la siguiente fila } }); return { errors, rowsGood }; } /** * Solo valida: retorna lista de errores (vacío = ok) */ async validateFile(buffer: Buffer) { const rows = await this.parseFile(buffer); const status = this.validateRows(rows); return status; } /** * Valida y luego persiste usuarios y relaciones básicas */ async loadFile(buffer: Buffer, user: string) { const file = await this.parseFile(buffer); const status = this.validateRows(file); const errs = status.errors; const rows = status.rowsGood; if (rows.length === 0) { throw new BadRequestException( 'No se encontraron filas válidas para cargar. ' + errs, ); } let conteoTiposRed: number = 0; let conteoTiposAt: number = 0; let conteoTiposSolicita: number = 0; let conteoTiposCorreo: number = 0; const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { const movimiento = await this.movimientoService.logger( queryRunner, 'LOAD', 'LOADING', 'Caraga masiva de usuarios hecha por: ' + user, `CARGA MASIVA DE USUARIOS`, ); // 🔹 1. Pre-cargar cuentas/rfc ya existentes const cuentas = rows.map((r) => r.cuenta).filter(Boolean); const rfcs = rows.map((r) => r.rfc).filter(Boolean); const existentes = await queryRunner.manager.find(Usuario, { where: [{ num_cuenta: In(cuentas) }, { rfc: In(rfcs) }], select: ['num_cuenta', 'rfc'], }); const setCuentas = new Set(existentes.map((u) => u.num_cuenta)); const setRfcs = new Set(existentes.map((u) => u.rfc)); // 🔹 2. Pre-cargar catálogos const generosUnicos = [ ...new Set(rows.map((r) => r.sexo).filter(Boolean)), ]; const carrerasUnicasClave = [ ...new Set(rows.map((r) => r.clave).filter(Boolean)), ]; const carrerasUnicasNombre = [ ...new Set(rows.map((r) => r.nomCarr).filter(Boolean)), ]; const tiposUnicos = [ ...new Set(rows.map((r) => r.tipo?.trim()).filter(Boolean)), ]; const [generosExist, carrerasExist, tiposExist] = await Promise.all([ queryRunner.manager.find(Genero, { where: { genero: In(generosUnicos) }, }), queryRunner.manager.find(Carrera, { where: [ { clave: In(carrerasUnicasClave) }, { carrera: In(carrerasUnicasNombre) }, ], }), queryRunner.manager.find(TipoUsuario, { where: { tipo_usuario: In(tiposUnicos) }, }), ]); const mapGenero = new Map(generosExist.map((g) => [g.genero, g])); const mapCarreraClave = new Map(carrerasExist.map((c) => [c.clave, c])); const mapCarreraNom = new Map(carrerasExist.map((c) => [c.carrera, c])); const mapTipo = new Map(tiposExist.map((t) => [t.tipo_usuario, t])); // 🔹 3. Arreglos para inserciones masivas const usuarios: Usuario[] = []; const carrerasUsuario: CarreraUsuario[] = []; const usuariosTipos: UsuarioTipoUsuario[] = []; const serviciosActivos: ServActivos[] = []; let contador = 0; for (const r of rows) { if (setCuentas.has(r.cuenta) || (r.rfc && setRfcs.has(r.rfc))) { errs.push(`Cuenta ${r.cuenta} ya existe, se omite.`); contador++; continue; } if (!r.tipo || !r.nombres || !r.apellidopa || !r.apellidoma) { errs.push(`Fila con cuenta ${r.cuenta} tiene campos faltantes.`); contador++; continue; } // Genero let genero = mapGenero.get(r.sexo); if (!genero) { genero = queryRunner.manager.create(Genero, { genero: r.sexo }); mapGenero.set(r.sexo, genero); } // Carrera let carrera: Carrera | undefined; if (r.clave && mapCarreraClave.has(r.clave)) { carrera = mapCarreraClave.get(r.clave); } else if (r.nomCarr && mapCarreraNom.has(r.nomCarr)) { carrera = mapCarreraNom.get(r.nomCarr); } else if (r.clave || r.nomCarr) { carrera = queryRunner.manager.create(Carrera, { clave: r.clave ?? '', carrera: r.nomCarr?.slice(0, 100) ?? '', }); mapCarreraClave.set(carrera.clave, carrera); mapCarreraNom.set(carrera.carrera, carrera); } // Tipo let tipo = mapTipo.get(r.tipo.trim()); if (!tipo) { tipo = queryRunner.manager.create(TipoUsuario, { tipo_usuario: r.tipo.trim(), }); mapTipo.set(r.tipo.trim(), tipo); } const generacion = /^[0-9]{4}$/.test(r.gen?.toString().trim()) ? parseInt(r.gen.toString().trim(), 10) : null; const usuario = queryRunner.manager.create(Usuario, { num_cuenta: r.cuenta, nombre: r.nombres, a_paterno: r.apellidopa, a_materno: r.apellidoma, rfc: r.rfc, fecha_nacimiento: r.fechnac, generacion, genero, movimiento, }); usuarios.push(usuario); if (carrera) { const carreraUsuario = queryRunner.manager.create(CarreraUsuario, { usuario, carrera, }); carrerasUsuario.push(carreraUsuario); } usuariosTipos.push( queryRunner.manager.create(UsuarioTipoUsuario, { usuario, tipoUsuario: tipo, }), ); // Servicios activos const servData: Partial = { usuario, RedStatus: 'Inactivo', ATStatus: 'Inactivo', CorreoStatus: 'Inactivo', PrestamosStatus: 'Inactivo', }; switch (r.tipo.trim()) { case 'Diplomado': Object.assign(servData, { Correo: true }); conteoTiposCorreo++; break; case 'Extra Largo': Object.assign(servData, { Correo: true, Prestamos: true }); conteoTiposCorreo++; conteoTiposSolicita++; break; case 'Servicio Social': Object.assign(servData, { Correo: true, AT: true, Red: true }); conteoTiposCorreo++; conteoTiposAt++; conteoTiposRed++; break; case 'Idiomas R (UNAM)': case 'Idiomas Sabatino': Object.assign(servData, { Correo: true, Red: true }); conteoTiposCorreo++; conteoTiposRed++; break; case 'Reinscrito': case 'Posgrado': case 'Intercambio UNAM': case 'Movilidad': case 'Ampliación de Conocimiento': case 'Licenciatura': case 'Profesor': Object.assign(servData, { Correo: true, AT: true, Red: true, Prestamos: true, }); conteoTiposCorreo++; conteoTiposRed++; conteoTiposAt++; conteoTiposSolicita++; break; case 'Trabajadores': Object.assign(servData, { Red: true }); conteoTiposRed++; break; } serviciosActivos.push( queryRunner.manager.create(ServActivos, servData), ); } // 🔹 4. Guardar en batch (orden correcto) await queryRunner.manager.save([...mapGenero.values()]); await queryRunner.manager.save([...mapCarreraClave.values()]); await queryRunner.manager.save([...mapTipo.values()]); // 1️⃣ Guardar usuarios primero — asigna IDs await queryRunner.manager.save(usuarios); // 2️⃣ Reconstruir relaciones con usuarios ya guardados // CarreraUsuario const carrerasUsuarioFinal: CarreraUsuario[] = []; for (const cu of carrerasUsuario) { const usuarioConId = usuarios.find( (u) => u.num_cuenta === cu.usuario.num_cuenta, ); if (usuarioConId && cu.carrera) { carrerasUsuarioFinal.push( queryRunner.manager.create(CarreraUsuario, { usuario: usuarioConId, carrera: cu.carrera, }), ); } } // UsuarioTipoUsuario const usuariosTiposFinal: UsuarioTipoUsuario[] = []; for (const ut of usuariosTipos) { const usuarioConId = usuarios.find( (u) => u.num_cuenta === ut.usuario.num_cuenta, ); if (usuarioConId && ut.tipoUsuario) { usuariosTiposFinal.push( queryRunner.manager.create(UsuarioTipoUsuario, { usuario: usuarioConId, tipoUsuario: ut.tipoUsuario, }), ); } } // ServActivos const serviciosActivosFinal: ServActivos[] = []; for (const sa of serviciosActivos) { const usuarioConId = usuarios.find( (u) => u.num_cuenta === sa.usuario.num_cuenta, ); if (usuarioConId) { serviciosActivosFinal.push( queryRunner.manager.create(ServActivos, { ...sa, usuario: usuarioConId, }), ); } } // 3️⃣ Guardar relaciones dependientes en orden await queryRunner.manager.save(carrerasUsuarioFinal); await queryRunner.manager.save(usuariosTiposFinal); await queryRunner.manager.save(serviciosActivosFinal); return { inserted: rows.length - contador, id_movimiento: movimiento.id_mov, errors: errs, conteoTiposAt, conteoTiposCorreo, conteoTiposRed, conteoTiposSolicita, }; } catch (err) { await queryRunner.rollbackTransaction(); throw err; } finally { await queryRunner.release(); } } // async generateCsv(): Promise { // const users = await this.usuarioRepo.find({ // relations: ['genero', 'carreraUsuarios'], // si quieres más info // select: ['num_cuenta', 'nombre', 'a_paterno', 'a_materno', 'rfc', 'fecha_nacimiento', 'generacion'], // }); // const header = [ // 'num_cuenta', 'nombre', 'a_paterno', 'a_materno', // 'carrera', 'rfc', 'fecha_nacimiento', 'generacion' // ].join('\t') + '\n'; // const rows = users.map(u => [ // u.num_cuenta, // u.nombre, // u.a_paterno, // u.a_materno, // u.rfc ?? '', // u.fecha_nacimiento, // (u.generacion != null ? u.generacion.toString() : '') // ].join('\t')).join('\n'); // return header + rows; // } async generateCsvCorreo(): Promise { const tipos = [ 'Diplomado', 'Extra Largo', 'Ampliación de Conocimiento', 'Servicio Social', 'Movilidad', 'Intercambio UNAM', 'Idiomas Sabatino', 'Idiomas R (UNAM)', 'Trabajadores', 'Profesor', 'Oyente', 'Posgrado', 'Reinscrito', 'Licenciatura', ]; const usuarios = await this.usuarioRepo.find({ where: { usuarioTipos: { tipoUsuario: { tipo_usuario: In(tipos), }, }, servActivo: { CorreoStatus: 'Inactivo', Correo: true, }, }, relations: [ 'genero', 'carreraUsuarios', 'carreraUsuarios.carrera', 'usuarioTipos', 'usuarioTipos.tipoUsuario', ], }); const header = [ 'num_cuenta', 'nombre', 'a_paterno', 'a_materno', 'carreras', 'rfc', 'fecha_nacimiento', 'generacion', 'tipos_usuario', ].join(',') + '\n'; const rows = usuarios .map((u) => { const carreras = (u.carreraUsuarios ?? []) .map((cu) => cu.carrera?.carrera ?? '') .join(' | '); const tiposUsuario = (u.usuarioTipos ?? []) .map((utu) => utu.tipoUsuario?.tipo_usuario ?? '') .join(' | '); return [ u.num_cuenta ?? '', u.nombre ?? '', u.a_paterno ?? '', u.a_materno ?? '', carreras, u.rfc ?? '', u.fecha_nacimiento ?? '', u.generacion?.toString() ?? '', tiposUsuario, ].join(','); }) .join('\n'); const tablaCompleta = header + rows; return tablaCompleta; } async generateCsvSolicita(): Promise { const tipos = [ 'Extra Largo', 'Ampliación de Conocimiento', 'Movilidad', 'Intercambio UNAM', 'Profesor', 'Posgrado', 'Reinscrito', 'Licenciatura', ]; const usuarios = await this.usuarioRepo.find({ where: { usuarioTipos: { tipoUsuario: { tipo_usuario: In(tipos), }, }, servActivo: { PrestamosStatus: 'Inactivo', // solo los que no tienen servicio activo Prestamos: true, // solo los que tienen el servicio de préstamos activo }, }, relations: [ 'genero', 'carreraUsuarios', // relación intermedia 'carreraUsuarios.carrera', // carrera asociada a través de la intermedia 'usuarioTipos', 'usuarioTipos.tipoUsuario', ], }); const header = [ 'num_cuenta', 'nombre', 'a_paterno', 'a_materno', 'carreras', 'rfc', 'tipos_usuario', ].join(',') + '\n'; const rows = usuarios .map((u) => { const carreras = (u.carreraUsuarios ?? []) .map((cu) => cu.carrera?.carrera ?? '') // obtenemos solo el nombre .join(' | '); // une todas las carreras en un solo string const tiposUsuario = (u.usuarioTipos ?? []) .map((utu) => utu.tipoUsuario?.tipo_usuario ?? '') .join(' | '); // une todos los tipos de usuario en un solo string return [ u.num_cuenta ?? '', u.nombre ?? '', u.a_paterno ?? '', u.a_materno ?? '', carreras ?? '', u.rfc ?? '', tiposUsuario, ].join(','); }) .join('\n'); const tablaCompleta = header + rows; return tablaCompleta; } async generateCsvRed(): Promise { const tipos = [ 'Ampliación de Conocimiento', 'Servicio Social', 'Movilidad', 'Intercambio UNAM', 'Idiomas Sabatino', 'Idiomas R (UNAM)', 'Trabajadores', 'Profesor', 'Posgrado', 'Reinscrito', 'Licenciatura', 'Caso Especial', ]; const usuarios = await this.usuarioRepo.find({ where: { usuarioTipos: { tipoUsuario: { tipo_usuario: In(tipos), }, }, servActivo: { RedStatus: 'Inactivo', Red: true, // solo los que tienen el servicio de red }, }, relations: ['usuarioTipos', 'usuarioTipos.tipoUsuario'], }); const header = ['num_cuenta', 'fecha_nacimiento', 'contraseña', 'tipos_usuario'].join( ',', ) + '\n'; const rows = usuarios .map((u) => { const tiposUsuario = (u.usuarioTipos ?? []) .map((utu) => utu.tipoUsuario?.tipo_usuario ?? '') .join(' | '); return [ u.num_cuenta, u.fecha_nacimiento, u.contraseña ?? '', tiposUsuario, ].join(','); }) .join('\n'); const tablaCompleta = header + rows; return tablaCompleta; } async generateCsvAT(): Promise { const tipos = [ 'Ampliación de Conocimiento', 'Servicio Social', 'Movilidad', 'Intercambio UNAM', 'Profesor', 'Posgrado', 'Reinscrito', 'Licenciatura', ]; const usuarios = await this.usuarioRepo.find({ where: { usuarioTipos: { tipoUsuario: { tipo_usuario: In(tipos), }, }, servActivo: { ATStatus: 'Inactivo', AT: true, }, }, relations: [ 'genero', 'carreraUsuarios', // relación intermedia 'carreraUsuarios.carrera', // carrera asociada a través de la intermedia 'usuarioTipos', 'usuarioTipos.tipoUsuario', ], }); const header = [ 'num_cuenta', 'nombre', 'a_paterno', 'a_materno', 'carreras', 'fecha_nacimiento', 'generacion', 'tipos_usuario', ].join(',') + '\n'; const rows = usuarios .map((u) => { const carreras = (u.carreraUsuarios ?? []) .map((cu) => cu.carrera?.carrera ?? '') // obtenemos solo el nombre .join(' | '); // une todas las carreras en un solo string const tiposUsuario = (u.usuarioTipos ?? []) .map((utu) => utu.tipoUsuario?.tipo_usuario ?? '') .join(' | '); // une todos los tipos de usuario en un solo string return [ u.num_cuenta ?? '', u.nombre ?? '', u.a_paterno ?? '', u.a_materno ?? '', carreras ?? '', u.fecha_nacimiento ?? '', u.generacion?.toString() ?? '', tiposUsuario, ].join(','); }) .join('\n'); const tablaCompleta = header + rows; return tablaCompleta; } //Funcion que se le debe hacer su propio módulo async activarServicios(id_movimiento: number, origen: string): Promise { if (!['AT', 'SOLICITA', 'CORREO', 'RED'].includes(origen)) { throw new BadRequestException(`Origen ${origen} no válido`); } const usuarios = await this.usuarioRepo.find({ where: { movimiento: { id_mov: id_movimiento } }, }); if (!usuarios || usuarios.length === 0) { throw new BadRequestException( 'No se encontró el usuario asociado al movimiento', ); } for (const usuario of usuarios) { const servActivos = await this.servActivosRepo.findOne({ where: { usuario: { id_usuario: usuario.id_usuario } }, }); if (!servActivos) { throw new BadRequestException( `No se encontraron servicios activos para el usuario con ID ${usuario.id_usuario}`, ); } const timestamp = 'Activo ' + new Date().toISOString(); if (origen === 'AT' && servActivos.AT) { servActivos.ATStatus = timestamp; } else if (origen === 'RED' && servActivos.Red) { servActivos.RedStatus = timestamp; } else if (origen === 'CORREO' && servActivos.Correo) { servActivos.CorreoStatus = timestamp; } else if (origen === 'SOLICITA' && servActivos.Prestamos) { servActivos.PrestamosStatus = timestamp; } else { continue; } await this.servActivosRepo.save(servActivos); } } async enviarCargaMasiva(data?: { nuevos: number; actualizados: number; }): Promise { // Aquí puedes enviar el correo usnado MailService this.enviarInforme( 'RED', 'Carga de Usuarios', `Se ha realizado una carga de usuarios en el sistema. Nuevos: ${data?.nuevos ?? 0}, Actualizados: ${data?.actualizados ?? 0}`, ); this.enviarInforme( 'AT', 'Carga de Usuarios', `Se ha realizado una carga de usuarios en el sistema. Nuevos: ${data?.nuevos ?? 0}, Actualizados: ${data?.actualizados ?? 0}`, ); this.enviarInforme( 'CORREO', 'Carga de Usuarios', `Se ha realizado una carga de usuarios en el sistema. Nuevos: ${data?.nuevos ?? 0}, Actualizados: ${data?.actualizados ?? 0}`, ); this.enviarInforme( 'SOLICITA', 'Carga de Usuarios', `Se ha realizado una carga de usuarios en el sistema. Nuevos: ${data?.nuevos ?? 0}, Actualizados: ${data?.actualizados ?? 0}`, ); } async enviarInforme( origen: string, subject: string, text: string, html: string = '', ): Promise { try { const user = await this.usuariosDelSistemaRepo.find({ where: { origen: { origen } }, }); user.forEach(async (u) => { await this.mailService.sendMail({ to: u.correo ?? '', subject, text, html, }); }); } catch (error) { throw new BadRequestException( 'No se encontraron usuarios para enviar correo o no se pudo enviar el correo', ); } } }