Add xlsx support and enhance Trabajadores module with file upload functionality

- Updated package.json and package-lock.json to include xlsx dependency.
- Enhanced AlumnosService to return UsuarioData type.
- Modified CuestionarioRespondidoService to include event start and end dates in responses.
- Updated TrabajadoresController to add file upload endpoint for processing Excel files.
- Implemented file processing logic in TrabajadoresService to handle worker registration from Excel.
- Created TrabajadorApiDocumentation for API documentation of the new file upload feature.
- Refactored RegistroTrabajador entity to include carrera field and changed num_trabajador type.
This commit is contained in:
miguel
2025-06-19 18:36:29 -06:00
parent ae0313cd1f
commit 74de90eda8
11 changed files with 317 additions and 20 deletions
+105 -1
View File
@@ -33,7 +33,8 @@
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.21"
"typeorm": "^0.3.21",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
@@ -4085,6 +4086,15 @@
"node": ">=0.4.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -4870,6 +4880,19 @@
],
"license": "CC-BY-4.0"
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -5095,6 +5118,15 @@
"node": ">= 0.12.0"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/collect-v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz",
@@ -5300,6 +5332,18 @@
}
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/create-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
@@ -6697,6 +6741,15 @@
"node": ">= 0.6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
@@ -10536,6 +10589,18 @@
"node": ">= 0.6"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stack-utils": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
@@ -12126,6 +12191,24 @@
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -12237,6 +12320,27 @@
"dev": true,
"license": "ISC"
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+2 -1
View File
@@ -44,7 +44,8 @@
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.21"
"typeorm": "^0.3.21",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
+2 -2
View File
@@ -1,5 +1,5 @@
import { Controller, Get, Param, NotFoundException } from '@nestjs/common';
import { AlumnosService } from './alumnos.service';
import { AlumnosService, UsuarioData } from './alumnos.service';
import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
import { AlumnoDto, ALUMNO_RESPONSES } from './alumnos.documentation';
@@ -14,7 +14,7 @@ export class AlumnosController {
@ApiResponse(ALUMNO_RESPONSES[200])
@ApiResponse(ALUMNO_RESPONSES[404])
@ApiResponse(ALUMNO_RESPONSES[500])
async getAlumnoByCuenta(@Param('cuenta') cuenta: string): Promise<AlumnoDto | null> {
async getAlumnoByCuenta(@Param('cuenta') cuenta: string): Promise<UsuarioData | null> {
const alumno = await this.alumnosService.findByCuenta(cuenta);
if (!alumno) {
throw new NotFoundException(`No se encontró alumno con número de cuenta ${cuenta}`);
+19 -2
View File
@@ -3,6 +3,15 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RegistroAlumno } from './entities/registro-alumno.entity';
export type UsuarioData = {
cuenta: string | null;
nombre: string | null;
apellidos: string | null;
carrera: string | null;
genero: string | null;
rfc?: string | null; // Solo para trabajadores
}
@Injectable()
export class AlumnosService {
constructor(
@@ -10,9 +19,17 @@ export class AlumnosService {
private registroAlumnoRepository: Repository<RegistroAlumno>,
) {}
async findByCuenta(cuenta: string): Promise<RegistroAlumno | null> {
return this.registroAlumnoRepository.findOne({
async findByCuenta(cuenta: string): Promise<UsuarioData | null> {
const alumno = await this.registroAlumnoRepository.findOne({
where: { id_ncuenta: parseInt(cuenta, 10) },
});
return {
cuenta: alumno?.id_ncuenta.toString() || null,
nombre: alumno?.nombre || null,
apellidos: alumno?.apellidos || null,
carrera: alumno?.carrera || null,
genero: alumno?.genero || null,
}
}
}
@@ -261,6 +261,12 @@ export class CuestionarioRespondidoService {
nombreEvento: cuestionario.evento?.nombre_evento,
correo: participante.correo,
fechaRegistro: new Date().toLocaleString(),
fechaInicioEvento: cuestionario.evento?.fecha_inicio
? new Date(cuestionario.evento.fecha_inicio).toLocaleString()
: undefined,
fechaFinEvento: cuestionario.evento?.fecha_fin
? new Date(cuestionario.evento.fecha_fin).toLocaleString()
: undefined,
}),
adjuntos: [
{
+17
View File
@@ -3,12 +3,27 @@ export function generarHtmlCorreoAsistencia({
nombreEvento,
correo,
fechaRegistro,
fechaInicioEvento,
fechaFinEvento,
}: {
nombreForm: string;
nombreEvento: string;
correo: string;
fechaRegistro: string;
fechaInicioEvento?: string;
fechaFinEvento?: string;
}) {
const horarioEvento = fechaInicioEvento && fechaFinEvento
? `
<div style="margin: 20px 0; padding: 12px; background-color: #e8f0fe; border-left: 4px solid #003d79;">
<p style="margin: 0; font-size: 15px; color: #003d79;">
<strong>🕒 Horario del evento:</strong><br/>
${fechaInicioEvento} <strong>al</strong> ${fechaFinEvento}
</p>
</div>
`
: '';
return `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; border: 1px solid #ccc; border-radius: 8px; background-color: #f9f9f9;">
<h1 style="color: #003d79; border-bottom: 2px solid #003d79; padding-bottom: 10px;">🎓 ¡Gracias por registrarte!</h1>
@@ -32,6 +47,8 @@ export function generarHtmlCorreoAsistencia({
<li><strong>Fecha de registro:</strong> ${fechaRegistro}</li>
</ul>
${horarioEvento}
<p style="margin-top: 30px; font-size: 14px; color: #666;">
Si tienes alguna duda, acude al módulo de información durante el evento.
</p>
@@ -0,0 +1,30 @@
import { applyDecorators } from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiConsumes,
ApiBody,
} from '@nestjs/swagger';
export class TrabajadorApiDocumentation {
static ApiController = ApiTags('Trabajador');
static ApiCargarArchivo = applyDecorators(
ApiOperation({ summary: 'Cargar archivo Excel con trabajadores' }),
ApiConsumes('multipart/form-data'),
ApiBody({
description: 'Archivo Excel (.xls o .xlsx) con columnas: rfc, num_empleado, nombre, adscripcion, correo, AP1, AP2, Nombres, sexo',
schema: {
type: 'object',
properties: {
file: {
type: 'string',
format: 'binary',
description: 'Archivo Excel con trabajadores a registrar',
},
},
required: ['file'],
},
}),
);
}
@@ -5,8 +5,8 @@ export class RegistroTrabajador {
@PrimaryGeneratedColumn()
id_trabajador: number;
@Column({ type: 'varchar', length: 20, unique: true, nullable: true })
numero_trabajador: string;
@Column({ type: 'int', unique: true, nullable: true })
num_trabajador: number;
@Column({ type: 'varchar', length: 13, unique: true })
rfc: string;
@@ -19,4 +19,7 @@ export class RegistroTrabajador {
@Column({ type: 'char', length: 1 })
genero: string;
@Column({ type: 'varchar', length: 100 })
carrera: string;
}
+45 -10
View File
@@ -1,29 +1,64 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseInterceptors,
UploadedFile,
NotFoundException,
} from '@nestjs/common';
import { TrabajadoresService } from './trabajadores.service';
import { CreateTrabajadoreDto } from './dto/create-trabajadore.dto';
import { UpdateTrabajadoreDto } from './dto/update-trabajadore.dto';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname } from 'path';
import { TrabajadorApiDocumentation } from './docs/trabajadores.docs';
@Controller('trabajadores')
@TrabajadorApiDocumentation.ApiController
export class TrabajadoresController {
constructor(private readonly trabajadoresService: TrabajadoresService) {}
@Post('cargar')
@TrabajadorApiDocumentation.ApiCargarArchivo
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
destination: './uploads',
filename: (_, file, callback) => {
const uniqueName = `${Date.now()}${extname(file.originalname)}`;
callback(null, uniqueName);
},
}),
}),
)
async cargarArchivo(@UploadedFile() file: Express.Multer.File) {
return this.trabajadoresService.procesarArchivo(file.path);
}
@Post()
create(@Body() createTrabajadoreDto: CreateTrabajadoreDto) {
return this.trabajadoresService.create(createTrabajadoreDto);
}
@Get()
findAll() {
return this.trabajadoresService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.trabajadoresService.findOne(+id);
@Get(':rfc')
findByRfc(@Param('rfc') rfc: string) {
const trabajador = this.trabajadoresService.findByRfc(rfc);
if (!trabajador) {
throw new NotFoundException(`No se encontró trabajador con RFC ${rfc}`);
}
return trabajador;
}
@Patch(':id')
update(@Param('id') id: string, @Body() updateTrabajadoreDto: UpdateTrabajadoreDto) {
update(
@Param('id') id: string,
@Body() updateTrabajadoreDto: UpdateTrabajadoreDto,
) {
return this.trabajadoresService.update(+id, updateTrabajadoreDto);
}
+4 -2
View File
@@ -5,9 +5,11 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { RegistroTrabajador } from './entities/trabajadore.entity';
@Module({
imports: [TypeOrmModule.forFeature([RegistroTrabajador], 'alumnosConnection')],
imports: [
TypeOrmModule.forFeature([RegistroTrabajador], 'alumnosConnection'),
],
controllers: [TrabajadoresController],
providers: [TrabajadoresService],
exports: [TrabajadoresService],
})
export class TrabajadoresModule {}
+82
View File
@@ -1,9 +1,91 @@
import { Injectable } from '@nestjs/common';
import { CreateTrabajadoreDto } from './dto/create-trabajadore.dto';
import { UpdateTrabajadoreDto } from './dto/update-trabajadore.dto';
import { InjectRepository } from '@nestjs/typeorm';
import { RegistroTrabajador } from './entities/trabajadore.entity';
import { Repository } from 'typeorm';
import * as fs from 'fs';
import * as XLSX from 'xlsx';
import { UsuarioData } from 'src/alumnos/alumnos.service';
@Injectable()
export class TrabajadoresService {
constructor(
@InjectRepository(RegistroTrabajador, 'alumnosConnection')
private readonly trabajadorRepo: Repository<RegistroTrabajador>,
) {}
async procesarArchivo(
filePath: string,
): Promise<{ insertados: number; omitidos: number }> {
const workbook = XLSX.readFile(filePath);
const sheetName = workbook.SheetNames[0];
const data: any[] = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]);
let insertados = 0;
let omitidos = 0;
for (const row of data) {
const rfc = (row['rfc'] || '').trim();
if (!rfc) {
omitidos++;
continue; // RFC es obligatorio
}
const numTrabajadorRaw = row['num_empleado'];
const numTrabajador = Number.isFinite(Number(numTrabajadorRaw))
? Number(numTrabajadorRaw)
: undefined;
const where: any[] = [{ rfc }];
if (numTrabajador !== undefined) {
where.push({ num_trabajador: numTrabajador });
}
const existe = await this.trabajadorRepo.findOne({ where });
if (existe) {
omitidos++;
continue;
}
const nuevo = this.trabajadorRepo.create({
rfc,
num_trabajador: numTrabajador,
nombre: (row['Nombres'] || '').trim(),
apellidos:
`${(row['AP1'] || '').trim()} ${(row['AP2'] || '').trim()}`.trim(),
genero: (row['sexo'] || '').trim().substring(0, 1).toUpperCase(),
carrera: (row['adscripcion'] || '').trim(),
});
await this.trabajadorRepo.save(nuevo);
insertados++;
}
fs.unlinkSync(filePath); // Limpia archivo temporal
return { insertados, omitidos };
}
async findByRfc(rfc: string): Promise<UsuarioData | null> {
const trabajador = await this.trabajadorRepo.findOne({
where: { rfc: rfc.trim().toUpperCase() },
});
return {
cuenta: trabajador?.num_trabajador?.toString() || null,
nombre: trabajador?.nombre || null,
apellidos: trabajador
? `${trabajador.apellidos}`.trim()
: null,
carrera: trabajador?.carrera || null,
genero: trabajador?.genero || null,
rfc: trabajador?.rfc || null, // Incluye RFC en el retorno
}
}
create(createTrabajadoreDto: CreateTrabajadoreDto) {
return 'This action adds a new trabajadore';
}