primera instancia de validador y carga del excel de usuarios, falta agregar las relaciones con las carreras y verificar los validadores

This commit is contained in:
Your Name
2025-06-13 17:54:38 -06:00
parent 8660c85b79
commit 4deb5ecf34
11 changed files with 2083 additions and 93 deletions
+34
View File
@@ -0,0 +1,34 @@
// src/excel/excel.controller.ts
import {
Controller,
Post,
UploadedFile,
UseInterceptors,
BadRequestException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ExcelService } from './excel.service';
import { ExcelDocumentation } from './excel.documentation';
@Controller('excel')
export class ExcelController {
constructor(private readonly excelService: ExcelService) {}
@Post('verify')
@UseInterceptors(FileInterceptor('file'))
@ExcelDocumentation.verifyExcel()
async verifyExcel(@UploadedFile() file: Express.Multer.File) {
if (!file) throw new BadRequestException('Se requiere un archivo .xlsx');
const errors = await this.excelService.validateFile(file.buffer);
return { valid: errors.length === 0, errors };
}
@Post('load')
@UseInterceptors(FileInterceptor('file'))
@ExcelDocumentation.loadExcel()
async loadExcel(@UploadedFile() file: Express.Multer.File) {
if (!file) throw new BadRequestException('Se requiere un archivo .xlsx');
const result = await this.excelService.loadFile(file.buffer);
return result; // { inserted: X }
}
}
+96
View File
@@ -0,0 +1,96 @@
import { applyDecorators } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiConsumes, ApiBody, ApiResponse } from '@nestjs/swagger';
export class ExcelDocumentation {
/**
* Decorators Swagger para el endpoint POST /excel/verify
*/
static verifyExcel() {
return applyDecorators(
ApiTags('Excel'),
ApiOperation({
summary: 'Verificar archivo Excel',
description: 'Valida duplicados, filas en blanco y formato básico de los datos sin realizar carga en la base.'
}),
ApiConsumes('multipart/form-data'),
ApiBody({
schema: {
type: 'object',
properties: {
file: {
type: 'string',
format: 'binary',
description: 'Archivo Excel (.xlsx) con usuarios a validar'
}
}
}
}),
ApiResponse({
status: 200,
description: 'Resultado de la validación',
schema: {
type: 'object',
properties: {
valid: { type: 'boolean', example: false },
errors: {
type: 'array',
items: { type: 'string' },
example: ['Fila 2: cuenta duplicada "42515101".', 'Fila 3: fecha de nacimiento inválida "1988051".']
}
}
}
}),
ApiResponse({ status: 400, description: 'No se recibió archivo o formato inválido.' })
);
}
/**
* Decorators Swagger para el endpoint POST /excel/load
*/
static loadExcel() {
return applyDecorators(
ApiTags('Excel'),
ApiOperation({
summary: 'Cargar archivo Excel',
description: 'Valida y persiste los datos en la base de datos. Retorna el número de registros insertados.'
}),
ApiConsumes('multipart/form-data'),
ApiBody({
schema: {
type: 'object',
properties: {
file: {
type: 'string',
format: 'binary',
description: 'Archivo Excel (.xlsx) con usuarios a cargar'
}
}
}
}),
ApiResponse({
status: 201,
description: 'Usuarios insertados correctamente',
schema: {
type: 'object',
properties: {
inserted: { type: 'number', example: 3 }
}
}
}),
ApiResponse({
status: 400,
description: 'Errores de validación o carga',
schema: {
type: 'object',
properties: {
errors: {
type: 'array',
items: { type: 'string' },
example: ['Fila 5: rfc contiene caracteres inválidos.']
}
}
}
})
);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { ExcelService } from './excel.service';
import { ExcelController } from './excel.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Usuario, Genero, Carrera } from '../entities/entities';
@Module({
imports: [
TypeOrmModule.forFeature([Usuario, Genero, Carrera]),
],
providers: [ExcelService],
controllers: [ExcelController],
})
export class ExcelModule {}
+207
View File
@@ -0,0 +1,207 @@
// src/excel/excel.service.ts
import { Injectable, BadRequestException, Logger } from '@nestjs/common';
import { Workbook } from 'exceljs';
import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { Usuario } 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;
}
@Injectable()
export class ExcelService {
private readonly logger = new Logger(ExcelService.name);
constructor(
@InjectRepository(Usuario)
private readonly usuarioRepo: Repository<Usuario>,
@InjectRepository(Genero)
private readonly generoRepo: Repository<Genero>,
@InjectRepository(Carrera)
private readonly carreraRepo: Repository<Carrera>,
// … 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<UsuarioRow[]> {
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<string>();
rows.forEach((r, i) => {
const rowNum = i + 2; // por el encabezado
// Campos que no pueden quedar vacíos
['cuenta','nombreCompleto','clave','fechnac'].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
if (!/^[0-9]+$/.test(cuentaStr)) {
errors.push(`Fila ${rowNum}: cuenta debe ser numérica.`);
}
const genStr = r.gen != null ? r.gen.toString().trim() : '';
if (!/^[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;
}
/**
* 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);
}
// 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: parseInt(r.gen, 10),
genero,
});
await this.usuarioRepo.save(usuario);
// 4) Relacion usuariocarrera (tabla intermedia)
await this.carreraRepo
.createQueryBuilder()
.relation('carreraUsuarios')
.of(carrera)
.add(usuario);
}
return { inserted: rows.length };
}
}