5 Commits

Author SHA1 Message Date
Your Name 605227aeba doc movimientos 2025-06-18 17:01:55 -06:00
Your Name 3411737e6d obtener movimientos de la tabla sql 2025-06-18 16:59:33 -06:00
Your Name 3a7f8d3647 validate advanced func sin utilizar en base al ejemplo de rosario 2025-06-18 16:23:01 -06:00
Your Name 2a1a3bc5c0 movimientos logs con data de payload, refactor de controller 2025-06-18 15:44:55 -06:00
Your Name 6114fa2583 movimiento log tipo admnin desde el payload, solo en verificacion de excel 2025-06-18 15:05:05 -06:00
7 changed files with 318 additions and 224 deletions
+1 -1
View File
@@ -22,6 +22,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
async validate(payload: any) { async validate(payload: any) {
// Devuelve lo que estará disponible en Request.user // Devuelve lo que estará disponible en Request.user
return { userId: payload.sub, email: payload.email, origen: payload.tipo }; return { userId: payload.sub, email: payload.email, origen: payload.tipo, tipo: payload.tipo, };
} }
} }
+70 -132
View File
@@ -1,5 +1,5 @@
// src/excel/excel.controller.ts // src/excel/excel.controller.ts
import { Controller, Post, Get, UseGuards, Request, Res, UploadedFile, UseInterceptors, HttpStatus } from '@nestjs/common'; import { Controller, Post, Get, UseGuards, Request, Res, UploadedFile, UseInterceptors, HttpStatus, ForbiddenException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { ApiBearerAuth } from '@nestjs/swagger'; import { ApiBearerAuth } from '@nestjs/swagger';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
@@ -17,176 +17,114 @@ export class ExcelController {
private readonly movimientoService: MovimientoService, private readonly movimientoService: MovimientoService,
) { } ) { }
// excel.controller.ts
@Post('verify') @Post('verify')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file'))
@ExcelDocumentation.verifyExcel() @ExcelDocumentation.verifyExcel()
async verifyExcel(@UploadedFile() file: Express.Multer.File, @Request() req) { async verifyExcel(
const userId: number = req.user.userId; // ahora sí existe @UploadedFile() file: Express.Multer.File,
@Request() req
) {
const { userId, tipo } = req.user; // obtenemos ambos
const errors = await this.excelService.validateFile(file.buffer); const errors = await this.excelService.validateFile(file.buffer);
await this.movimientoService.log(
'VERIFY', await this.movimientoService.log(
userId, // el ID
tipo, // aquí va tu fuente dinámica
errors.length ? 'FAILED' : 'SUCCESS', errors.length ? 'FAILED' : 'SUCCESS',
errors.join('; ') errors.join('; ')
); );
return { valid: errors.length === 0, errors }; return { valid: errors.length === 0, errors };
} }
@Post('load') @Post('load')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file'))
@ExcelDocumentation.loadExcel() @ExcelDocumentation.loadExcel()
async loadExcel(@Request() req, @UploadedFile() file: Express.Multer.File) { async loadExcel(
const origen = req.user.origen; // ahora sí existe @UploadedFile() file: Express.Multer.File,
if (origen != 'LOAD') { @Request() req,
) {
const { userId, tipo } = req.user; // userId es number, tipo tu fuente
if (tipo !== 'LOAD') {
await this.movimientoService.log( await this.movimientoService.log(
'LOAD', userId,
tipo,
'FAILED', 'FAILED',
'Origen no permitido para carga' 'Origen no permitido para carga',
); );
throw new Error('Origen no permitido para carga.'); throw new ForbiddenException('Origen no permitido para carga.');
} }
try {
const result = await this.excelService.loadFile(file.buffer);
await this.movimientoService.log(
'LOAD', try {
const { inserted } = await this.excelService.loadFile(file.buffer);
await this.movimientoService.log(
userId,
tipo,
'SUCCESS', 'SUCCESS',
undefined, undefined,
`inserted=${result.inserted}` `inserted=${inserted}`,
); );
return result; return { inserted };
} catch (err) { } catch (err) {
await this.movimientoService.log( await this.movimientoService.log(
userId,
'LOAD', tipo,
'FAILED', 'FAILED',
err.message err.message,
); );
throw err; throw err;
} }
} }
@Get('download') @Get('download')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth('bearer')
@ExcelDocumentation.downloadExcel() @ExcelDocumentation.downloadExcel()
async downloadData(@Request() req, @Res() res: Response) { async downloadData(@Request() req, @Res() res: Response) {
const origen = req.user.origen; // ahora sí existe const { userId, tipo } = req.user;
console.log('Origen de descarga:', origen);
console.log('Usuario:', req.user);
if (origen == 'SOLICITA') { // Mapeo: tipo → [método de ExcelService, sufijo de filename]
try { const generators: Record<string, [() => Promise<string>, string]> = {
const csv = await this.excelService.generateCsvSolicita(); SOLICITA: [() => this.excelService.generateCsvSolicita(), 'solicita'],
await this.movimientoService.log( CORREO: [() => this.excelService.generateCsvCorreo(), 'correo'],
AT: [() => this.excelService.generateCsvAT(), 'at'],
RED: [() => this.excelService.generateCsvRed(), 'red'],
};
origen, const entry = generators[tipo];
'SUCCESS', if (!entry) {
undefined, await this.movimientoService.log(userId, tipo, 'FAILED', 'Origen no permitido para descarga');
`size=${csv.length}`
);
return res
.status(HttpStatus.OK)
.header('Content-Type', 'text/tab-separated-values')
.header('Content-Disposition', 'attachment; filename="usuarios_solicita.tsv"')
.send(csv);
} catch (err) {
await this.movimientoService.log(
origen,
'FAILED',
'Origen no permitido para descarga'
);
return res
.status(HttpStatus.FORBIDDEN)
.json({ statusCode: 403, message: 'Origen no permitido para descarga.' });
}
} else if (origen == 'CORREO') {
try {
const csv = await this.excelService.generateCsvCorreo();
await this.movimientoService.log(
origen,
'SUCCESS',
undefined,
`size=${csv.length}`
);
return res
.status(HttpStatus.OK)
.header('Content-Type', 'text/tab-separated-values')
.header('Content-Disposition', 'attachment; filename="usuarios.tsv"')
.send(csv);
} catch (err) {
await this.movimientoService.log(
origen,
'FAILED',
err.message
);
return res
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.json({ statusCode: 500, message: 'Error generando descarga.' });
}
} else if (origen == 'AT') {
try {
const csv = await this.excelService.generateCsvAT();
await this.movimientoService.log(
origen,
'SUCCESS',
undefined,
`size=${csv.length}`
);
return res
.status(HttpStatus.OK)
.header('Content-Type', 'text/tab-separated-values')
.header('Content-Disposition', 'attachment; filename="usuarios_at.tsv"')
.send(csv);
} catch (err) {
await this.movimientoService.log(
origen,
'FAILED',
err.message
);
return res
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.json({ statusCode: 500, message: 'Error generando descarga.' });
}
} else if (origen == 'RED') {
try {
const csv = await this.excelService.generateCsvRed();
await this.movimientoService.log(
origen,
'SUCCESS',
undefined,
`size=${csv.length}`
);
return res
.status(HttpStatus.OK)
.header('Content-Type', 'text/tab-separated-values')
.header('Content-Disposition', 'attachment; filename="usuarios_red.tsv"')
.send(csv);
} catch (err) {
await this.movimientoService.log(
origen,
'FAILED',
err.message
);
return res
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.json({ statusCode: 500, message: 'Error generando descarga.' });
}
} else {
await this.movimientoService.log(
origen,
'FAILED',
'Origen no permitido para descarga'
);
return res return res
.status(HttpStatus.FORBIDDEN) .status(HttpStatus.FORBIDDEN)
.json({ statusCode: 403, message: 'Origen no permitido para descarga.' }); .json({ statusCode: 403, message: 'Origen no permitido para descarga.' });
} }
const [generateCsv, suffix] = entry;
try {
const csv = await generateCsv();
await this.movimientoService.log(userId, tipo, 'SUCCESS', undefined, `size=${csv.length}`);
return res
.status(HttpStatus.OK)
.header('Content-Type', 'text/tab-separated-values')
.header('Content-Disposition', `attachment; filename="usuarios_${suffix}.tsv"`)
.send(csv);
} catch (err) {
await this.movimientoService.log(userId, tipo, 'FAILED', err.message);
return res
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.json({ statusCode: 500, message: 'Error generando descarga.' });
} }
} }
}
+142 -52
View File
@@ -23,6 +23,9 @@ export interface UsuarioRow {
rfc: string; rfc: string;
} }
type RuleFn = (r: UsuarioRow, rowNum: number, errors: string[]) => void;
@Injectable() @Injectable()
export class ExcelService { export class ExcelService {
private readonly logger = new Logger(ExcelService.name); private readonly logger = new Logger(ExcelService.name);
@@ -43,59 +46,7 @@ export class ExcelService {
// … inyecta otros repositorios si los usarás // … 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 */ /** Valida duplicados, celdas vacías y patrones básicos */
validateRows(rows: UsuarioRow[]) { validateRows(rows: UsuarioRow[]) {
@@ -148,6 +99,145 @@ export class ExcelService {
} }
// Describes which rules aplicar por tipo
private typeRules: Record<string, RuleFn[]> = {
// 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<string>();
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<UsuarioRow[]> {
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) * Solo valida: retorna lista de errores (vacío = ok)
*/ */
+23
View File
@@ -0,0 +1,23 @@
// src/movimiento/movimiento.controller.ts
import { Controller, Get, UseGuards } from '@nestjs/common';
import { MovimientoService } from './movimiento.service';
import { Movimiento } from '../entities/entities';
import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { MovimientoDocumentation } from './movimiento.documentation';
@ApiTags('Movimientos')
@ApiBearerAuth('bearer')
@UseGuards(AuthGuard('jwt'))
@Controller('movimientos')
export class MovimientoController {
constructor(private readonly movimientoService: MovimientoService) {}
@Get()
@MovimientoDocumentation.findAllmov()
@ApiOperation({ summary: 'Listar movimientos', description: 'Obtiene todos los registros de movimientos con usuario y origen.' })
@ApiResponse({ status: 200, description: 'Listado de movimientos', type: Movimiento, isArray: true })
async findAll(): Promise<Movimiento[]> {
return this.movimientoService.findAll();
}
}
@@ -0,0 +1,26 @@
import { applyDecorators } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { Movimiento } from '../entities/entities';
export class MovimientoDocumentation {
/**
* Decorators Swagger para el endpoint GET /movimientos
*/
static findAllmov() {
return applyDecorators(
ApiTags('Movimientos'),
ApiBearerAuth('bearer'),
ApiOperation({
summary: 'Listar todos los movimientos',
description: 'Devuelve el histórico completo de movimientos, incluyendo usuario y origen.'
}),
ApiResponse({
status: 200,
description: 'Listado de movimientos exitoso.',
type: Movimiento,
isArray: true
}),
ApiResponse({ status: 401, description: 'No autorizado.' }),
);
}
}
+3
View File
@@ -3,10 +3,13 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { MovimientoService } from './movimiento.service'; import { MovimientoService } from './movimiento.service';
import { Movimiento, Origen } from '../entities/entities'; import { Movimiento, Origen } from '../entities/entities';
import { MovimientoController } from './movimiento.controller';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Movimiento, Origen])], imports: [TypeOrmModule.forFeature([Movimiento, Origen])],
providers: [MovimientoService], providers: [MovimientoService],
controllers: [MovimientoController],
exports: [MovimientoService], exports: [MovimientoService],
}) })
export class MovimientoModule {} export class MovimientoModule {}
+17 -3
View File
@@ -15,15 +15,16 @@ export class MovimientoService {
/** Crea un registro de movimiento */ /** Crea un registro de movimiento */
async log( async log(
origen: string, usuarioId: number,
fuente: string,
status: string, status: string,
observaciones?: string, observaciones?: string,
reporte?: string, reporte?: string,
flag = false, flag = false,
) { ) {
// 1) Obtén el origen o falla si no existe // 1) Obtén el origen o falla si no existe
const origin = await this.origenRepo.findOne({ where: { origen } }); const origin = await this.origenRepo.findOne({ where: { origen:fuente } });
if (!origin) throw new Error(`Origen desconocido: ${origen}`); if (!origin) throw new Error(`Origen desconocido: ${fuente}`);
// 2) Inserta directamente usando los campos de FK // 2) Inserta directamente usando los campos de FK
await this.movRepo.insert({ await this.movRepo.insert({
@@ -35,4 +36,17 @@ export class MovimientoService {
origen: { id_origen: origin.id_origen }, origen: { id_origen: origin.id_origen },
}); });
} }
/** Devuelve todos los movimientos con usuario y origen */
async findAll(): Promise<Movimiento[]> {
return this.movRepo.find({
relations: [ 'origen'],
order: { fecha_mov: 'DESC' },
});
}
} }