// src/excel/excel.service.ts import { Injectable, BadRequestException, Logger } from '@nestjs/common'; import { Workbook } from 'exceljs'; import { In, Repository } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; import { CarreraUsuario, TipoUsuario, Usuario, UsuarioTipoUsuario } from '../entities/entities'; import { Genero } from '../entities/entities'; import { Carrera } from '../entities/entities'; 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; } type RuleFn = (r: UsuarioRow, rowNum: number, errors: string[]) => void; @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, // … inyecta otros repositorios si los usarás ) { } /** Valida duplicados, celdas vacías y patrones básicos */ validateRows(rows: UsuarioRow[]) { const errors: string[] = []; const seen = new Set(); rows.forEach((r, i) => { const rowNum = i + 2; // por el encabezado // Campos que no pueden quedar vacíos ['cuenta', 'nombreCompleto'].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.`); } }); // Duplicados de "cuenta" const cuentaStr = r.cuenta != null ? r.cuenta.toString().trim() : ''; if (seen.has(cuentaStr)) { errors.push(`Fila ${rowNum}: cuenta duplicada "${cuentaStr}".`); } else { seen.add(cuentaStr); } // Numérico en cuenta y generación 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}".`); } // 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}".`); } // 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.`); } }); return errors; } // Describes which rules aplicar por tipo private typeRules: Record = { // Regla común para todos los tipos __default: [ this.ruleNonEmpty(['cuenta', 'nombreCompleto', 'tipo']), this.ruleUniqueCuenta(), this.ruleNumericField('gen', /^[0-9]{4}$/), this.ruleDateField('fechnac'), this.ruleRFC('rfc'), ], // Añade reglas extra para Licenciatura Licenciatura: [ this.ruleNonEmpty(['clave', 'nomCarr']), (r, rowNum, errors) => { if (!/^[0-9]{5}$/.test(r.clave)) { errors.push(`Fila ${rowNum}: clave de Licenciatura debe tener 5 dígitos.`); } }, ], // Añade reglas extra para Profesor Profesor: [ (r, rowNum, errors) => { if (!r.correo?.includes('@')) { errors.push(`Fila ${rowNum}: Profesor debe tener correo válido.`); } }, ], // … puedes definir más tipos aquí … }; /** Métodos auxiliares que devuelven funciones de regla */ private ruleNonEmpty(fields: (keyof UsuarioRow)[]): RuleFn { return (r, rowNum, errors) => { for (const f of fields) { const v = (r[f] ?? '').toString().trim(); if (!v) { errors.push(`Fila ${rowNum}: campo "${f}" está vacío.`); } } }; } private ruleUniqueCuenta(): RuleFn { const seen = new Set(); return (r, rowNum, errors) => { const c = (r.cuenta ?? '').toString().trim(); if (seen.has(c)) { errors.push(`Fila ${rowNum}: cuenta duplicada "${c}".`); } else { seen.add(c); } }; } private ruleNumericField(field: keyof UsuarioRow, regex: RegExp): RuleFn { return (r, rowNum, errors) => { const v = (r[field] ?? '').toString().trim(); if (v && !regex.test(v)) { errors.push(`Fila ${rowNum}: campo "${field}" inválido: "${v}".`); } }; } private ruleDateField(field: keyof UsuarioRow): RuleFn { return (r, rowNum, errors) => { const v = (r[field] ?? '').toString().trim(); if (v && !/^[0-9]{8}$/.test(v)) { errors.push(`Fila ${rowNum}: fecha "${field}" debe ser YYYYMMDD: "${v}".`); } }; } private ruleRFC(field: keyof UsuarioRow): RuleFn { return (r, rowNum, errors) => { const v = (r[field] ?? '').toString().trim(); if (v && !/^[A-Z0-9]+$/.test(v)) { errors.push(`Fila ${rowNum}: RFC contiene caracteres inválidos: "${v}".`); } }; } /** 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[] = []; sheet.eachRow((row, idx) => { if (idx === 1) return; // salto encabezados const vals = row.values as any[]; rows.push({ cuenta: vals[1] ?? '', nombreCompleto: vals[2] ?? '', clave: vals[3] ?? '', nomCarr: vals[4] ?? '', gen: vals[5] ?? '', fechnac: vals[6] ?? '', apellidopa: vals[7] ?? '', apellidoma: vals[8] ?? '', nombres: vals[9] ?? '', sexo: vals[10] ?? '', tipo: vals[11] ?? '', correo: vals[12] ?? '', rfc: vals[13] ?? '', }); }); return rows; } /** * Valida cada fila según reglas comunes y específicas por tipo. * Retorna un array de mensajes de error. */ validateRowsAdvanced(rows: UsuarioRow[]): string[] { const errors: string[] = []; rows.forEach((r, i) => { const rowNum = i + 2; // 1) Aplica reglas comunes for (const rule of this.typeRules.__default) { rule.call(this, r, rowNum, errors); } // 2) Aplica reglas del tipo específico const specificRules = this.typeRules[r.tipo] ?? []; for (const rule of specificRules) { rule.call(this, r, rowNum, errors); } }); return errors; } /** * Solo valida: retorna lista de errores (vacío = ok) */ async validateFile(buffer: Buffer) { const rows = await this.parseFile(buffer); const errs = this.validateRows(rows); return errs; } /** * Valida y luego persiste usuarios y relaciones básicas */ async loadFile(buffer: Buffer) { const rows = await this.parseFile(buffer); const errs = this.validateRows(rows); if (errs.length) { throw new BadRequestException({ errors: errs }); } for (const r of rows) { // 1) Género (crea o reutiliza) let genero = await this.generoRepo.findOne({ where: { genero: r.sexo } }); if (!genero) { genero = this.generoRepo.create({ genero: r.sexo }); await this.generoRepo.save(genero); } // 2) Carrera (por clave) let carrera = await this.carreraRepo.findOne({ where: { clave: r.clave }, }); if (!carrera) { carrera = this.carreraRepo.create({ clave: r.clave, carrera: r.nomCarr.slice(0, 100), }); await this.carreraRepo.save(carrera); } let tipo = await this.tipoUsuarioRepo.findOne({ where: { tipo_usuario: r.tipo.trim() }, }); if (!tipo) { tipo = this.tipoUsuarioRepo.create({ tipo_usuario: r.tipo.trim(), }); await this.tipoUsuarioRepo.save(tipo); } const generacion = /^[0-9]{4}$/.test(r.gen?.toString().trim()) ? parseInt(r.gen.toString().trim(), 10) : null; // 3) Usuario const usuario = this.usuarioRepo.create({ num_cuenta: r.cuenta, nombre: r.nombres.trim().split(' ')[0], // ajusta si quieres a_paterno: r.apellidopa, a_materno: r.apellidoma, rfc: r.rfc, fecha_nacimiento: r.fechnac, generacion: generacion, genero, }); await this.usuarioRepo.save(usuario); // 4) Relacion usuario–carrxera (tabla intermedia) const carreraUsuario = this.carreraUsuarioRepo.create({ usuario, carrera, }); await this.carreraUsuarioRepo.save(carreraUsuario); // 5) Tipo de usuario const usuarioTipo = this.usuarioTipoUsuarioRepo.create({ usuario, tipoUsuario: tipo, }); await this.usuarioTipoUsuarioRepo.save(usuarioTipo); } return { inserted: rows.length }; } // 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), }, }, }, relations: [ 'genero', 'carreraUsuarios', // relación intermedia 'carreraUsuarios.carrera', // carrera asociada a través de la intermedia ], }); const header = [ 'num_cuenta', 'nombre', 'a_paterno', 'a_materno', 'carreras', 'rfc', 'fecha_nacimiento', 'generacion' ].join('\t') + '\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 return [ u.num_cuenta, u.nombre, u.a_paterno, u.a_materno, carreras, u.rfc ?? '', u.fecha_nacimiento, u.generacion?.toString() ?? '' ].join('\t'); }).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), }, }, }, relations: [ 'genero', 'carreraUsuarios', // relación intermedia 'carreraUsuarios.carrera', // carrera asociada a través de la intermedia ], }); const usuariosTipo = await this.usuarioRepo.find({ relations: [ 'genero', 'carreraUsuarios', // relación intermedia 'carreraUsuarios.carrera', 'usuarioTipos', // relación con tipo de usuario 'usuarioTipos.tipoUsuario', // tipo de usuario asociado ], }); console.log('Usuarios encontrados:', usuariosTipo); console.log('Usuarios encontrados:', usuarios); const header = [ 'num_cuenta', 'nombre', 'a_paterno', 'a_materno', 'carreras', 'rfc' ].join('\t') + '\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 return [ u.num_cuenta, u.nombre, u.a_paterno, u.a_materno, carreras, u.rfc ?? '', ].join('\t'); }).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', ]; const usuarios = await this.usuarioRepo.find({ where: { usuarioTipos: { tipoUsuario: { tipo_usuario: In(tipos), }, }, }, relations: [ 'genero', 'carreraUsuarios', // relación intermedia 'carreraUsuarios.carrera', // carrera asociada a través de la intermedia ], }); const header = [ 'num_cuenta', 'fecha_nacimiento' ].join('\t') + '\n'; const rows = usuarios.map(u => { return [ u.num_cuenta, u.fecha_nacimiento, ].join('\t'); }).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), }, }, }, relations: [ 'genero', 'carreraUsuarios', // relación intermedia 'carreraUsuarios.carrera', // carrera asociada a través de la intermedia ], }); const header = [ 'num_cuenta', 'nombre', 'a_paterno', 'a_materno', 'carreras', 'rfc', 'fecha_nacimiento', 'generacion' ].join('\t') + '\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 return [ u.num_cuenta, u.nombre, u.a_paterno, u.a_materno, carreras, u.fecha_nacimiento, u.generacion?.toString() ?? '' ].join('\t'); }).join('\n'); const tablaCompleta = header + rows; return tablaCompleta; } }