[ERP-CONSTRUCCION] feat(hse): Add 14 services for safety, training, and environmental management
Safety: - dias-sin-accidente.service: Days without accidents counter - hallazgo.service: Safety findings with workflow - programa-seguridad.service: Annual safety programs Training: - instructor.service: Training instructors management - sesion-capacitacion.service: Training sessions and attendance - constancia-dc3.service: DC-3 certificates (STPS) - cumplimiento.service: Regulatory compliance tracking - comision-seguridad.service: Safety commissions (NOM-019) - horas-trabajadas.service: Work hours for safety indices Environmental: - residuo-peligroso.service: Hazardous waste management - almacen-temporal.service: Temporary storage facilities - proveedor-ambiental.service: Environmental providers - auditoria-ambiental.service: Environmental audits - reporte-programado.service: Scheduled HSE reports Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e5f63495e8
commit
88e1c4e9b6
481
src/modules/hse/services/almacen-temporal.service.ts
Normal file
481
src/modules/hse/services/almacen-temporal.service.ts
Normal file
@ -0,0 +1,481 @@
|
||||
/**
|
||||
* AlmacenTemporalService - Servicio para gestión de almacenes temporales de residuos
|
||||
*
|
||||
* RF-MAA017-006: Gestión de almacenes temporales con control de capacidad,
|
||||
* inspecciones y cumplimiento normativo.
|
||||
*
|
||||
* @module HSE
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { AlmacenTemporal, EstadoAlmacen } from '../entities/almacen-temporal.entity';
|
||||
import { ResiduoGeneracion } from '../entities/residuo-generacion.entity';
|
||||
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||
|
||||
export interface AlmacenFilters {
|
||||
fraccionamientoId?: string;
|
||||
estado?: EstadoAlmacen;
|
||||
tieneContencion?: boolean;
|
||||
tieneTecho?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface CreateAlmacenDto {
|
||||
fraccionamientoId: string;
|
||||
nombre: string;
|
||||
ubicacion?: string;
|
||||
capacidadM3?: number;
|
||||
tieneContencion?: boolean;
|
||||
tieneTecho?: boolean;
|
||||
senalizacionOk?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateAlmacenDto {
|
||||
nombre?: string;
|
||||
ubicacion?: string;
|
||||
capacidadM3?: number;
|
||||
tieneContencion?: boolean;
|
||||
tieneTecho?: boolean;
|
||||
senalizacionOk?: boolean;
|
||||
estado?: EstadoAlmacen;
|
||||
}
|
||||
|
||||
export interface InspeccionAlmacenDto {
|
||||
tieneContencion: boolean;
|
||||
tieneTecho: boolean;
|
||||
senalizacionOk: boolean;
|
||||
observaciones?: string;
|
||||
}
|
||||
|
||||
export interface AlmacenConOcupacion extends AlmacenTemporal {
|
||||
ocupacionActual: number;
|
||||
porcentajeOcupacion: number;
|
||||
residuosAlmacenados: number;
|
||||
}
|
||||
|
||||
export interface AlmacenStats {
|
||||
totalAlmacenes: number;
|
||||
almacenesOperativos: number;
|
||||
almacenesLlenos: number;
|
||||
almacenesMantenimiento: number;
|
||||
capacidadTotalM3: number;
|
||||
ocupacionTotalM3: number;
|
||||
porcentajeOcupacionGlobal: number;
|
||||
almacenesSinContencion: number;
|
||||
almacenesSinTecho: number;
|
||||
almacenesSinSenalizacion: number;
|
||||
}
|
||||
|
||||
export interface AlertaAlmacen {
|
||||
almacenId: string;
|
||||
nombre: string;
|
||||
tipo: 'capacidad' | 'infraestructura' | 'normativo';
|
||||
mensaje: string;
|
||||
nivel: 'advertencia' | 'critico';
|
||||
}
|
||||
|
||||
export class AlmacenTemporalService {
|
||||
constructor(
|
||||
private readonly almacenRepository: Repository<AlmacenTemporal>,
|
||||
private readonly generacionRepository: Repository<ResiduoGeneracion>
|
||||
) {}
|
||||
|
||||
// ========== CRUD de Almacenes ==========
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters: AlmacenFilters = {},
|
||||
page: number = 1,
|
||||
limit: number = 20
|
||||
): Promise<PaginatedResult<AlmacenTemporal>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.almacenRepository
|
||||
.createQueryBuilder('a')
|
||||
.leftJoinAndSelect('a.fraccionamiento', 'fraccionamiento')
|
||||
.where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (filters.fraccionamientoId) {
|
||||
queryBuilder.andWhere('a.fraccionamiento_id = :fraccionamientoId', {
|
||||
fraccionamientoId: filters.fraccionamientoId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.estado) {
|
||||
queryBuilder.andWhere('a.estado = :estado', { estado: filters.estado });
|
||||
}
|
||||
|
||||
if (filters.tieneContencion !== undefined) {
|
||||
queryBuilder.andWhere('a.tiene_contencion = :tieneContencion', {
|
||||
tieneContencion: filters.tieneContencion,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.tieneTecho !== undefined) {
|
||||
queryBuilder.andWhere('a.tiene_techo = :tieneTecho', {
|
||||
tieneTecho: filters.tieneTecho,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
queryBuilder.andWhere(
|
||||
'(a.nombre ILIKE :search OR a.ubicacion ILIKE :search)',
|
||||
{ search: `%${filters.search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
queryBuilder
|
||||
.orderBy('a.nombre', 'ASC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async findById(ctx: ServiceContext, id: string): Promise<AlmacenTemporal | null> {
|
||||
return this.almacenRepository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<AlmacenTemporal>,
|
||||
relations: ['fraccionamiento'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByFraccionamiento(
|
||||
ctx: ServiceContext,
|
||||
fraccionamientoId: string
|
||||
): Promise<AlmacenTemporal[]> {
|
||||
return this.almacenRepository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
fraccionamientoId,
|
||||
} as FindOptionsWhere<AlmacenTemporal>,
|
||||
order: { nombre: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(ctx: ServiceContext, dto: CreateAlmacenDto): Promise<AlmacenTemporal> {
|
||||
const almacen = this.almacenRepository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
fraccionamientoId: dto.fraccionamientoId,
|
||||
nombre: dto.nombre,
|
||||
ubicacion: dto.ubicacion,
|
||||
capacidadM3: dto.capacidadM3,
|
||||
tieneContencion: dto.tieneContencion ?? true,
|
||||
tieneTecho: dto.tieneTecho ?? true,
|
||||
senalizacionOk: dto.senalizacionOk ?? true,
|
||||
estado: 'operativo',
|
||||
});
|
||||
|
||||
return this.almacenRepository.save(almacen);
|
||||
}
|
||||
|
||||
async update(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
dto: UpdateAlmacenDto
|
||||
): Promise<AlmacenTemporal | null> {
|
||||
const almacen = await this.findById(ctx, id);
|
||||
if (!almacen) return null;
|
||||
|
||||
if (dto.nombre !== undefined) almacen.nombre = dto.nombre;
|
||||
if (dto.ubicacion !== undefined) almacen.ubicacion = dto.ubicacion;
|
||||
if (dto.capacidadM3 !== undefined) almacen.capacidadM3 = dto.capacidadM3;
|
||||
if (dto.tieneContencion !== undefined) almacen.tieneContencion = dto.tieneContencion;
|
||||
if (dto.tieneTecho !== undefined) almacen.tieneTecho = dto.tieneTecho;
|
||||
if (dto.senalizacionOk !== undefined) almacen.senalizacionOk = dto.senalizacionOk;
|
||||
if (dto.estado !== undefined) almacen.estado = dto.estado;
|
||||
|
||||
return this.almacenRepository.save(almacen);
|
||||
}
|
||||
|
||||
async updateEstado(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
estado: EstadoAlmacen
|
||||
): Promise<AlmacenTemporal | null> {
|
||||
const almacen = await this.findById(ctx, id);
|
||||
if (!almacen) return null;
|
||||
|
||||
almacen.estado = estado;
|
||||
return this.almacenRepository.save(almacen);
|
||||
}
|
||||
|
||||
async delete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
// Check if there are residuos stored
|
||||
const residuosCount = await this.generacionRepository.count({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
contenedorId: id,
|
||||
estado: 'almacenado',
|
||||
} as FindOptionsWhere<ResiduoGeneracion>,
|
||||
});
|
||||
|
||||
if (residuosCount > 0) {
|
||||
throw new Error('No se puede eliminar un almacen con residuos almacenados');
|
||||
}
|
||||
|
||||
const result = await this.almacenRepository.delete({
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
} as FindOptionsWhere<AlmacenTemporal>);
|
||||
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
// ========== Inspección de Almacén ==========
|
||||
async registrarInspeccion(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
dto: InspeccionAlmacenDto
|
||||
): Promise<AlmacenTemporal | null> {
|
||||
const almacen = await this.findById(ctx, id);
|
||||
if (!almacen) return null;
|
||||
|
||||
almacen.tieneContencion = dto.tieneContencion;
|
||||
almacen.tieneTecho = dto.tieneTecho;
|
||||
almacen.senalizacionOk = dto.senalizacionOk;
|
||||
|
||||
// Auto-update estado based on inspection
|
||||
if (!dto.tieneContencion || !dto.tieneTecho || !dto.senalizacionOk) {
|
||||
almacen.estado = 'mantenimiento';
|
||||
}
|
||||
|
||||
return this.almacenRepository.save(almacen);
|
||||
}
|
||||
|
||||
// ========== Ocupación y Capacidad ==========
|
||||
async getAlmacenConOcupacion(
|
||||
ctx: ServiceContext,
|
||||
id: string
|
||||
): Promise<AlmacenConOcupacion | null> {
|
||||
const almacen = await this.findById(ctx, id);
|
||||
if (!almacen) return null;
|
||||
|
||||
const ocupacion = await this.calcularOcupacion(ctx, id);
|
||||
|
||||
return {
|
||||
...almacen,
|
||||
ocupacionActual: ocupacion.volumenM3,
|
||||
porcentajeOcupacion: ocupacion.porcentaje,
|
||||
residuosAlmacenados: ocupacion.cantidadResiduos,
|
||||
};
|
||||
}
|
||||
|
||||
async findAllConOcupacion(
|
||||
ctx: ServiceContext,
|
||||
fraccionamientoId?: string
|
||||
): Promise<AlmacenConOcupacion[]> {
|
||||
const filters: AlmacenFilters = {};
|
||||
if (fraccionamientoId) filters.fraccionamientoId = fraccionamientoId;
|
||||
|
||||
const result = await this.findAll(ctx, filters, 1, 1000);
|
||||
const almacenes = result.data;
|
||||
|
||||
const almacenesConOcupacion: AlmacenConOcupacion[] = [];
|
||||
|
||||
for (const almacen of almacenes) {
|
||||
const ocupacion = await this.calcularOcupacion(ctx, almacen.id);
|
||||
almacenesConOcupacion.push({
|
||||
...almacen,
|
||||
ocupacionActual: ocupacion.volumenM3,
|
||||
porcentajeOcupacion: ocupacion.porcentaje,
|
||||
residuosAlmacenados: ocupacion.cantidadResiduos,
|
||||
});
|
||||
}
|
||||
|
||||
return almacenesConOcupacion;
|
||||
}
|
||||
|
||||
private async calcularOcupacion(
|
||||
ctx: ServiceContext,
|
||||
almacenId: string
|
||||
): Promise<{ volumenM3: number; porcentaje: number; cantidadResiduos: number }> {
|
||||
const almacen = await this.almacenRepository.findOne({
|
||||
where: { id: almacenId, tenantId: ctx.tenantId } as FindOptionsWhere<AlmacenTemporal>,
|
||||
});
|
||||
|
||||
if (!almacen) {
|
||||
return { volumenM3: 0, porcentaje: 0, cantidadResiduos: 0 };
|
||||
}
|
||||
|
||||
// Get all residuos stored in this almacen
|
||||
const residuos = await this.generacionRepository
|
||||
.createQueryBuilder('g')
|
||||
.where('g.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('g.contenedor_id = :contenedorId', { contenedorId: almacenId })
|
||||
.andWhere('g.estado = :estado', { estado: 'almacenado' })
|
||||
.select('g.unidad', 'unidad')
|
||||
.addSelect('SUM(g.cantidad)', 'total')
|
||||
.groupBy('g.unidad')
|
||||
.getRawMany();
|
||||
|
||||
// Convert to m3 (approximate conversions)
|
||||
let volumenM3 = 0;
|
||||
for (const r of residuos) {
|
||||
const cantidad = parseFloat(r.total || '0');
|
||||
switch (r.unidad) {
|
||||
case 'm3':
|
||||
volumenM3 += cantidad;
|
||||
break;
|
||||
case 'litros':
|
||||
volumenM3 += cantidad / 1000;
|
||||
break;
|
||||
case 'kg':
|
||||
// Approximate: 1 kg = 0.001 m3 (depends on density)
|
||||
volumenM3 += cantidad / 1000;
|
||||
break;
|
||||
case 'piezas':
|
||||
// Approximate: 1 piece = 0.01 m3
|
||||
volumenM3 += cantidad * 0.01;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const cantidadResiduos = await this.generacionRepository.count({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
contenedorId: almacenId,
|
||||
estado: 'almacenado',
|
||||
} as FindOptionsWhere<ResiduoGeneracion>,
|
||||
});
|
||||
|
||||
const capacidad = almacen.capacidadM3 || 1;
|
||||
const porcentaje = Math.min(100, Math.round((volumenM3 / capacidad) * 100));
|
||||
|
||||
return { volumenM3, porcentaje, cantidadResiduos };
|
||||
}
|
||||
|
||||
// ========== Alertas ==========
|
||||
async getAlertas(ctx: ServiceContext, fraccionamientoId?: string): Promise<AlertaAlmacen[]> {
|
||||
const almacenes = await this.findAllConOcupacion(ctx, fraccionamientoId);
|
||||
const alertas: AlertaAlmacen[] = [];
|
||||
|
||||
for (const almacen of almacenes) {
|
||||
// Alerta de capacidad
|
||||
if (almacen.porcentajeOcupacion >= 90) {
|
||||
alertas.push({
|
||||
almacenId: almacen.id,
|
||||
nombre: almacen.nombre,
|
||||
tipo: 'capacidad',
|
||||
mensaje: `Almacen al ${almacen.porcentajeOcupacion}% de capacidad`,
|
||||
nivel: almacen.porcentajeOcupacion >= 95 ? 'critico' : 'advertencia',
|
||||
});
|
||||
}
|
||||
|
||||
// Alertas de infraestructura
|
||||
if (!almacen.tieneContencion) {
|
||||
alertas.push({
|
||||
almacenId: almacen.id,
|
||||
nombre: almacen.nombre,
|
||||
tipo: 'infraestructura',
|
||||
mensaje: 'Almacen sin sistema de contencion',
|
||||
nivel: 'critico',
|
||||
});
|
||||
}
|
||||
|
||||
if (!almacen.tieneTecho) {
|
||||
alertas.push({
|
||||
almacenId: almacen.id,
|
||||
nombre: almacen.nombre,
|
||||
tipo: 'infraestructura',
|
||||
mensaje: 'Almacen sin techo de proteccion',
|
||||
nivel: 'advertencia',
|
||||
});
|
||||
}
|
||||
|
||||
// Alertas normativas
|
||||
if (!almacen.senalizacionOk) {
|
||||
alertas.push({
|
||||
almacenId: almacen.id,
|
||||
nombre: almacen.nombre,
|
||||
tipo: 'normativo',
|
||||
mensaje: 'Senalizacion incompleta o danada',
|
||||
nivel: 'advertencia',
|
||||
});
|
||||
}
|
||||
|
||||
if (almacen.estado === 'mantenimiento') {
|
||||
alertas.push({
|
||||
almacenId: almacen.id,
|
||||
nombre: almacen.nombre,
|
||||
tipo: 'infraestructura',
|
||||
mensaje: 'Almacen en mantenimiento',
|
||||
nivel: 'advertencia',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return alertas;
|
||||
}
|
||||
|
||||
// ========== Estadísticas ==========
|
||||
async getStats(ctx: ServiceContext, fraccionamientoId?: string): Promise<AlmacenStats> {
|
||||
const baseQuery = () => {
|
||||
const qb = this.almacenRepository
|
||||
.createQueryBuilder('a')
|
||||
.where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (fraccionamientoId) {
|
||||
qb.andWhere('a.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
|
||||
}
|
||||
return qb;
|
||||
};
|
||||
|
||||
const totalAlmacenes = await baseQuery().getCount();
|
||||
|
||||
const almacenesOperativos = await baseQuery()
|
||||
.andWhere('a.estado = :estado', { estado: 'operativo' })
|
||||
.getCount();
|
||||
|
||||
const almacenesLlenos = await baseQuery()
|
||||
.andWhere('a.estado = :estado', { estado: 'lleno' })
|
||||
.getCount();
|
||||
|
||||
const almacenesMantenimiento = await baseQuery()
|
||||
.andWhere('a.estado = :estado', { estado: 'mantenimiento' })
|
||||
.getCount();
|
||||
|
||||
const capacidadResult = await baseQuery()
|
||||
.select('SUM(a.capacidad_m3)', 'total')
|
||||
.getRawOne();
|
||||
const capacidadTotalM3 = parseFloat(capacidadResult?.total || '0');
|
||||
|
||||
// Calculate total occupation
|
||||
const almacenes = await this.findAllConOcupacion(ctx, fraccionamientoId);
|
||||
const ocupacionTotalM3 = almacenes.reduce((sum, a) => sum + a.ocupacionActual, 0);
|
||||
const porcentajeOcupacionGlobal = capacidadTotalM3 > 0
|
||||
? Math.round((ocupacionTotalM3 / capacidadTotalM3) * 100)
|
||||
: 0;
|
||||
|
||||
const almacenesSinContencion = await baseQuery()
|
||||
.andWhere('a.tiene_contencion = false')
|
||||
.getCount();
|
||||
|
||||
const almacenesSinTecho = await baseQuery()
|
||||
.andWhere('a.tiene_techo = false')
|
||||
.getCount();
|
||||
|
||||
const almacenesSinSenalizacion = await baseQuery()
|
||||
.andWhere('a.senalizacion_ok = false')
|
||||
.getCount();
|
||||
|
||||
return {
|
||||
totalAlmacenes,
|
||||
almacenesOperativos,
|
||||
almacenesLlenos,
|
||||
almacenesMantenimiento,
|
||||
capacidadTotalM3,
|
||||
ocupacionTotalM3,
|
||||
porcentajeOcupacionGlobal,
|
||||
almacenesSinContencion,
|
||||
almacenesSinTecho,
|
||||
almacenesSinSenalizacion,
|
||||
};
|
||||
}
|
||||
}
|
||||
585
src/modules/hse/services/auditoria-ambiental.service.ts
Normal file
585
src/modules/hse/services/auditoria-ambiental.service.ts
Normal file
@ -0,0 +1,585 @@
|
||||
/**
|
||||
* AuditoriaAmbientalService - Servicio para gestión de auditorías ambientales
|
||||
*
|
||||
* RF-MAA017-006: Gestión de auditorías ambientales con programación,
|
||||
* seguimiento de hallazgos y cumplimiento normativo.
|
||||
*
|
||||
* @module HSE
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { Auditoria, TipoAuditoria, ResultadoAuditoria } from '../entities/auditoria.entity';
|
||||
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||
|
||||
export interface AuditoriaAmbientalFilters {
|
||||
fraccionamientoId?: string;
|
||||
tipo?: TipoAuditoria;
|
||||
resultado?: ResultadoAuditoria;
|
||||
pendiente?: boolean;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
anio?: number;
|
||||
}
|
||||
|
||||
export interface CreateAuditoriaAmbientalDto {
|
||||
fraccionamientoId: string;
|
||||
tipo: TipoAuditoria;
|
||||
fechaProgramada: Date;
|
||||
auditor?: string;
|
||||
observaciones?: string;
|
||||
}
|
||||
|
||||
export interface UpdateAuditoriaAmbientalDto {
|
||||
tipo?: TipoAuditoria;
|
||||
fechaProgramada?: Date;
|
||||
auditor?: string;
|
||||
observaciones?: string;
|
||||
}
|
||||
|
||||
export interface CompletarAuditoriaDto {
|
||||
resultado: ResultadoAuditoria;
|
||||
noConformidades: number;
|
||||
observaciones?: string;
|
||||
informeUrl?: string;
|
||||
}
|
||||
|
||||
export interface ProgramarAuditoriaSerieDto {
|
||||
fraccionamientoId: string;
|
||||
tipo: TipoAuditoria;
|
||||
fechaInicio: Date;
|
||||
periodicidadMeses: number;
|
||||
cantidad: number;
|
||||
auditor?: string;
|
||||
}
|
||||
|
||||
export interface AuditoriaAmbientalStats {
|
||||
totalAuditorias: number;
|
||||
auditoriasRealizadas: number;
|
||||
auditoriasPendientes: number;
|
||||
auditoriasVencidas: number;
|
||||
porResultado: { resultado: string; count: number }[];
|
||||
porTipo: { tipo: string; count: number }[];
|
||||
totalNoConformidades: number;
|
||||
promedioNoConformidades: number;
|
||||
tendenciaMensual: { mes: string; realizadas: number; noConformidades: number }[];
|
||||
}
|
||||
|
||||
export interface CalendarioAuditoria {
|
||||
mes: string;
|
||||
auditorias: {
|
||||
id: string;
|
||||
tipo: TipoAuditoria;
|
||||
fechaProgramada: Date;
|
||||
fraccionamientoNombre: string;
|
||||
auditor: string | null;
|
||||
estado: 'programada' | 'realizada' | 'vencida';
|
||||
resultado?: ResultadoAuditoria;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface AlertaAuditoria {
|
||||
auditoriaId: string;
|
||||
tipo: TipoAuditoria;
|
||||
fraccionamientoNombre: string;
|
||||
tipoAlerta: 'vencida' | 'proxima' | 'sin_auditor';
|
||||
mensaje: string;
|
||||
diasRestantes?: number;
|
||||
nivel: 'advertencia' | 'critico';
|
||||
}
|
||||
|
||||
export class AuditoriaAmbientalService {
|
||||
constructor(
|
||||
private readonly auditoriaRepository: Repository<Auditoria>
|
||||
) {}
|
||||
|
||||
// ========== CRUD de Auditorías ==========
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters: AuditoriaAmbientalFilters = {},
|
||||
page: number = 1,
|
||||
limit: number = 20
|
||||
): Promise<PaginatedResult<Auditoria>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.auditoriaRepository
|
||||
.createQueryBuilder('a')
|
||||
.leftJoinAndSelect('a.fraccionamiento', 'fraccionamiento')
|
||||
.where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (filters.fraccionamientoId) {
|
||||
queryBuilder.andWhere('a.fraccionamiento_id = :fraccionamientoId', {
|
||||
fraccionamientoId: filters.fraccionamientoId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.tipo) {
|
||||
queryBuilder.andWhere('a.tipo = :tipo', { tipo: filters.tipo });
|
||||
}
|
||||
|
||||
if (filters.resultado) {
|
||||
queryBuilder.andWhere('a.resultado = :resultado', { resultado: filters.resultado });
|
||||
}
|
||||
|
||||
if (filters.pendiente === true) {
|
||||
queryBuilder.andWhere('a.resultado IS NULL');
|
||||
} else if (filters.pendiente === false) {
|
||||
queryBuilder.andWhere('a.resultado IS NOT NULL');
|
||||
}
|
||||
|
||||
if (filters.dateFrom) {
|
||||
queryBuilder.andWhere('a.fecha_programada >= :dateFrom', { dateFrom: filters.dateFrom });
|
||||
}
|
||||
|
||||
if (filters.dateTo) {
|
||||
queryBuilder.andWhere('a.fecha_programada <= :dateTo', { dateTo: filters.dateTo });
|
||||
}
|
||||
|
||||
if (filters.anio) {
|
||||
queryBuilder.andWhere('EXTRACT(YEAR FROM a.fecha_programada) = :anio', { anio: filters.anio });
|
||||
}
|
||||
|
||||
queryBuilder
|
||||
.orderBy('a.fecha_programada', 'DESC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async findById(ctx: ServiceContext, id: string): Promise<Auditoria | null> {
|
||||
return this.auditoriaRepository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<Auditoria>,
|
||||
relations: ['fraccionamiento'],
|
||||
});
|
||||
}
|
||||
|
||||
async findPendientes(ctx: ServiceContext, fraccionamientoId?: string): Promise<Auditoria[]> {
|
||||
const queryBuilder = this.auditoriaRepository
|
||||
.createQueryBuilder('a')
|
||||
.leftJoinAndSelect('a.fraccionamiento', 'fraccionamiento')
|
||||
.where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('a.resultado IS NULL');
|
||||
|
||||
if (fraccionamientoId) {
|
||||
queryBuilder.andWhere('a.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
|
||||
}
|
||||
|
||||
return queryBuilder.orderBy('a.fecha_programada', 'ASC').getMany();
|
||||
}
|
||||
|
||||
async findVencidas(ctx: ServiceContext): Promise<Auditoria[]> {
|
||||
const today = new Date();
|
||||
return this.auditoriaRepository
|
||||
.createQueryBuilder('a')
|
||||
.leftJoinAndSelect('a.fraccionamiento', 'fraccionamiento')
|
||||
.where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('a.resultado IS NULL')
|
||||
.andWhere('a.fecha_programada < :today', { today })
|
||||
.orderBy('a.fecha_programada', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async create(ctx: ServiceContext, dto: CreateAuditoriaAmbientalDto): Promise<Auditoria> {
|
||||
const auditoria = this.auditoriaRepository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
fraccionamientoId: dto.fraccionamientoId,
|
||||
tipo: dto.tipo,
|
||||
fechaProgramada: dto.fechaProgramada,
|
||||
auditor: dto.auditor,
|
||||
observaciones: dto.observaciones,
|
||||
});
|
||||
|
||||
return this.auditoriaRepository.save(auditoria);
|
||||
}
|
||||
|
||||
async update(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
dto: UpdateAuditoriaAmbientalDto
|
||||
): Promise<Auditoria | null> {
|
||||
const auditoria = await this.findById(ctx, id);
|
||||
if (!auditoria) return null;
|
||||
|
||||
if (auditoria.resultado) {
|
||||
throw new Error('No se puede modificar una auditoria ya completada');
|
||||
}
|
||||
|
||||
if (dto.tipo !== undefined) auditoria.tipo = dto.tipo;
|
||||
if (dto.fechaProgramada !== undefined) auditoria.fechaProgramada = dto.fechaProgramada;
|
||||
if (dto.auditor !== undefined) auditoria.auditor = dto.auditor;
|
||||
if (dto.observaciones !== undefined) auditoria.observaciones = dto.observaciones;
|
||||
|
||||
return this.auditoriaRepository.save(auditoria);
|
||||
}
|
||||
|
||||
async delete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const auditoria = await this.findById(ctx, id);
|
||||
if (!auditoria) return false;
|
||||
|
||||
if (auditoria.resultado) {
|
||||
throw new Error('No se puede eliminar una auditoria ya completada');
|
||||
}
|
||||
|
||||
const result = await this.auditoriaRepository.delete({
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
} as FindOptionsWhere<Auditoria>);
|
||||
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
// ========== Completar Auditoría ==========
|
||||
async completar(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
dto: CompletarAuditoriaDto
|
||||
): Promise<Auditoria | null> {
|
||||
const auditoria = await this.findById(ctx, id);
|
||||
if (!auditoria) return null;
|
||||
|
||||
if (auditoria.resultado) {
|
||||
throw new Error('La auditoria ya fue completada');
|
||||
}
|
||||
|
||||
auditoria.fechaRealizada = new Date();
|
||||
auditoria.resultado = dto.resultado;
|
||||
auditoria.noConformidades = dto.noConformidades;
|
||||
if (dto.observaciones) auditoria.observaciones = dto.observaciones;
|
||||
if (dto.informeUrl) auditoria.informeUrl = dto.informeUrl;
|
||||
|
||||
return this.auditoriaRepository.save(auditoria);
|
||||
}
|
||||
|
||||
async asignarAuditor(ctx: ServiceContext, id: string, auditor: string): Promise<Auditoria | null> {
|
||||
const auditoria = await this.findById(ctx, id);
|
||||
if (!auditoria) return null;
|
||||
|
||||
auditoria.auditor = auditor;
|
||||
return this.auditoriaRepository.save(auditoria);
|
||||
}
|
||||
|
||||
async reprogramar(ctx: ServiceContext, id: string, nuevaFecha: Date): Promise<Auditoria | null> {
|
||||
const auditoria = await this.findById(ctx, id);
|
||||
if (!auditoria) return null;
|
||||
|
||||
if (auditoria.resultado) {
|
||||
throw new Error('No se puede reprogramar una auditoria ya completada');
|
||||
}
|
||||
|
||||
auditoria.fechaProgramada = nuevaFecha;
|
||||
return this.auditoriaRepository.save(auditoria);
|
||||
}
|
||||
|
||||
// ========== Programación en Serie ==========
|
||||
async programarSerie(ctx: ServiceContext, dto: ProgramarAuditoriaSerieDto): Promise<Auditoria[]> {
|
||||
const auditorias: Auditoria[] = [];
|
||||
let fecha = new Date(dto.fechaInicio);
|
||||
|
||||
for (let i = 0; i < dto.cantidad; i++) {
|
||||
const auditoria = this.auditoriaRepository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
fraccionamientoId: dto.fraccionamientoId,
|
||||
tipo: dto.tipo,
|
||||
fechaProgramada: new Date(fecha),
|
||||
auditor: dto.auditor,
|
||||
});
|
||||
|
||||
const saved = await this.auditoriaRepository.save(auditoria);
|
||||
auditorias.push(saved);
|
||||
|
||||
// Advance to next date
|
||||
fecha.setMonth(fecha.getMonth() + dto.periodicidadMeses);
|
||||
}
|
||||
|
||||
return auditorias;
|
||||
}
|
||||
|
||||
// ========== Calendario ==========
|
||||
async getCalendario(ctx: ServiceContext, anio: number, fraccionamientoId?: string): Promise<CalendarioAuditoria[]> {
|
||||
const queryBuilder = this.auditoriaRepository
|
||||
.createQueryBuilder('a')
|
||||
.leftJoinAndSelect('a.fraccionamiento', 'fraccionamiento')
|
||||
.where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('EXTRACT(YEAR FROM a.fecha_programada) = :anio', { anio });
|
||||
|
||||
if (fraccionamientoId) {
|
||||
queryBuilder.andWhere('a.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
|
||||
}
|
||||
|
||||
const auditorias = await queryBuilder.orderBy('a.fecha_programada', 'ASC').getMany();
|
||||
|
||||
const today = new Date();
|
||||
const calendario: CalendarioAuditoria[] = [];
|
||||
|
||||
// Group by month
|
||||
const meses: Record<string, CalendarioAuditoria['auditorias']> = {};
|
||||
|
||||
for (const audit of auditorias) {
|
||||
const fecha = new Date(audit.fechaProgramada);
|
||||
const mesKey = `${fecha.getFullYear()}-${String(fecha.getMonth() + 1).padStart(2, '0')}`;
|
||||
|
||||
if (!meses[mesKey]) {
|
||||
meses[mesKey] = [];
|
||||
}
|
||||
|
||||
let estado: 'programada' | 'realizada' | 'vencida' = 'programada';
|
||||
if (audit.resultado) {
|
||||
estado = 'realizada';
|
||||
} else if (fecha < today) {
|
||||
estado = 'vencida';
|
||||
}
|
||||
|
||||
meses[mesKey].push({
|
||||
id: audit.id,
|
||||
tipo: audit.tipo,
|
||||
fechaProgramada: audit.fechaProgramada,
|
||||
fraccionamientoNombre: audit.fraccionamiento?.nombre || 'Sin asignar',
|
||||
auditor: audit.auditor,
|
||||
estado,
|
||||
resultado: audit.resultado,
|
||||
});
|
||||
}
|
||||
|
||||
// Convert to array
|
||||
for (const [mes, auditoriasDelMes] of Object.entries(meses)) {
|
||||
calendario.push({ mes, auditorias: auditoriasDelMes });
|
||||
}
|
||||
|
||||
return calendario;
|
||||
}
|
||||
|
||||
// ========== Alertas ==========
|
||||
async getAlertas(ctx: ServiceContext, diasProximo: number = 7): Promise<AlertaAuditoria[]> {
|
||||
const alertas: AlertaAuditoria[] = [];
|
||||
const today = new Date();
|
||||
const proximaFecha = new Date();
|
||||
proximaFecha.setDate(proximaFecha.getDate() + diasProximo);
|
||||
|
||||
const pendientes = await this.auditoriaRepository
|
||||
.createQueryBuilder('a')
|
||||
.leftJoinAndSelect('a.fraccionamiento', 'fraccionamiento')
|
||||
.where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('a.resultado IS NULL')
|
||||
.getMany();
|
||||
|
||||
for (const auditoria of pendientes) {
|
||||
const fecha = new Date(auditoria.fechaProgramada);
|
||||
const fracNombre = auditoria.fraccionamiento?.nombre || 'Sin asignar';
|
||||
|
||||
// Vencidas
|
||||
if (fecha < today) {
|
||||
const diasVencido = Math.floor((today.getTime() - fecha.getTime()) / (1000 * 60 * 60 * 24));
|
||||
alertas.push({
|
||||
auditoriaId: auditoria.id,
|
||||
tipo: auditoria.tipo,
|
||||
fraccionamientoNombre: fracNombre,
|
||||
tipoAlerta: 'vencida',
|
||||
mensaje: `Auditoria ${auditoria.tipo} vencida hace ${diasVencido} dias`,
|
||||
diasRestantes: -diasVencido,
|
||||
nivel: 'critico',
|
||||
});
|
||||
}
|
||||
// Próximas
|
||||
else if (fecha <= proximaFecha) {
|
||||
const diasRestantes = Math.floor((fecha.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||
alertas.push({
|
||||
auditoriaId: auditoria.id,
|
||||
tipo: auditoria.tipo,
|
||||
fraccionamientoNombre: fracNombre,
|
||||
tipoAlerta: 'proxima',
|
||||
mensaje: `Auditoria ${auditoria.tipo} programada en ${diasRestantes} dias`,
|
||||
diasRestantes,
|
||||
nivel: 'advertencia',
|
||||
});
|
||||
}
|
||||
|
||||
// Sin auditor
|
||||
if (!auditoria.auditor && fecha >= today) {
|
||||
alertas.push({
|
||||
auditoriaId: auditoria.id,
|
||||
tipo: auditoria.tipo,
|
||||
fraccionamientoNombre: fracNombre,
|
||||
tipoAlerta: 'sin_auditor',
|
||||
mensaje: `Auditoria ${auditoria.tipo} sin auditor asignado`,
|
||||
nivel: 'advertencia',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by urgency
|
||||
alertas.sort((a, b) => {
|
||||
if (a.nivel === 'critico' && b.nivel !== 'critico') return -1;
|
||||
if (a.nivel !== 'critico' && b.nivel === 'critico') return 1;
|
||||
return (a.diasRestantes ?? 999) - (b.diasRestantes ?? 999);
|
||||
});
|
||||
|
||||
return alertas;
|
||||
}
|
||||
|
||||
// ========== Estadísticas ==========
|
||||
async getStats(ctx: ServiceContext, fraccionamientoId?: string, anio?: number): Promise<AuditoriaAmbientalStats> {
|
||||
const baseQuery = () => {
|
||||
const qb = this.auditoriaRepository
|
||||
.createQueryBuilder('a')
|
||||
.where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (fraccionamientoId) {
|
||||
qb.andWhere('a.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
|
||||
}
|
||||
if (anio) {
|
||||
qb.andWhere('EXTRACT(YEAR FROM a.fecha_programada) = :anio', { anio });
|
||||
}
|
||||
return qb;
|
||||
};
|
||||
|
||||
const today = new Date();
|
||||
|
||||
const totalAuditorias = await baseQuery().getCount();
|
||||
|
||||
const auditoriasRealizadas = await baseQuery()
|
||||
.andWhere('a.resultado IS NOT NULL')
|
||||
.getCount();
|
||||
|
||||
const auditoriasPendientes = await baseQuery()
|
||||
.andWhere('a.resultado IS NULL')
|
||||
.andWhere('a.fecha_programada >= :today', { today })
|
||||
.getCount();
|
||||
|
||||
const auditoriasVencidas = await baseQuery()
|
||||
.andWhere('a.resultado IS NULL')
|
||||
.andWhere('a.fecha_programada < :today', { today })
|
||||
.getCount();
|
||||
|
||||
// Por resultado
|
||||
const porResultadoRaw = await baseQuery()
|
||||
.andWhere('a.resultado IS NOT NULL')
|
||||
.select('a.resultado', 'resultado')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.groupBy('a.resultado')
|
||||
.getRawMany();
|
||||
|
||||
const porResultado = porResultadoRaw.map((r) => ({
|
||||
resultado: r.resultado,
|
||||
count: parseInt(r.count, 10),
|
||||
}));
|
||||
|
||||
// Por tipo
|
||||
const porTipoRaw = await baseQuery()
|
||||
.select('a.tipo', 'tipo')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.groupBy('a.tipo')
|
||||
.getRawMany();
|
||||
|
||||
const porTipo = porTipoRaw.map((t) => ({
|
||||
tipo: t.tipo,
|
||||
count: parseInt(t.count, 10),
|
||||
}));
|
||||
|
||||
// No conformidades
|
||||
const noConfResult = await baseQuery()
|
||||
.andWhere('a.resultado IS NOT NULL')
|
||||
.select('SUM(a.no_conformidades)', 'total')
|
||||
.addSelect('AVG(a.no_conformidades)', 'promedio')
|
||||
.getRawOne();
|
||||
|
||||
const totalNoConformidades = parseInt(noConfResult?.total || '0', 10);
|
||||
const promedioNoConformidades = parseFloat(noConfResult?.promedio || '0');
|
||||
|
||||
// Tendencia mensual (últimos 12 meses)
|
||||
const doceMesesAtras = new Date();
|
||||
doceMesesAtras.setMonth(doceMesesAtras.getMonth() - 12);
|
||||
|
||||
const tendenciaRaw = await this.auditoriaRepository
|
||||
.createQueryBuilder('a')
|
||||
.where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('a.fecha_programada >= :doceMesesAtras', { doceMesesAtras })
|
||||
.andWhere('a.resultado IS NOT NULL')
|
||||
.select("TO_CHAR(a.fecha_realizada, 'YYYY-MM')", 'mes')
|
||||
.addSelect('COUNT(*)', 'realizadas')
|
||||
.addSelect('SUM(a.no_conformidades)', 'noConformidades')
|
||||
.groupBy("TO_CHAR(a.fecha_realizada, 'YYYY-MM')")
|
||||
.orderBy("TO_CHAR(a.fecha_realizada, 'YYYY-MM')", 'ASC')
|
||||
.getRawMany();
|
||||
|
||||
const tendenciaMensual = tendenciaRaw.map((t) => ({
|
||||
mes: t.mes,
|
||||
realizadas: parseInt(t.realizadas, 10),
|
||||
noConformidades: parseInt(t.noConformidades || '0', 10),
|
||||
}));
|
||||
|
||||
return {
|
||||
totalAuditorias,
|
||||
auditoriasRealizadas,
|
||||
auditoriasPendientes,
|
||||
auditoriasVencidas,
|
||||
porResultado,
|
||||
porTipo,
|
||||
totalNoConformidades,
|
||||
promedioNoConformidades: Math.round(promedioNoConformidades * 100) / 100,
|
||||
tendenciaMensual,
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Historial por Fraccionamiento ==========
|
||||
async getHistorialFraccionamiento(
|
||||
ctx: ServiceContext,
|
||||
fraccionamientoId: string
|
||||
): Promise<Auditoria[]> {
|
||||
return this.auditoriaRepository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
fraccionamientoId,
|
||||
} as FindOptionsWhere<Auditoria>,
|
||||
order: { fechaProgramada: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Comparativa entre Obras ==========
|
||||
async getComparativaObras(ctx: ServiceContext, anio: number): Promise<{
|
||||
fraccionamientoId: string;
|
||||
fraccionamientoNombre: string;
|
||||
totalAuditorias: number;
|
||||
aprobadas: number;
|
||||
noAprobadas: number;
|
||||
porcentajeAprobacion: number;
|
||||
totalNoConformidades: number;
|
||||
}[]> {
|
||||
const result = await this.auditoriaRepository
|
||||
.createQueryBuilder('a')
|
||||
.leftJoin('a.fraccionamiento', 'f')
|
||||
.where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('EXTRACT(YEAR FROM a.fecha_programada) = :anio', { anio })
|
||||
.andWhere('a.resultado IS NOT NULL')
|
||||
.select('a.fraccionamiento_id', 'fraccionamientoId')
|
||||
.addSelect('f.nombre', 'fraccionamientoNombre')
|
||||
.addSelect('COUNT(*)', 'totalAuditorias')
|
||||
.addSelect("SUM(CASE WHEN a.resultado IN ('aprobada', 'aprobada_observaciones') THEN 1 ELSE 0 END)", 'aprobadas')
|
||||
.addSelect("SUM(CASE WHEN a.resultado = 'no_aprobada' THEN 1 ELSE 0 END)", 'noAprobadas')
|
||||
.addSelect('SUM(a.no_conformidades)', 'totalNoConformidades')
|
||||
.groupBy('a.fraccionamiento_id')
|
||||
.addGroupBy('f.nombre')
|
||||
.getRawMany();
|
||||
|
||||
return result.map((r) => {
|
||||
const total = parseInt(r.totalAuditorias, 10);
|
||||
const aprobadas = parseInt(r.aprobadas, 10);
|
||||
return {
|
||||
fraccionamientoId: r.fraccionamientoId,
|
||||
fraccionamientoNombre: r.fraccionamientoNombre || 'Sin nombre',
|
||||
totalAuditorias: total,
|
||||
aprobadas,
|
||||
noAprobadas: parseInt(r.noAprobadas, 10),
|
||||
porcentajeAprobacion: total > 0 ? Math.round((aprobadas / total) * 100) : 0,
|
||||
totalNoConformidades: parseInt(r.totalNoConformidades || '0', 10),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
450
src/modules/hse/services/comision-seguridad.service.ts
Normal file
450
src/modules/hse/services/comision-seguridad.service.ts
Normal file
@ -0,0 +1,450 @@
|
||||
/**
|
||||
* ComisionSeguridadService - Servicio para comisiones de seguridad e higiene
|
||||
*
|
||||
* RF-MAA017-005: Cumplimiento STPS - Comisiones de Seguridad
|
||||
*
|
||||
* @module HSE
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { ComisionSeguridad, EstadoComision } from '../entities/comision-seguridad.entity';
|
||||
import { ComisionIntegrante, RolComision, Representacion } from '../entities/comision-integrante.entity';
|
||||
import { ComisionRecorrido } from '../entities/comision-recorrido.entity';
|
||||
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||
|
||||
export interface CreateComisionSeguridadDto {
|
||||
fraccionamientoId: string;
|
||||
fechaConstitucion: Date;
|
||||
numeroActa?: string;
|
||||
vigenciaInicio: Date;
|
||||
vigenciaFin: Date;
|
||||
documentoActaUrl?: string;
|
||||
}
|
||||
|
||||
export interface UpdateComisionSeguridadDto {
|
||||
numeroActa?: string;
|
||||
vigenciaInicio?: Date;
|
||||
vigenciaFin?: Date;
|
||||
documentoActaUrl?: string;
|
||||
}
|
||||
|
||||
export interface ComisionSeguridadFilters {
|
||||
fraccionamientoId?: string;
|
||||
estado?: EstadoComision;
|
||||
vigente?: boolean;
|
||||
}
|
||||
|
||||
export interface AddIntegranteComisionDto {
|
||||
employeeId: string;
|
||||
rol: RolComision;
|
||||
representacion: Representacion;
|
||||
fechaNombramiento: Date;
|
||||
}
|
||||
|
||||
export interface CreateRecorridoComisionDto {
|
||||
comisionId: string;
|
||||
fechaProgramada: Date;
|
||||
areasRecorridas?: string;
|
||||
}
|
||||
|
||||
export interface CompletarRecorridoDto {
|
||||
fechaRealizada?: Date;
|
||||
hallazgos?: string;
|
||||
recomendaciones?: string;
|
||||
numeroActa?: string;
|
||||
documentoActaUrl?: string;
|
||||
}
|
||||
|
||||
export interface ComisionStats {
|
||||
totalComisiones: number;
|
||||
comisionesActivas: number;
|
||||
comisionesVencidas: number;
|
||||
totalIntegrantes: number;
|
||||
recorridosProgramados: number;
|
||||
recorridosRealizados: number;
|
||||
recorridosPendientes: number;
|
||||
}
|
||||
|
||||
export type EstadoRecorrido = 'programado' | 'realizado' | 'cancelado' | 'pendiente';
|
||||
|
||||
export class ComisionSeguridadService {
|
||||
constructor(
|
||||
private readonly comisionRepository: Repository<ComisionSeguridad>,
|
||||
private readonly integranteRepository: Repository<ComisionIntegrante>,
|
||||
private readonly recorridoRepository: Repository<ComisionRecorrido>
|
||||
) {}
|
||||
|
||||
// ========== Comisiones ==========
|
||||
async findComisiones(
|
||||
ctx: ServiceContext,
|
||||
filters: ComisionSeguridadFilters = {},
|
||||
page: number = 1,
|
||||
limit: number = 20
|
||||
): Promise<PaginatedResult<ComisionSeguridad>> {
|
||||
const skip = (page - 1) * limit;
|
||||
const today = new Date();
|
||||
|
||||
const queryBuilder = this.comisionRepository
|
||||
.createQueryBuilder('c')
|
||||
.leftJoinAndSelect('c.fraccionamiento', 'fraccionamiento')
|
||||
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (filters.fraccionamientoId) {
|
||||
queryBuilder.andWhere('c.fraccionamiento_id = :fraccionamientoId', {
|
||||
fraccionamientoId: filters.fraccionamientoId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.estado) {
|
||||
queryBuilder.andWhere('c.estado = :estado', { estado: filters.estado });
|
||||
}
|
||||
|
||||
if (filters.vigente === true) {
|
||||
queryBuilder.andWhere('c.vigencia_fin >= :today', { today });
|
||||
queryBuilder.andWhere('c.estado = :estado', { estado: 'activa' });
|
||||
}
|
||||
|
||||
if (filters.vigente === false) {
|
||||
queryBuilder.andWhere('c.vigencia_fin < :today', { today });
|
||||
}
|
||||
|
||||
queryBuilder
|
||||
.orderBy('c.fecha_constitucion', 'DESC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async findById(ctx: ServiceContext, id: string): Promise<ComisionSeguridad | null> {
|
||||
return this.comisionRepository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<ComisionSeguridad>,
|
||||
relations: ['fraccionamiento', 'integrantes', 'integrantes.employee', 'recorridos'],
|
||||
});
|
||||
}
|
||||
|
||||
async findVigenteByFraccionamiento(ctx: ServiceContext, fraccionamientoId: string): Promise<ComisionSeguridad | null> {
|
||||
const today = new Date();
|
||||
|
||||
return this.comisionRepository
|
||||
.createQueryBuilder('c')
|
||||
.leftJoinAndSelect('c.integrantes', 'integrantes', 'integrantes.activo = true')
|
||||
.leftJoinAndSelect('integrantes.employee', 'employee')
|
||||
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('c.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId })
|
||||
.andWhere('c.estado = :estado', { estado: 'activa' })
|
||||
.andWhere('c.vigencia_fin >= :today', { today })
|
||||
.getOne();
|
||||
}
|
||||
|
||||
async create(ctx: ServiceContext, dto: CreateComisionSeguridadDto): Promise<ComisionSeguridad> {
|
||||
const existingActive = await this.findVigenteByFraccionamiento(ctx, dto.fraccionamientoId);
|
||||
if (existingActive) {
|
||||
throw new Error('Ya existe una comisión activa para este fraccionamiento');
|
||||
}
|
||||
|
||||
const comision = this.comisionRepository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
fraccionamientoId: dto.fraccionamientoId,
|
||||
fechaConstitucion: dto.fechaConstitucion,
|
||||
numeroActa: dto.numeroActa,
|
||||
vigenciaInicio: dto.vigenciaInicio,
|
||||
vigenciaFin: dto.vigenciaFin,
|
||||
documentoActaUrl: dto.documentoActaUrl,
|
||||
estado: 'activa',
|
||||
});
|
||||
|
||||
return this.comisionRepository.save(comision);
|
||||
}
|
||||
|
||||
async update(ctx: ServiceContext, id: string, dto: UpdateComisionSeguridadDto): Promise<ComisionSeguridad | null> {
|
||||
const comision = await this.findById(ctx, id);
|
||||
if (!comision) return null;
|
||||
|
||||
if (dto.numeroActa !== undefined) comision.numeroActa = dto.numeroActa;
|
||||
if (dto.vigenciaInicio) comision.vigenciaInicio = dto.vigenciaInicio;
|
||||
if (dto.vigenciaFin) comision.vigenciaFin = dto.vigenciaFin;
|
||||
if (dto.documentoActaUrl !== undefined) comision.documentoActaUrl = dto.documentoActaUrl;
|
||||
|
||||
return this.comisionRepository.save(comision);
|
||||
}
|
||||
|
||||
async updateEstado(ctx: ServiceContext, id: string, estado: EstadoComision): Promise<ComisionSeguridad | null> {
|
||||
const comision = await this.findById(ctx, id);
|
||||
if (!comision) return null;
|
||||
|
||||
comision.estado = estado;
|
||||
return this.comisionRepository.save(comision);
|
||||
}
|
||||
|
||||
async renovarComision(
|
||||
ctx: ServiceContext,
|
||||
comisionAnteriorId: string,
|
||||
dto: CreateComisionSeguridadDto
|
||||
): Promise<ComisionSeguridad> {
|
||||
const comisionAnterior = await this.findById(ctx, comisionAnteriorId);
|
||||
if (!comisionAnterior) {
|
||||
throw new Error('Comisión anterior no encontrada');
|
||||
}
|
||||
|
||||
comisionAnterior.estado = 'renovada';
|
||||
await this.comisionRepository.save(comisionAnterior);
|
||||
|
||||
return this.create(ctx, dto);
|
||||
}
|
||||
|
||||
// ========== Integrantes ==========
|
||||
async findIntegrantesByComision(comisionId: string, soloActivos: boolean = true): Promise<ComisionIntegrante[]> {
|
||||
const where: FindOptionsWhere<ComisionIntegrante> = { comisionId };
|
||||
if (soloActivos) {
|
||||
where.activo = true;
|
||||
}
|
||||
|
||||
return this.integranteRepository.find({
|
||||
where,
|
||||
relations: ['employee'],
|
||||
order: { rol: 'ASC', representacion: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async addIntegrante(comisionId: string, dto: AddIntegranteComisionDto): Promise<ComisionIntegrante> {
|
||||
const existing = await this.integranteRepository.findOne({
|
||||
where: {
|
||||
comisionId,
|
||||
employeeId: dto.employeeId,
|
||||
activo: true,
|
||||
} as FindOptionsWhere<ComisionIntegrante>,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error('El empleado ya es integrante activo de esta comisión');
|
||||
}
|
||||
|
||||
const integrante = this.integranteRepository.create({
|
||||
comisionId,
|
||||
employeeId: dto.employeeId,
|
||||
rol: dto.rol,
|
||||
representacion: dto.representacion,
|
||||
fechaNombramiento: dto.fechaNombramiento,
|
||||
activo: true,
|
||||
});
|
||||
|
||||
return this.integranteRepository.save(integrante);
|
||||
}
|
||||
|
||||
async updateIntegrante(
|
||||
integranteId: string,
|
||||
rol?: RolComision,
|
||||
representacion?: Representacion
|
||||
): Promise<ComisionIntegrante | null> {
|
||||
const integrante = await this.integranteRepository.findOne({
|
||||
where: { id: integranteId } as FindOptionsWhere<ComisionIntegrante>,
|
||||
});
|
||||
|
||||
if (!integrante) return null;
|
||||
|
||||
if (rol) integrante.rol = rol;
|
||||
if (representacion) integrante.representacion = representacion;
|
||||
|
||||
return this.integranteRepository.save(integrante);
|
||||
}
|
||||
|
||||
async bajaIntegrante(integranteId: string): Promise<ComisionIntegrante | null> {
|
||||
const integrante = await this.integranteRepository.findOne({
|
||||
where: { id: integranteId } as FindOptionsWhere<ComisionIntegrante>,
|
||||
});
|
||||
|
||||
if (!integrante) return null;
|
||||
|
||||
integrante.activo = false;
|
||||
|
||||
return this.integranteRepository.save(integrante);
|
||||
}
|
||||
|
||||
// ========== Recorridos ==========
|
||||
async findRecorridosByComision(comisionId: string): Promise<ComisionRecorrido[]> {
|
||||
return this.recorridoRepository.find({
|
||||
where: { comisionId } as FindOptionsWhere<ComisionRecorrido>,
|
||||
order: { fechaProgramada: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findRecorridoById(id: string): Promise<ComisionRecorrido | null> {
|
||||
return this.recorridoRepository.findOne({
|
||||
where: { id } as FindOptionsWhere<ComisionRecorrido>,
|
||||
relations: ['comision', 'comision.fraccionamiento'],
|
||||
});
|
||||
}
|
||||
|
||||
async createRecorrido(dto: CreateRecorridoComisionDto): Promise<ComisionRecorrido> {
|
||||
const recorrido = this.recorridoRepository.create({
|
||||
comisionId: dto.comisionId,
|
||||
fechaProgramada: dto.fechaProgramada,
|
||||
areasRecorridas: dto.areasRecorridas,
|
||||
estado: 'programado',
|
||||
});
|
||||
|
||||
return this.recorridoRepository.save(recorrido);
|
||||
}
|
||||
|
||||
async completarRecorrido(id: string, dto: CompletarRecorridoDto): Promise<ComisionRecorrido | null> {
|
||||
const recorrido = await this.findRecorridoById(id);
|
||||
if (!recorrido) return null;
|
||||
|
||||
recorrido.fechaRealizada = dto.fechaRealizada || new Date();
|
||||
if (dto.hallazgos) recorrido.hallazgos = dto.hallazgos;
|
||||
if (dto.recomendaciones) recorrido.recomendaciones = dto.recomendaciones;
|
||||
if (dto.numeroActa) recorrido.numeroActa = dto.numeroActa;
|
||||
if (dto.documentoActaUrl) recorrido.documentoActaUrl = dto.documentoActaUrl;
|
||||
recorrido.estado = 'realizado';
|
||||
|
||||
return this.recorridoRepository.save(recorrido);
|
||||
}
|
||||
|
||||
async cancelarRecorrido(id: string): Promise<ComisionRecorrido | null> {
|
||||
const recorrido = await this.findRecorridoById(id);
|
||||
if (!recorrido) return null;
|
||||
|
||||
if (recorrido.estado === 'realizado') {
|
||||
throw new Error('No se puede cancelar un recorrido ya realizado');
|
||||
}
|
||||
|
||||
recorrido.estado = 'cancelado';
|
||||
return this.recorridoRepository.save(recorrido);
|
||||
}
|
||||
|
||||
async getRecorridosPendientes(ctx: ServiceContext, fraccionamientoId?: string): Promise<ComisionRecorrido[]> {
|
||||
const today = new Date();
|
||||
|
||||
const queryBuilder = this.recorridoRepository
|
||||
.createQueryBuilder('r')
|
||||
.innerJoinAndSelect('r.comision', 'comision')
|
||||
.leftJoinAndSelect('comision.fraccionamiento', 'fraccionamiento')
|
||||
.where('comision.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('r.estado IN (:...estados)', { estados: ['programado', 'pendiente'] })
|
||||
.andWhere('r.fecha_programada <= :today', { today });
|
||||
|
||||
if (fraccionamientoId) {
|
||||
queryBuilder.andWhere('comision.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
|
||||
}
|
||||
|
||||
return queryBuilder.orderBy('r.fecha_programada', 'ASC').getMany();
|
||||
}
|
||||
|
||||
async getRecorridosProximos(ctx: ServiceContext, dias: number = 30): Promise<ComisionRecorrido[]> {
|
||||
const today = new Date();
|
||||
const fechaLimite = new Date();
|
||||
fechaLimite.setDate(fechaLimite.getDate() + dias);
|
||||
|
||||
return this.recorridoRepository
|
||||
.createQueryBuilder('r')
|
||||
.innerJoinAndSelect('r.comision', 'comision')
|
||||
.leftJoinAndSelect('comision.fraccionamiento', 'fraccionamiento')
|
||||
.where('comision.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('r.estado = :estado', { estado: 'programado' })
|
||||
.andWhere('r.fecha_programada >= :today', { today })
|
||||
.andWhere('r.fecha_programada <= :fechaLimite', { fechaLimite })
|
||||
.orderBy('r.fecha_programada', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
// ========== Verificar Vigencia ==========
|
||||
async verificarYActualizarVigencias(ctx: ServiceContext): Promise<number> {
|
||||
const today = new Date();
|
||||
|
||||
const comisionesVencidas = await this.comisionRepository
|
||||
.createQueryBuilder('c')
|
||||
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('c.estado = :estado', { estado: 'activa' })
|
||||
.andWhere('c.vigencia_fin < :today', { today })
|
||||
.getMany();
|
||||
|
||||
for (const comision of comisionesVencidas) {
|
||||
comision.estado = 'vencida';
|
||||
await this.comisionRepository.save(comision);
|
||||
}
|
||||
|
||||
return comisionesVencidas.length;
|
||||
}
|
||||
|
||||
// ========== Estadísticas ==========
|
||||
async getStats(ctx: ServiceContext, fraccionamientoId?: string): Promise<ComisionStats> {
|
||||
const today = new Date();
|
||||
|
||||
const comisionQuery = this.comisionRepository
|
||||
.createQueryBuilder('c')
|
||||
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (fraccionamientoId) {
|
||||
comisionQuery.andWhere('c.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
|
||||
}
|
||||
|
||||
const totalComisiones = await comisionQuery.getCount();
|
||||
|
||||
const comisionesActivas = await this.comisionRepository
|
||||
.createQueryBuilder('c')
|
||||
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('c.estado = :estado', { estado: 'activa' })
|
||||
.andWhere('c.vigencia_fin >= :today', { today })
|
||||
.andWhere(fraccionamientoId ? 'c.fraccionamiento_id = :fraccionamientoId' : '1=1', { fraccionamientoId })
|
||||
.getCount();
|
||||
|
||||
const comisionesVencidas = await this.comisionRepository
|
||||
.createQueryBuilder('c')
|
||||
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('c.estado = :estado', { estado: 'vencida' })
|
||||
.andWhere(fraccionamientoId ? 'c.fraccionamiento_id = :fraccionamientoId' : '1=1', { fraccionamientoId })
|
||||
.getCount();
|
||||
|
||||
const totalIntegrantes = await this.integranteRepository
|
||||
.createQueryBuilder('i')
|
||||
.innerJoin('i.comision', 'c')
|
||||
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('i.activo = true')
|
||||
.andWhere(fraccionamientoId ? 'c.fraccionamiento_id = :fraccionamientoId' : '1=1', { fraccionamientoId })
|
||||
.getCount();
|
||||
|
||||
const recorridosQuery = this.recorridoRepository
|
||||
.createQueryBuilder('r')
|
||||
.innerJoin('r.comision', 'c')
|
||||
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (fraccionamientoId) {
|
||||
recorridosQuery.andWhere('c.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
|
||||
}
|
||||
|
||||
const recorridosProgramados = await recorridosQuery
|
||||
.clone()
|
||||
.andWhere('r.estado = :estado', { estado: 'programado' })
|
||||
.getCount();
|
||||
|
||||
const recorridosRealizados = await recorridosQuery
|
||||
.clone()
|
||||
.andWhere('r.estado = :estado', { estado: 'realizado' })
|
||||
.getCount();
|
||||
|
||||
const recorridosPendientes = await recorridosQuery
|
||||
.clone()
|
||||
.andWhere('r.estado IN (:...estados)', { estados: ['programado', 'pendiente'] })
|
||||
.andWhere('r.fecha_programada < :today', { today })
|
||||
.getCount();
|
||||
|
||||
return {
|
||||
totalComisiones,
|
||||
comisionesActivas,
|
||||
comisionesVencidas,
|
||||
totalIntegrantes,
|
||||
recorridosProgramados,
|
||||
recorridosRealizados,
|
||||
recorridosPendientes,
|
||||
};
|
||||
}
|
||||
}
|
||||
298
src/modules/hse/services/constancia-dc3.service.ts
Normal file
298
src/modules/hse/services/constancia-dc3.service.ts
Normal file
@ -0,0 +1,298 @@
|
||||
/**
|
||||
* ConstanciaDc3Service - Servicio para gestión de constancias DC-3
|
||||
*
|
||||
* RF-MAA017-002: Control de Capacitaciones - Constancias DC-3 STPS
|
||||
*
|
||||
* @module HSE
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere, LessThan } from 'typeorm';
|
||||
import { ConstanciaDc3 } from '../entities/constancia-dc3.entity';
|
||||
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||
|
||||
export interface CreateConstanciaDc3Dto {
|
||||
folio: string;
|
||||
asistenteId: string;
|
||||
employeeId: string;
|
||||
capacitacionId: string;
|
||||
fechaEmision: Date;
|
||||
fechaVencimiento?: Date;
|
||||
documentoUrl?: string;
|
||||
}
|
||||
|
||||
export interface ConstanciaDc3Filters {
|
||||
employeeId?: string;
|
||||
capacitacionId?: string;
|
||||
vigente?: boolean;
|
||||
vencidaProximamente?: boolean;
|
||||
diasProximoVencimiento?: number;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface ConstanciaDc3Stats {
|
||||
totalConstancias: number;
|
||||
vigentes: number;
|
||||
vencidas: number;
|
||||
porVencer: number;
|
||||
sinVencimiento: number;
|
||||
}
|
||||
|
||||
export interface EmpleadoCapacitacionStatus {
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
capacitacionId: string;
|
||||
capacitacionNombre: string;
|
||||
constanciaId?: string;
|
||||
folio?: string;
|
||||
fechaEmision?: Date;
|
||||
fechaVencimiento?: Date;
|
||||
estado: 'vigente' | 'vencida' | 'por_vencer' | 'sin_constancia';
|
||||
}
|
||||
|
||||
export class ConstanciaDc3Service {
|
||||
constructor(private readonly repository: Repository<ConstanciaDc3>) {}
|
||||
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters: ConstanciaDc3Filters = {},
|
||||
page: number = 1,
|
||||
limit: number = 20
|
||||
): Promise<PaginatedResult<ConstanciaDc3>> {
|
||||
const skip = (page - 1) * limit;
|
||||
const today = new Date();
|
||||
|
||||
const queryBuilder = this.repository
|
||||
.createQueryBuilder('constancia')
|
||||
.leftJoinAndSelect('constancia.employee', 'employee')
|
||||
.leftJoinAndSelect('constancia.capacitacion', 'capacitacion')
|
||||
.leftJoinAndSelect('constancia.asistente', 'asistente')
|
||||
.where('constancia.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (filters.employeeId) {
|
||||
queryBuilder.andWhere('constancia.employee_id = :employeeId', {
|
||||
employeeId: filters.employeeId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.capacitacionId) {
|
||||
queryBuilder.andWhere('constancia.capacitacion_id = :capacitacionId', {
|
||||
capacitacionId: filters.capacitacionId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.vigente === true) {
|
||||
queryBuilder.andWhere(
|
||||
'(constancia.fecha_vencimiento IS NULL OR constancia.fecha_vencimiento >= :today)',
|
||||
{ today }
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.vigente === false) {
|
||||
queryBuilder.andWhere('constancia.fecha_vencimiento < :today', { today });
|
||||
}
|
||||
|
||||
if (filters.vencidaProximamente) {
|
||||
const diasLimite = filters.diasProximoVencimiento || 30;
|
||||
const fechaLimite = new Date();
|
||||
fechaLimite.setDate(fechaLimite.getDate() + diasLimite);
|
||||
queryBuilder.andWhere(
|
||||
'constancia.fecha_vencimiento >= :today AND constancia.fecha_vencimiento <= :fechaLimite',
|
||||
{ today, fechaLimite }
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.dateFrom) {
|
||||
queryBuilder.andWhere('constancia.fecha_emision >= :dateFrom', { dateFrom: filters.dateFrom });
|
||||
}
|
||||
|
||||
if (filters.dateTo) {
|
||||
queryBuilder.andWhere('constancia.fecha_emision <= :dateTo', { dateTo: filters.dateTo });
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
queryBuilder.andWhere(
|
||||
'(constancia.folio ILIKE :search OR employee.nombres ILIKE :search OR employee.apellido_paterno ILIKE :search)',
|
||||
{ search: `%${filters.search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
queryBuilder
|
||||
.orderBy('constancia.fecha_emision', 'DESC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async findById(ctx: ServiceContext, id: string): Promise<ConstanciaDc3 | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<ConstanciaDc3>,
|
||||
relations: ['employee', 'capacitacion', 'asistente'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByFolio(ctx: ServiceContext, folio: string): Promise<ConstanciaDc3 | null> {
|
||||
return this.repository.findOne({
|
||||
where: { tenantId: ctx.tenantId, folio } as FindOptionsWhere<ConstanciaDc3>,
|
||||
relations: ['employee', 'capacitacion'],
|
||||
});
|
||||
}
|
||||
|
||||
async create(ctx: ServiceContext, dto: CreateConstanciaDc3Dto): Promise<ConstanciaDc3> {
|
||||
const existingFolio = await this.findByFolio(ctx, dto.folio);
|
||||
if (existingFolio) {
|
||||
throw new Error(`Ya existe una constancia DC-3 con el folio ${dto.folio}`);
|
||||
}
|
||||
|
||||
const constancia = this.repository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
folio: dto.folio,
|
||||
asistenteId: dto.asistenteId,
|
||||
employeeId: dto.employeeId,
|
||||
capacitacionId: dto.capacitacionId,
|
||||
fechaEmision: dto.fechaEmision,
|
||||
fechaVencimiento: dto.fechaVencimiento,
|
||||
documentoUrl: dto.documentoUrl,
|
||||
});
|
||||
|
||||
return this.repository.save(constancia);
|
||||
}
|
||||
|
||||
async updateDocumentoUrl(ctx: ServiceContext, id: string, documentoUrl: string): Promise<ConstanciaDc3 | null> {
|
||||
const constancia = await this.findById(ctx, id);
|
||||
if (!constancia) return null;
|
||||
|
||||
constancia.documentoUrl = documentoUrl;
|
||||
return this.repository.save(constancia);
|
||||
}
|
||||
|
||||
async findByEmployee(ctx: ServiceContext, employeeId: string): Promise<ConstanciaDc3[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId: ctx.tenantId, employeeId } as FindOptionsWhere<ConstanciaDc3>,
|
||||
relations: ['capacitacion'],
|
||||
order: { fechaEmision: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findVigenteByEmployeeAndCapacitacion(
|
||||
ctx: ServiceContext,
|
||||
employeeId: string,
|
||||
capacitacionId: string
|
||||
): Promise<ConstanciaDc3 | null> {
|
||||
const today = new Date();
|
||||
|
||||
return this.repository
|
||||
.createQueryBuilder('c')
|
||||
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('c.employee_id = :employeeId', { employeeId })
|
||||
.andWhere('c.capacitacion_id = :capacitacionId', { capacitacionId })
|
||||
.andWhere('(c.fecha_vencimiento IS NULL OR c.fecha_vencimiento >= :today)', { today })
|
||||
.orderBy('c.fecha_emision', 'DESC')
|
||||
.getOne();
|
||||
}
|
||||
|
||||
async getConstanciasVencidas(ctx: ServiceContext): Promise<ConstanciaDc3[]> {
|
||||
const today = new Date();
|
||||
|
||||
return this.repository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
fechaVencimiento: LessThan(today),
|
||||
} as FindOptionsWhere<ConstanciaDc3>,
|
||||
relations: ['employee', 'capacitacion'],
|
||||
order: { fechaVencimiento: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async getConstanciasPorVencer(ctx: ServiceContext, dias: number = 30): Promise<ConstanciaDc3[]> {
|
||||
const today = new Date();
|
||||
const fechaLimite = new Date();
|
||||
fechaLimite.setDate(fechaLimite.getDate() + dias);
|
||||
|
||||
return this.repository
|
||||
.createQueryBuilder('c')
|
||||
.leftJoinAndSelect('c.employee', 'employee')
|
||||
.leftJoinAndSelect('c.capacitacion', 'capacitacion')
|
||||
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('c.fecha_vencimiento >= :today', { today })
|
||||
.andWhere('c.fecha_vencimiento <= :fechaLimite', { fechaLimite })
|
||||
.orderBy('c.fecha_vencimiento', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async getStats(ctx: ServiceContext): Promise<ConstanciaDc3Stats> {
|
||||
const today = new Date();
|
||||
const treintaDias = new Date();
|
||||
treintaDias.setDate(treintaDias.getDate() + 30);
|
||||
|
||||
const totalConstancias = await this.repository.count({
|
||||
where: { tenantId: ctx.tenantId } as FindOptionsWhere<ConstanciaDc3>,
|
||||
});
|
||||
|
||||
const vigentes = await this.repository
|
||||
.createQueryBuilder('c')
|
||||
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('(c.fecha_vencimiento IS NULL OR c.fecha_vencimiento >= :today)', { today })
|
||||
.getCount();
|
||||
|
||||
const vencidas = await this.repository.count({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
fechaVencimiento: LessThan(today),
|
||||
} as FindOptionsWhere<ConstanciaDc3>,
|
||||
});
|
||||
|
||||
const porVencer = await this.repository
|
||||
.createQueryBuilder('c')
|
||||
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('c.fecha_vencimiento >= :today', { today })
|
||||
.andWhere('c.fecha_vencimiento <= :treintaDias', { treintaDias })
|
||||
.getCount();
|
||||
|
||||
const sinVencimiento = await this.repository
|
||||
.createQueryBuilder('c')
|
||||
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('c.fecha_vencimiento IS NULL')
|
||||
.getCount();
|
||||
|
||||
return {
|
||||
totalConstancias,
|
||||
vigentes,
|
||||
vencidas,
|
||||
porVencer,
|
||||
sinVencimiento,
|
||||
};
|
||||
}
|
||||
|
||||
async generateFolio(ctx: ServiceContext, year?: number): Promise<string> {
|
||||
const anio = year || new Date().getFullYear();
|
||||
const prefix = `DC3-${anio}`;
|
||||
|
||||
const lastConstancia = await this.repository
|
||||
.createQueryBuilder('c')
|
||||
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('c.folio LIKE :prefix', { prefix: `${prefix}%` })
|
||||
.orderBy('c.folio', 'DESC')
|
||||
.getOne();
|
||||
|
||||
let nextNumber = 1;
|
||||
if (lastConstancia) {
|
||||
const parts = lastConstancia.folio.split('-');
|
||||
if (parts.length >= 3) {
|
||||
nextNumber = parseInt(parts[2], 10) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return `${prefix}-${nextNumber.toString().padStart(5, '0')}`;
|
||||
}
|
||||
}
|
||||
378
src/modules/hse/services/cumplimiento.service.ts
Normal file
378
src/modules/hse/services/cumplimiento.service.ts
Normal file
@ -0,0 +1,378 @@
|
||||
/**
|
||||
* CumplimientoService - Servicio para cumplimiento normativo por obra
|
||||
*
|
||||
* RF-MAA017-005: Cumplimiento STPS - Verificación de cumplimiento
|
||||
*
|
||||
* @module HSE
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { CumplimientoObra, EstadoCumplimiento } from '../entities/cumplimiento-obra.entity';
|
||||
import { NormaStps } from '../entities/norma-stps.entity';
|
||||
import { NormaRequisito } from '../entities/norma-requisito.entity';
|
||||
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||
|
||||
export interface CreateCumplimientoObraDto {
|
||||
fraccionamientoId: string;
|
||||
normaId: string;
|
||||
requisitoId?: string;
|
||||
evaluadorId?: string;
|
||||
fechaEvaluacion: Date;
|
||||
estado?: EstadoCumplimiento;
|
||||
evidenciaUrl?: string;
|
||||
observaciones?: string;
|
||||
fechaCompromiso?: Date;
|
||||
}
|
||||
|
||||
export interface UpdateCumplimientoDto {
|
||||
estado?: EstadoCumplimiento;
|
||||
evidenciaUrl?: string;
|
||||
observaciones?: string;
|
||||
fechaCompromiso?: Date;
|
||||
evaluadorId?: string;
|
||||
}
|
||||
|
||||
export interface CumplimientoObraFilters {
|
||||
fraccionamientoId?: string;
|
||||
normaId?: string;
|
||||
requisitoId?: string;
|
||||
estado?: EstadoCumplimiento;
|
||||
evaluadorId?: string;
|
||||
fechaDesde?: Date;
|
||||
fechaHasta?: Date;
|
||||
}
|
||||
|
||||
export interface CumplimientoResumen {
|
||||
normaId: string;
|
||||
normaCodigo: string;
|
||||
normaNombre: string;
|
||||
totalRequisitos: number;
|
||||
cumple: number;
|
||||
parcial: number;
|
||||
noCumple: number;
|
||||
noAplica: number;
|
||||
porcentajeCumplimiento: number;
|
||||
}
|
||||
|
||||
export interface CumplimientoStats {
|
||||
totalEvaluaciones: number;
|
||||
cumple: number;
|
||||
parcial: number;
|
||||
noCumple: number;
|
||||
noAplica: number;
|
||||
porcentajeCumplimiento: number;
|
||||
pendientesCompromiso: number;
|
||||
}
|
||||
|
||||
export class CumplimientoService {
|
||||
constructor(
|
||||
private readonly cumplimientoRepository: Repository<CumplimientoObra>,
|
||||
private readonly normaRepository: Repository<NormaStps>,
|
||||
private readonly requisitoRepository: Repository<NormaRequisito>
|
||||
) {}
|
||||
|
||||
// ========== Normas ==========
|
||||
async findNormas(soloAplicaConstruccion: boolean = true): Promise<NormaStps[]> {
|
||||
const where: FindOptionsWhere<NormaStps> = { activo: true };
|
||||
if (soloAplicaConstruccion) {
|
||||
where.aplicaConstruccion = true;
|
||||
}
|
||||
|
||||
return this.normaRepository.find({
|
||||
where,
|
||||
order: { codigo: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findNormaById(id: string): Promise<NormaStps | null> {
|
||||
return this.normaRepository.findOne({
|
||||
where: { id } as FindOptionsWhere<NormaStps>,
|
||||
relations: ['requisitos'],
|
||||
});
|
||||
}
|
||||
|
||||
async findRequisitosByNorma(normaId: string): Promise<NormaRequisito[]> {
|
||||
return this.requisitoRepository.find({
|
||||
where: { normaId } as FindOptionsWhere<NormaRequisito>,
|
||||
order: { numero: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Cumplimiento ==========
|
||||
async findCumplimientos(
|
||||
ctx: ServiceContext,
|
||||
filters: CumplimientoObraFilters = {},
|
||||
page: number = 1,
|
||||
limit: number = 20
|
||||
): Promise<PaginatedResult<CumplimientoObra>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.cumplimientoRepository
|
||||
.createQueryBuilder('c')
|
||||
.leftJoinAndSelect('c.fraccionamiento', 'fraccionamiento')
|
||||
.leftJoinAndSelect('c.norma', 'norma')
|
||||
.leftJoinAndSelect('c.requisito', 'requisito')
|
||||
.leftJoinAndSelect('c.evaluador', 'evaluador')
|
||||
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (filters.fraccionamientoId) {
|
||||
queryBuilder.andWhere('c.fraccionamiento_id = :fraccionamientoId', {
|
||||
fraccionamientoId: filters.fraccionamientoId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.normaId) {
|
||||
queryBuilder.andWhere('c.norma_id = :normaId', { normaId: filters.normaId });
|
||||
}
|
||||
|
||||
if (filters.requisitoId) {
|
||||
queryBuilder.andWhere('c.requisito_id = :requisitoId', { requisitoId: filters.requisitoId });
|
||||
}
|
||||
|
||||
if (filters.estado) {
|
||||
queryBuilder.andWhere('c.estado = :estado', { estado: filters.estado });
|
||||
}
|
||||
|
||||
if (filters.evaluadorId) {
|
||||
queryBuilder.andWhere('c.evaluador_id = :evaluadorId', { evaluadorId: filters.evaluadorId });
|
||||
}
|
||||
|
||||
if (filters.fechaDesde) {
|
||||
queryBuilder.andWhere('c.fecha_evaluacion >= :fechaDesde', { fechaDesde: filters.fechaDesde });
|
||||
}
|
||||
|
||||
if (filters.fechaHasta) {
|
||||
queryBuilder.andWhere('c.fecha_evaluacion <= :fechaHasta', { fechaHasta: filters.fechaHasta });
|
||||
}
|
||||
|
||||
queryBuilder
|
||||
.orderBy('norma.codigo', 'ASC')
|
||||
.addOrderBy('requisito.numero', 'ASC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async findById(ctx: ServiceContext, id: string): Promise<CumplimientoObra | null> {
|
||||
return this.cumplimientoRepository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<CumplimientoObra>,
|
||||
relations: ['fraccionamiento', 'norma', 'requisito', 'evaluador'],
|
||||
});
|
||||
}
|
||||
|
||||
async create(ctx: ServiceContext, dto: CreateCumplimientoObraDto): Promise<CumplimientoObra> {
|
||||
const cumplimiento = this.cumplimientoRepository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
fraccionamientoId: dto.fraccionamientoId,
|
||||
normaId: dto.normaId,
|
||||
requisitoId: dto.requisitoId,
|
||||
evaluadorId: dto.evaluadorId,
|
||||
fechaEvaluacion: dto.fechaEvaluacion,
|
||||
estado: dto.estado || 'no_cumple',
|
||||
evidenciaUrl: dto.evidenciaUrl,
|
||||
observaciones: dto.observaciones,
|
||||
fechaCompromiso: dto.fechaCompromiso,
|
||||
});
|
||||
|
||||
return this.cumplimientoRepository.save(cumplimiento);
|
||||
}
|
||||
|
||||
async update(ctx: ServiceContext, id: string, dto: UpdateCumplimientoDto): Promise<CumplimientoObra | null> {
|
||||
const cumplimiento = await this.findById(ctx, id);
|
||||
if (!cumplimiento) return null;
|
||||
|
||||
if (dto.estado) cumplimiento.estado = dto.estado;
|
||||
if (dto.evidenciaUrl !== undefined) cumplimiento.evidenciaUrl = dto.evidenciaUrl;
|
||||
if (dto.observaciones !== undefined) cumplimiento.observaciones = dto.observaciones;
|
||||
if (dto.fechaCompromiso !== undefined) cumplimiento.fechaCompromiso = dto.fechaCompromiso;
|
||||
if (dto.evaluadorId !== undefined) cumplimiento.evaluadorId = dto.evaluadorId;
|
||||
|
||||
cumplimiento.fechaEvaluacion = new Date();
|
||||
|
||||
return this.cumplimientoRepository.save(cumplimiento);
|
||||
}
|
||||
|
||||
async updateEstado(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
estado: EstadoCumplimiento,
|
||||
evidenciaUrl?: string,
|
||||
observaciones?: string
|
||||
): Promise<CumplimientoObra | null> {
|
||||
const cumplimiento = await this.findById(ctx, id);
|
||||
if (!cumplimiento) return null;
|
||||
|
||||
cumplimiento.estado = estado;
|
||||
cumplimiento.fechaEvaluacion = new Date();
|
||||
if (evidenciaUrl) cumplimiento.evidenciaUrl = evidenciaUrl;
|
||||
if (observaciones) cumplimiento.observaciones = observaciones;
|
||||
|
||||
return this.cumplimientoRepository.save(cumplimiento);
|
||||
}
|
||||
|
||||
// ========== Evaluación Masiva ==========
|
||||
async evaluarNormaCompleta(
|
||||
ctx: ServiceContext,
|
||||
fraccionamientoId: string,
|
||||
normaId: string,
|
||||
evaluadorId: string
|
||||
): Promise<CumplimientoObra[]> {
|
||||
const requisitos = await this.findRequisitosByNorma(normaId);
|
||||
const cumplimientos: CumplimientoObra[] = [];
|
||||
|
||||
for (const requisito of requisitos) {
|
||||
const existing = await this.cumplimientoRepository.findOne({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
fraccionamientoId,
|
||||
normaId,
|
||||
requisitoId: requisito.id,
|
||||
} as FindOptionsWhere<CumplimientoObra>,
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
const cumplimiento = await this.create(ctx, {
|
||||
fraccionamientoId,
|
||||
normaId,
|
||||
requisitoId: requisito.id,
|
||||
evaluadorId,
|
||||
fechaEvaluacion: new Date(),
|
||||
estado: 'no_cumple',
|
||||
});
|
||||
cumplimientos.push(cumplimiento);
|
||||
}
|
||||
}
|
||||
|
||||
return cumplimientos;
|
||||
}
|
||||
|
||||
// ========== Resumen por Norma ==========
|
||||
async getResumenPorNorma(ctx: ServiceContext, fraccionamientoId: string): Promise<CumplimientoResumen[]> {
|
||||
const normas = await this.findNormas(true);
|
||||
const resumenes: CumplimientoResumen[] = [];
|
||||
|
||||
for (const norma of normas) {
|
||||
const cumplimientos = await this.cumplimientoRepository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
fraccionamientoId,
|
||||
normaId: norma.id,
|
||||
} as FindOptionsWhere<CumplimientoObra>,
|
||||
});
|
||||
|
||||
const cumple = cumplimientos.filter((c) => c.estado === 'cumple').length;
|
||||
const parcial = cumplimientos.filter((c) => c.estado === 'parcial').length;
|
||||
const noCumple = cumplimientos.filter((c) => c.estado === 'no_cumple').length;
|
||||
const noAplica = cumplimientos.filter((c) => c.estado === 'no_aplica').length;
|
||||
const totalRequisitos = cumplimientos.length;
|
||||
|
||||
const evaluables = totalRequisitos - noAplica;
|
||||
const porcentajeCumplimiento = evaluables > 0
|
||||
? Math.round(((cumple + parcial * 0.5) / evaluables) * 100)
|
||||
: 100;
|
||||
|
||||
resumenes.push({
|
||||
normaId: norma.id,
|
||||
normaCodigo: norma.codigo,
|
||||
normaNombre: norma.nombre,
|
||||
totalRequisitos,
|
||||
cumple,
|
||||
parcial,
|
||||
noCumple,
|
||||
noAplica,
|
||||
porcentajeCumplimiento,
|
||||
});
|
||||
}
|
||||
|
||||
return resumenes;
|
||||
}
|
||||
|
||||
// ========== Estadísticas ==========
|
||||
async getStats(ctx: ServiceContext, fraccionamientoId?: string): Promise<CumplimientoStats> {
|
||||
const queryBuilder = this.cumplimientoRepository
|
||||
.createQueryBuilder('c')
|
||||
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (fraccionamientoId) {
|
||||
queryBuilder.andWhere('c.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
|
||||
}
|
||||
|
||||
const cumplimientos = await queryBuilder.getMany();
|
||||
|
||||
const cumple = cumplimientos.filter((c) => c.estado === 'cumple').length;
|
||||
const parcial = cumplimientos.filter((c) => c.estado === 'parcial').length;
|
||||
const noCumple = cumplimientos.filter((c) => c.estado === 'no_cumple').length;
|
||||
const noAplica = cumplimientos.filter((c) => c.estado === 'no_aplica').length;
|
||||
const totalEvaluaciones = cumplimientos.length;
|
||||
|
||||
const evaluables = totalEvaluaciones - noAplica;
|
||||
const porcentajeCumplimiento = evaluables > 0
|
||||
? Math.round(((cumple + parcial * 0.5) / evaluables) * 100)
|
||||
: 100;
|
||||
|
||||
const today = new Date();
|
||||
const pendientesCompromiso = cumplimientos.filter(
|
||||
(c) => c.estado !== 'cumple' && c.fechaCompromiso && c.fechaCompromiso < today
|
||||
).length;
|
||||
|
||||
return {
|
||||
totalEvaluaciones,
|
||||
cumple,
|
||||
parcial,
|
||||
noCumple,
|
||||
noAplica,
|
||||
porcentajeCumplimiento,
|
||||
pendientesCompromiso,
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Compromisos Pendientes ==========
|
||||
async getCompromisosPendientes(ctx: ServiceContext, fraccionamientoId?: string): Promise<CumplimientoObra[]> {
|
||||
const today = new Date();
|
||||
|
||||
const queryBuilder = this.cumplimientoRepository
|
||||
.createQueryBuilder('c')
|
||||
.leftJoinAndSelect('c.fraccionamiento', 'fraccionamiento')
|
||||
.leftJoinAndSelect('c.norma', 'norma')
|
||||
.leftJoinAndSelect('c.requisito', 'requisito')
|
||||
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('c.estado IN (:...estados)', { estados: ['no_cumple', 'parcial'] })
|
||||
.andWhere('c.fecha_compromiso IS NOT NULL')
|
||||
.andWhere('c.fecha_compromiso < :today', { today });
|
||||
|
||||
if (fraccionamientoId) {
|
||||
queryBuilder.andWhere('c.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
|
||||
}
|
||||
|
||||
return queryBuilder.orderBy('c.fecha_compromiso', 'ASC').getMany();
|
||||
}
|
||||
|
||||
// ========== No Cumplimientos ==========
|
||||
async getNoCumplimientos(ctx: ServiceContext, fraccionamientoId?: string): Promise<CumplimientoObra[]> {
|
||||
const queryBuilder = this.cumplimientoRepository
|
||||
.createQueryBuilder('c')
|
||||
.leftJoinAndSelect('c.fraccionamiento', 'fraccionamiento')
|
||||
.leftJoinAndSelect('c.norma', 'norma')
|
||||
.leftJoinAndSelect('c.requisito', 'requisito')
|
||||
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('c.estado = :estado', { estado: 'no_cumple' });
|
||||
|
||||
if (fraccionamientoId) {
|
||||
queryBuilder.andWhere('c.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
|
||||
}
|
||||
|
||||
return queryBuilder
|
||||
.orderBy('norma.codigo', 'ASC')
|
||||
.addOrderBy('requisito.numero', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
}
|
||||
276
src/modules/hse/services/dias-sin-accidente.service.ts
Normal file
276
src/modules/hse/services/dias-sin-accidente.service.ts
Normal file
@ -0,0 +1,276 @@
|
||||
/**
|
||||
* DiasSinAccidenteService - Servicio para contador de dias sin accidente
|
||||
*
|
||||
* RF-MAA017-008: Gestion de indicadores de dias sin accidente por obra
|
||||
*
|
||||
* @module HSE
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { DiasSinAccidente } from '../entities/dias-sin-accidente.entity';
|
||||
import { Incidente, TipoIncidente } from '../entities/incidente.entity';
|
||||
import { ServiceContext } from '../../../shared/services/base.service';
|
||||
|
||||
export interface CreateDiasSinAccidenteDto {
|
||||
fraccionamientoId: string;
|
||||
fechaInicioConteo: Date;
|
||||
}
|
||||
|
||||
export interface DiasSinAccidenteStats {
|
||||
fraccionamientoId: string;
|
||||
diasAcumulados: number;
|
||||
recordHistorico: number;
|
||||
fechaInicioConteo: Date;
|
||||
ultimoAccidenteFecha?: Date;
|
||||
}
|
||||
|
||||
export interface DiasSinAccidenteResumen {
|
||||
totalObras: number;
|
||||
obraConMasDias: { fraccionamientoId: string; dias: number } | null;
|
||||
obraConMenosDias: { fraccionamientoId: string; dias: number } | null;
|
||||
promedioDias: number;
|
||||
}
|
||||
|
||||
export class DiasSinAccidenteService {
|
||||
constructor(
|
||||
private readonly diasSinAccidenteRepository: Repository<DiasSinAccidente>,
|
||||
private readonly incidenteRepository: Repository<Incidente>
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Obtiene el contador de dias sin accidente para una obra
|
||||
*/
|
||||
async findByFraccionamiento(
|
||||
ctx: ServiceContext,
|
||||
fraccionamientoId: string
|
||||
): Promise<DiasSinAccidente | null> {
|
||||
return this.diasSinAccidenteRepository.findOne({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
fraccionamientoId,
|
||||
} as FindOptionsWhere<DiasSinAccidente>,
|
||||
relations: ['fraccionamiento', 'ultimoIncidente'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista todos los contadores de dias sin accidente del tenant
|
||||
*/
|
||||
async findAll(ctx: ServiceContext): Promise<DiasSinAccidente[]> {
|
||||
return this.diasSinAccidenteRepository.find({
|
||||
where: { tenantId: ctx.tenantId } as FindOptionsWhere<DiasSinAccidente>,
|
||||
relations: ['fraccionamiento'],
|
||||
order: { diasAcumulados: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Inicializa el contador para una obra
|
||||
*/
|
||||
async initialize(
|
||||
ctx: ServiceContext,
|
||||
dto: CreateDiasSinAccidenteDto
|
||||
): Promise<DiasSinAccidente> {
|
||||
// Verificar si ya existe
|
||||
const existing = await this.findByFraccionamiento(ctx, dto.fraccionamientoId);
|
||||
if (existing) {
|
||||
throw new Error('El contador ya existe para esta obra');
|
||||
}
|
||||
|
||||
const contador = this.diasSinAccidenteRepository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
fraccionamientoId: dto.fraccionamientoId,
|
||||
fechaInicioConteo: dto.fechaInicioConteo,
|
||||
diasAcumulados: this.calculateDays(dto.fechaInicioConteo),
|
||||
recordHistorico: 0,
|
||||
});
|
||||
|
||||
return this.diasSinAccidenteRepository.save(contador);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinicia el contador por un nuevo accidente
|
||||
*/
|
||||
async resetByAccident(
|
||||
ctx: ServiceContext,
|
||||
fraccionamientoId: string,
|
||||
incidenteId: string
|
||||
): Promise<DiasSinAccidente | null> {
|
||||
const contador = await this.findByFraccionamiento(ctx, fraccionamientoId);
|
||||
if (!contador) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Actualizar record si es necesario
|
||||
const diasActuales = this.calculateDays(contador.fechaInicioConteo);
|
||||
if (diasActuales > contador.recordHistorico) {
|
||||
contador.recordHistorico = diasActuales;
|
||||
}
|
||||
|
||||
// Reiniciar conteo
|
||||
contador.fechaInicioConteo = new Date();
|
||||
contador.diasAcumulados = 0;
|
||||
contador.ultimoIncidenteId = incidenteId;
|
||||
|
||||
return this.diasSinAccidenteRepository.save(contador);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza los dias acumulados (llamado por cron o manualmente)
|
||||
*/
|
||||
async updateDiasAcumulados(
|
||||
ctx: ServiceContext,
|
||||
fraccionamientoId: string
|
||||
): Promise<DiasSinAccidente | null> {
|
||||
const contador = await this.findByFraccionamiento(ctx, fraccionamientoId);
|
||||
if (!contador) {
|
||||
return null;
|
||||
}
|
||||
|
||||
contador.diasAcumulados = this.calculateDays(contador.fechaInicioConteo);
|
||||
|
||||
// Actualizar record si supera el historico
|
||||
if (contador.diasAcumulados > contador.recordHistorico) {
|
||||
contador.recordHistorico = contador.diasAcumulados;
|
||||
}
|
||||
|
||||
return this.diasSinAccidenteRepository.save(contador);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza todos los contadores del tenant
|
||||
*/
|
||||
async updateAllContadores(ctx: ServiceContext): Promise<number> {
|
||||
const contadores = await this.findAll(ctx);
|
||||
let updated = 0;
|
||||
|
||||
for (const contador of contadores) {
|
||||
const dias = this.calculateDays(contador.fechaInicioConteo);
|
||||
if (dias !== contador.diasAcumulados) {
|
||||
contador.diasAcumulados = dias;
|
||||
if (dias > contador.recordHistorico) {
|
||||
contador.recordHistorico = dias;
|
||||
}
|
||||
await this.diasSinAccidenteRepository.save(contador);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene estadisticas de una obra
|
||||
*/
|
||||
async getStats(
|
||||
ctx: ServiceContext,
|
||||
fraccionamientoId: string
|
||||
): Promise<DiasSinAccidenteStats | null> {
|
||||
const contador = await this.findByFraccionamiento(ctx, fraccionamientoId);
|
||||
if (!contador) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Obtener ultimo accidente
|
||||
const ultimoAccidente = await this.incidenteRepository.findOne({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
fraccionamientoId,
|
||||
tipo: 'accidente' as TipoIncidente,
|
||||
} as FindOptionsWhere<Incidente>,
|
||||
order: { fechaHora: 'DESC' },
|
||||
});
|
||||
|
||||
return {
|
||||
fraccionamientoId,
|
||||
diasAcumulados: this.calculateDays(contador.fechaInicioConteo),
|
||||
recordHistorico: contador.recordHistorico,
|
||||
fechaInicioConteo: contador.fechaInicioConteo,
|
||||
ultimoAccidenteFecha: ultimoAccidente?.fechaHora,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene resumen de dias sin accidente del tenant
|
||||
*/
|
||||
async getResumen(ctx: ServiceContext): Promise<DiasSinAccidenteResumen> {
|
||||
const contadores = await this.findAll(ctx);
|
||||
|
||||
if (contadores.length === 0) {
|
||||
return {
|
||||
totalObras: 0,
|
||||
obraConMasDias: null,
|
||||
obraConMenosDias: null,
|
||||
promedioDias: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Actualizar dias acumulados
|
||||
const contadoresActualizados = contadores.map((c) => ({
|
||||
...c,
|
||||
diasAcumulados: this.calculateDays(c.fechaInicioConteo),
|
||||
}));
|
||||
|
||||
const sorted = contadoresActualizados.sort((a, b) => b.diasAcumulados - a.diasAcumulados);
|
||||
const totalDias = sorted.reduce((sum, c) => sum + c.diasAcumulados, 0);
|
||||
|
||||
return {
|
||||
totalObras: contadores.length,
|
||||
obraConMasDias: {
|
||||
fraccionamientoId: sorted[0].fraccionamientoId,
|
||||
dias: sorted[0].diasAcumulados,
|
||||
},
|
||||
obraConMenosDias: {
|
||||
fraccionamientoId: sorted[sorted.length - 1].fraccionamientoId,
|
||||
dias: sorted[sorted.length - 1].diasAcumulados,
|
||||
},
|
||||
promedioDias: Math.round(totalDias / contadores.length),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si se debe reiniciar el contador basado en incidentes recientes
|
||||
*/
|
||||
async checkAndResetIfNeeded(
|
||||
ctx: ServiceContext,
|
||||
fraccionamientoId: string
|
||||
): Promise<{ reset: boolean; incidenteId?: string }> {
|
||||
const contador = await this.findByFraccionamiento(ctx, fraccionamientoId);
|
||||
if (!contador) {
|
||||
return { reset: false };
|
||||
}
|
||||
|
||||
// Buscar accidentes despues del inicio del conteo
|
||||
const accidenteReciente = await this.incidenteRepository.findOne({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
fraccionamientoId,
|
||||
tipo: 'accidente' as TipoIncidente,
|
||||
} as FindOptionsWhere<Incidente>,
|
||||
order: { fechaHora: 'DESC' },
|
||||
});
|
||||
|
||||
if (
|
||||
accidenteReciente &&
|
||||
new Date(accidenteReciente.fechaHora) > new Date(contador.fechaInicioConteo) &&
|
||||
accidenteReciente.id !== contador.ultimoIncidenteId
|
||||
) {
|
||||
await this.resetByAccident(ctx, fraccionamientoId, accidenteReciente.id);
|
||||
return { reset: true, incidenteId: accidenteReciente.id };
|
||||
}
|
||||
|
||||
return { reset: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula dias desde una fecha hasta hoy
|
||||
*/
|
||||
private calculateDays(fechaInicio: Date): number {
|
||||
const inicio = new Date(fechaInicio);
|
||||
inicio.setHours(0, 0, 0, 0);
|
||||
const hoy = new Date();
|
||||
hoy.setHours(0, 0, 0, 0);
|
||||
const diff = hoy.getTime() - inicio.getTime();
|
||||
return Math.max(0, Math.floor(diff / (1000 * 60 * 60 * 24)));
|
||||
}
|
||||
}
|
||||
518
src/modules/hse/services/hallazgo.service.ts
Normal file
518
src/modules/hse/services/hallazgo.service.ts
Normal file
@ -0,0 +1,518 @@
|
||||
/**
|
||||
* HallazgoService - Servicio para gestion de hallazgos de seguridad
|
||||
*
|
||||
* RF-MAA017-003: Gestion de hallazgos, acciones correctivas y seguimiento
|
||||
*
|
||||
* @module HSE
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { Hallazgo, GravedadHallazgo, EstadoHallazgo } from '../entities/hallazgo.entity';
|
||||
import { HallazgoEvidencia, TipoEvidencia } from '../entities/hallazgo-evidencia.entity';
|
||||
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||
|
||||
export interface HallazgoServiceFilters {
|
||||
fraccionamientoId?: string;
|
||||
inspeccionId?: string;
|
||||
gravedad?: GravedadHallazgo;
|
||||
estado?: EstadoHallazgo;
|
||||
responsableId?: string;
|
||||
vencidos?: boolean;
|
||||
tipo?: 'acto_inseguro' | 'condicion_insegura';
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
}
|
||||
|
||||
export interface CreateHallazgoServiceDto {
|
||||
inspeccionId: string;
|
||||
evaluacionId?: string;
|
||||
gravedad: GravedadHallazgo;
|
||||
tipo: 'acto_inseguro' | 'condicion_insegura';
|
||||
descripcion: string;
|
||||
ubicacionDescripcion?: string;
|
||||
responsableCorreccionId?: string;
|
||||
fechaLimite: Date;
|
||||
}
|
||||
|
||||
export interface UpdateHallazgoDto {
|
||||
gravedad?: GravedadHallazgo;
|
||||
descripcion?: string;
|
||||
ubicacionDescripcion?: string;
|
||||
responsableCorreccionId?: string;
|
||||
fechaLimite?: Date;
|
||||
}
|
||||
|
||||
export interface AddEvidenciaDto {
|
||||
tipo: TipoEvidencia;
|
||||
archivoUrl: string;
|
||||
descripcion?: string;
|
||||
}
|
||||
|
||||
export interface RegistrarCorreccionDto {
|
||||
descripcionCorreccion: string;
|
||||
evidenciaUrl?: string;
|
||||
}
|
||||
|
||||
export interface HallazgoStats {
|
||||
total: number;
|
||||
porGravedad: { gravedad: string; count: number }[];
|
||||
porEstado: { estado: string; count: number }[];
|
||||
porTipo: { tipo: string; count: number }[];
|
||||
vencidos: number;
|
||||
proximosAVencer: number;
|
||||
tiempoPromedioCorreccion: number;
|
||||
}
|
||||
|
||||
export class HallazgoService {
|
||||
constructor(
|
||||
private readonly hallazgoRepository: Repository<Hallazgo>,
|
||||
private readonly evidenciaRepository: Repository<HallazgoEvidencia>
|
||||
) {}
|
||||
|
||||
private generateFolio(): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear().toString().slice(-2);
|
||||
const month = (now.getMonth() + 1).toString().padStart(2, '0');
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `HAL-${year}${month}-${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista hallazgos con filtros y paginacion
|
||||
*/
|
||||
async findWithFilters(
|
||||
ctx: ServiceContext,
|
||||
filters: HallazgoServiceFilters = {},
|
||||
page: number = 1,
|
||||
limit: number = 20
|
||||
): Promise<PaginatedResult<Hallazgo>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.hallazgoRepository
|
||||
.createQueryBuilder('hallazgo')
|
||||
.leftJoinAndSelect('hallazgo.inspeccion', 'inspeccion')
|
||||
.leftJoinAndSelect('inspeccion.fraccionamiento', 'fraccionamiento')
|
||||
.leftJoinAndSelect('hallazgo.responsableCorreccion', 'responsable')
|
||||
.leftJoinAndSelect('hallazgo.verificador', 'verificador')
|
||||
.where('hallazgo.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (filters.fraccionamientoId) {
|
||||
queryBuilder.andWhere('inspeccion.fraccionamiento_id = :fraccionamientoId', {
|
||||
fraccionamientoId: filters.fraccionamientoId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.inspeccionId) {
|
||||
queryBuilder.andWhere('hallazgo.inspeccion_id = :inspeccionId', {
|
||||
inspeccionId: filters.inspeccionId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.gravedad) {
|
||||
queryBuilder.andWhere('hallazgo.gravedad = :gravedad', { gravedad: filters.gravedad });
|
||||
}
|
||||
|
||||
if (filters.estado) {
|
||||
queryBuilder.andWhere('hallazgo.estado = :estado', { estado: filters.estado });
|
||||
}
|
||||
|
||||
if (filters.responsableId) {
|
||||
queryBuilder.andWhere('hallazgo.responsable_correccion_id = :responsableId', {
|
||||
responsableId: filters.responsableId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.tipo) {
|
||||
queryBuilder.andWhere('hallazgo.tipo = :tipo', { tipo: filters.tipo });
|
||||
}
|
||||
|
||||
if (filters.vencidos) {
|
||||
queryBuilder.andWhere('hallazgo.fecha_limite < :today', { today: new Date() });
|
||||
queryBuilder.andWhere('hallazgo.estado NOT IN (:...cerrados)', { cerrados: ['cerrado'] });
|
||||
}
|
||||
|
||||
if (filters.dateFrom) {
|
||||
queryBuilder.andWhere('hallazgo.created_at >= :dateFrom', { dateFrom: filters.dateFrom });
|
||||
}
|
||||
|
||||
if (filters.dateTo) {
|
||||
queryBuilder.andWhere('hallazgo.created_at <= :dateTo', { dateTo: filters.dateTo });
|
||||
}
|
||||
|
||||
queryBuilder
|
||||
.orderBy('hallazgo.fecha_limite', 'ASC')
|
||||
.addOrderBy('hallazgo.gravedad', 'ASC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene hallazgo por ID
|
||||
*/
|
||||
async findById(ctx: ServiceContext, id: string): Promise<Hallazgo | null> {
|
||||
return this.hallazgoRepository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<Hallazgo>,
|
||||
relations: ['inspeccion', 'responsableCorreccion', 'verificador'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene hallazgo con todas las relaciones
|
||||
*/
|
||||
async findWithDetails(ctx: ServiceContext, id: string): Promise<Hallazgo | null> {
|
||||
return this.hallazgoRepository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<Hallazgo>,
|
||||
relations: [
|
||||
'inspeccion',
|
||||
'inspeccion.fraccionamiento',
|
||||
'inspeccion.tipoInspeccion',
|
||||
'evaluacion',
|
||||
'responsableCorreccion',
|
||||
'verificador',
|
||||
'evidencias',
|
||||
'evidencias.createdBy',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un nuevo hallazgo
|
||||
*/
|
||||
async create(ctx: ServiceContext, dto: CreateHallazgoServiceDto): Promise<Hallazgo> {
|
||||
const hallazgo = this.hallazgoRepository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
folio: this.generateFolio(),
|
||||
inspeccionId: dto.inspeccionId,
|
||||
evaluacionId: dto.evaluacionId,
|
||||
gravedad: dto.gravedad,
|
||||
tipo: dto.tipo,
|
||||
descripcion: dto.descripcion,
|
||||
ubicacionDescripcion: dto.ubicacionDescripcion,
|
||||
responsableCorreccionId: dto.responsableCorreccionId,
|
||||
fechaLimite: dto.fechaLimite,
|
||||
estado: 'abierto',
|
||||
});
|
||||
|
||||
return this.hallazgoRepository.save(hallazgo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza un hallazgo
|
||||
*/
|
||||
async update(ctx: ServiceContext, id: string, dto: UpdateHallazgoDto): Promise<Hallazgo | null> {
|
||||
const existing = await this.findById(ctx, id);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (existing.estado === 'cerrado') {
|
||||
throw new Error('No se puede modificar un hallazgo cerrado');
|
||||
}
|
||||
|
||||
const updated = this.hallazgoRepository.merge(existing, dto);
|
||||
return this.hallazgoRepository.save(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inicia la correccion de un hallazgo
|
||||
*/
|
||||
async iniciarCorreccion(ctx: ServiceContext, id: string): Promise<Hallazgo | null> {
|
||||
const hallazgo = await this.findById(ctx, id);
|
||||
if (!hallazgo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hallazgo.estado !== 'abierto' && hallazgo.estado !== 'reabierto') {
|
||||
throw new Error('El hallazgo no esta en estado abierto o reabierto');
|
||||
}
|
||||
|
||||
hallazgo.estado = 'en_correccion';
|
||||
return this.hallazgoRepository.save(hallazgo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra la correccion de un hallazgo
|
||||
*/
|
||||
async registrarCorreccion(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
dto: RegistrarCorreccionDto
|
||||
): Promise<Hallazgo | null> {
|
||||
const hallazgo = await this.findById(ctx, id);
|
||||
if (!hallazgo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hallazgo.estado === 'cerrado') {
|
||||
throw new Error('El hallazgo ya esta cerrado');
|
||||
}
|
||||
|
||||
hallazgo.estado = 'verificando';
|
||||
hallazgo.descripcionCorreccion = dto.descripcionCorreccion;
|
||||
hallazgo.fechaCorreccion = new Date();
|
||||
|
||||
const saved = await this.hallazgoRepository.save(hallazgo);
|
||||
|
||||
// Agregar evidencia de correccion si se proporciona
|
||||
if (dto.evidenciaUrl) {
|
||||
await this.addEvidencia(ctx, id, {
|
||||
tipo: 'correccion',
|
||||
archivoUrl: dto.evidenciaUrl,
|
||||
descripcion: 'Evidencia de correccion',
|
||||
});
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica un hallazgo (aprueba o rechaza la correccion)
|
||||
*/
|
||||
async verificar(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
aprobado: boolean,
|
||||
observaciones?: string
|
||||
): Promise<Hallazgo | null> {
|
||||
const hallazgo = await this.findById(ctx, id);
|
||||
if (!hallazgo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hallazgo.estado !== 'verificando') {
|
||||
throw new Error('El hallazgo no esta en estado de verificacion');
|
||||
}
|
||||
|
||||
hallazgo.verificadorId = ctx.userId || '';
|
||||
hallazgo.fechaVerificacion = new Date();
|
||||
hallazgo.estado = aprobado ? 'cerrado' : 'reabierto';
|
||||
|
||||
if (observaciones && !aprobado) {
|
||||
hallazgo.descripcionCorreccion = `${hallazgo.descripcionCorreccion}\n\n[RECHAZADO]: ${observaciones}`;
|
||||
}
|
||||
|
||||
return this.hallazgoRepository.save(hallazgo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reabre un hallazgo cerrado
|
||||
*/
|
||||
async reabrir(ctx: ServiceContext, id: string, motivo: string): Promise<Hallazgo | null> {
|
||||
const hallazgo = await this.findById(ctx, id);
|
||||
if (!hallazgo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hallazgo.estado !== 'cerrado') {
|
||||
throw new Error('Solo se pueden reabrir hallazgos cerrados');
|
||||
}
|
||||
|
||||
hallazgo.estado = 'reabierto';
|
||||
hallazgo.descripcionCorreccion = `${hallazgo.descripcionCorreccion}\n\n[REABIERTO]: ${motivo}`;
|
||||
hallazgo.fechaCorreccion = undefined as unknown as Date;
|
||||
hallazgo.fechaVerificacion = undefined as unknown as Date;
|
||||
|
||||
return this.hallazgoRepository.save(hallazgo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agrega evidencia a un hallazgo
|
||||
*/
|
||||
async addEvidencia(
|
||||
ctx: ServiceContext,
|
||||
hallazgoId: string,
|
||||
dto: AddEvidenciaDto
|
||||
): Promise<HallazgoEvidencia> {
|
||||
const hallazgo = await this.findById(ctx, hallazgoId);
|
||||
if (!hallazgo) {
|
||||
throw new Error('Hallazgo no encontrado');
|
||||
}
|
||||
|
||||
const evidencia = this.evidenciaRepository.create({
|
||||
hallazgoId,
|
||||
tipo: dto.tipo,
|
||||
archivoUrl: dto.archivoUrl,
|
||||
descripcion: dto.descripcion,
|
||||
createdById: ctx.userId,
|
||||
});
|
||||
|
||||
return this.evidenciaRepository.save(evidencia);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene evidencias de un hallazgo
|
||||
*/
|
||||
async getEvidencias(ctx: ServiceContext, hallazgoId: string): Promise<HallazgoEvidencia[]> {
|
||||
const hallazgo = await this.findById(ctx, hallazgoId);
|
||||
if (!hallazgo) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.evidenciaRepository.find({
|
||||
where: { hallazgoId } as FindOptionsWhere<HallazgoEvidencia>,
|
||||
relations: ['createdBy'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina evidencia
|
||||
*/
|
||||
async removeEvidencia(ctx: ServiceContext, hallazgoId: string, evidenciaId: string): Promise<boolean> {
|
||||
const hallazgo = await this.findById(ctx, hallazgoId);
|
||||
if (!hallazgo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await this.evidenciaRepository.delete({
|
||||
id: evidenciaId,
|
||||
hallazgoId,
|
||||
});
|
||||
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene hallazgos vencidos
|
||||
*/
|
||||
async findVencidos(ctx: ServiceContext, fraccionamientoId?: string): Promise<Hallazgo[]> {
|
||||
const queryBuilder = this.hallazgoRepository
|
||||
.createQueryBuilder('hallazgo')
|
||||
.leftJoinAndSelect('hallazgo.inspeccion', 'inspeccion')
|
||||
.leftJoinAndSelect('hallazgo.responsableCorreccion', 'responsable')
|
||||
.where('hallazgo.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('hallazgo.fecha_limite < :today', { today: new Date() })
|
||||
.andWhere('hallazgo.estado NOT IN (:...cerrados)', { cerrados: ['cerrado'] });
|
||||
|
||||
if (fraccionamientoId) {
|
||||
queryBuilder.andWhere('inspeccion.fraccionamiento_id = :fraccionamientoId', {
|
||||
fraccionamientoId,
|
||||
});
|
||||
}
|
||||
|
||||
return queryBuilder
|
||||
.orderBy('hallazgo.fecha_limite', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene hallazgos proximos a vencer (7 dias)
|
||||
*/
|
||||
async findProximosAVencer(
|
||||
ctx: ServiceContext,
|
||||
dias: number = 7,
|
||||
fraccionamientoId?: string
|
||||
): Promise<Hallazgo[]> {
|
||||
const fechaLimite = new Date();
|
||||
fechaLimite.setDate(fechaLimite.getDate() + dias);
|
||||
|
||||
const queryBuilder = this.hallazgoRepository
|
||||
.createQueryBuilder('hallazgo')
|
||||
.leftJoinAndSelect('hallazgo.inspeccion', 'inspeccion')
|
||||
.leftJoinAndSelect('hallazgo.responsableCorreccion', 'responsable')
|
||||
.where('hallazgo.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('hallazgo.fecha_limite >= :today', { today: new Date() })
|
||||
.andWhere('hallazgo.fecha_limite <= :fechaLimite', { fechaLimite })
|
||||
.andWhere('hallazgo.estado NOT IN (:...cerrados)', { cerrados: ['cerrado'] });
|
||||
|
||||
if (fraccionamientoId) {
|
||||
queryBuilder.andWhere('inspeccion.fraccionamiento_id = :fraccionamientoId', {
|
||||
fraccionamientoId,
|
||||
});
|
||||
}
|
||||
|
||||
return queryBuilder
|
||||
.orderBy('hallazgo.fecha_limite', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene estadisticas de hallazgos
|
||||
*/
|
||||
async getStats(ctx: ServiceContext, fraccionamientoId?: string): Promise<HallazgoStats> {
|
||||
const baseQuery = this.hallazgoRepository
|
||||
.createQueryBuilder('h')
|
||||
.leftJoin('h.inspeccion', 'i')
|
||||
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (fraccionamientoId) {
|
||||
baseQuery.andWhere('i.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
|
||||
}
|
||||
|
||||
const total = await baseQuery.getCount();
|
||||
|
||||
// Por gravedad
|
||||
const porGravedad = await this.hallazgoRepository
|
||||
.createQueryBuilder('h')
|
||||
.select('h.gravedad', 'gravedad')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.groupBy('h.gravedad')
|
||||
.getRawMany();
|
||||
|
||||
// Por estado
|
||||
const porEstado = await this.hallazgoRepository
|
||||
.createQueryBuilder('h')
|
||||
.select('h.estado', 'estado')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.groupBy('h.estado')
|
||||
.getRawMany();
|
||||
|
||||
// Por tipo
|
||||
const porTipo = await this.hallazgoRepository
|
||||
.createQueryBuilder('h')
|
||||
.select('h.tipo', 'tipo')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.groupBy('h.tipo')
|
||||
.getRawMany();
|
||||
|
||||
// Vencidos
|
||||
const vencidos = await this.hallazgoRepository
|
||||
.createQueryBuilder('h')
|
||||
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('h.fecha_limite < :today', { today: new Date() })
|
||||
.andWhere('h.estado NOT IN (:...cerrados)', { cerrados: ['cerrado'] })
|
||||
.getCount();
|
||||
|
||||
// Proximos a vencer (7 dias)
|
||||
const fechaLimite = new Date();
|
||||
fechaLimite.setDate(fechaLimite.getDate() + 7);
|
||||
const proximosAVencer = await this.hallazgoRepository
|
||||
.createQueryBuilder('h')
|
||||
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('h.fecha_limite >= :today', { today: new Date() })
|
||||
.andWhere('h.fecha_limite <= :fechaLimite', { fechaLimite })
|
||||
.andWhere('h.estado NOT IN (:...cerrados)', { cerrados: ['cerrado'] })
|
||||
.getCount();
|
||||
|
||||
// Tiempo promedio de correccion
|
||||
const tiemposCorreccion = await this.hallazgoRepository
|
||||
.createQueryBuilder('h')
|
||||
.select('AVG(EXTRACT(EPOCH FROM (h.fecha_correccion - h.created_at)) / 86400)', 'avgDias')
|
||||
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('h.fecha_correccion IS NOT NULL')
|
||||
.getRawOne();
|
||||
|
||||
return {
|
||||
total,
|
||||
porGravedad: porGravedad.map((p) => ({ gravedad: p.gravedad, count: parseInt(p.count, 10) })),
|
||||
porEstado: porEstado.map((p) => ({ estado: p.estado, count: parseInt(p.count, 10) })),
|
||||
porTipo: porTipo.map((p) => ({ tipo: p.tipo, count: parseInt(p.count, 10) })),
|
||||
vencidos,
|
||||
proximosAVencer,
|
||||
tiempoPromedioCorreccion: tiemposCorreccion?.avgDias ? parseFloat(tiemposCorreccion.avgDias) : 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
386
src/modules/hse/services/horas-trabajadas.service.ts
Normal file
386
src/modules/hse/services/horas-trabajadas.service.ts
Normal file
@ -0,0 +1,386 @@
|
||||
/**
|
||||
* HorasTrabajadasService - Servicio para registro de horas trabajadas
|
||||
*
|
||||
* RF-MAA017-008: Indicadores HSE - Horas Hombre Trabajadas
|
||||
*
|
||||
* @module HSE
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere, Between } from 'typeorm';
|
||||
import { HorasTrabajadas, FuenteHoras } from '../entities/horas-trabajadas.entity';
|
||||
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||
|
||||
export interface CreateHorasTrabajadasDto {
|
||||
fraccionamientoId: string;
|
||||
fecha: Date;
|
||||
horasTotales: number;
|
||||
trabajadoresPromedio: number;
|
||||
fuente?: FuenteHoras;
|
||||
}
|
||||
|
||||
export interface UpdateHorasTrabajadasDto {
|
||||
horasTotales?: number;
|
||||
trabajadoresPromedio?: number;
|
||||
fuente?: FuenteHoras;
|
||||
}
|
||||
|
||||
export interface HorasTrabajadasFilters {
|
||||
fraccionamientoId?: string;
|
||||
fuente?: FuenteHoras;
|
||||
fechaDesde?: Date;
|
||||
fechaHasta?: Date;
|
||||
year?: number;
|
||||
month?: number;
|
||||
}
|
||||
|
||||
export interface HorasTrabajadasResumen {
|
||||
fraccionamientoId: string;
|
||||
fraccionamientoNombre?: string;
|
||||
periodo: string;
|
||||
totalHoras: number;
|
||||
promedioTrabajadores: number;
|
||||
diasRegistrados: number;
|
||||
}
|
||||
|
||||
export interface HorasTrabajadasStats {
|
||||
totalHorasAcumuladas: number;
|
||||
horasUltimoMes: number;
|
||||
promedioTrabajadoresDiario: number;
|
||||
diasRegistrados: number;
|
||||
horasPromedioMensual: number;
|
||||
}
|
||||
|
||||
export class HorasTrabajadasService {
|
||||
constructor(private readonly repository: Repository<HorasTrabajadas>) {}
|
||||
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters: HorasTrabajadasFilters = {},
|
||||
page: number = 1,
|
||||
limit: number = 50
|
||||
): Promise<PaginatedResult<HorasTrabajadas>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.repository
|
||||
.createQueryBuilder('h')
|
||||
.leftJoinAndSelect('h.fraccionamiento', 'fraccionamiento')
|
||||
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (filters.fraccionamientoId) {
|
||||
queryBuilder.andWhere('h.fraccionamiento_id = :fraccionamientoId', {
|
||||
fraccionamientoId: filters.fraccionamientoId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.fuente) {
|
||||
queryBuilder.andWhere('h.fuente = :fuente', { fuente: filters.fuente });
|
||||
}
|
||||
|
||||
if (filters.fechaDesde) {
|
||||
queryBuilder.andWhere('h.fecha >= :fechaDesde', { fechaDesde: filters.fechaDesde });
|
||||
}
|
||||
|
||||
if (filters.fechaHasta) {
|
||||
queryBuilder.andWhere('h.fecha <= :fechaHasta', { fechaHasta: filters.fechaHasta });
|
||||
}
|
||||
|
||||
if (filters.year) {
|
||||
queryBuilder.andWhere('EXTRACT(YEAR FROM h.fecha) = :year', { year: filters.year });
|
||||
}
|
||||
|
||||
if (filters.month) {
|
||||
queryBuilder.andWhere('EXTRACT(MONTH FROM h.fecha) = :month', { month: filters.month });
|
||||
}
|
||||
|
||||
queryBuilder
|
||||
.orderBy('h.fecha', 'DESC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async findById(ctx: ServiceContext, id: string): Promise<HorasTrabajadas | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<HorasTrabajadas>,
|
||||
relations: ['fraccionamiento'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByFecha(
|
||||
ctx: ServiceContext,
|
||||
fraccionamientoId: string,
|
||||
fecha: Date
|
||||
): Promise<HorasTrabajadas | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
fraccionamientoId,
|
||||
fecha,
|
||||
} as FindOptionsWhere<HorasTrabajadas>,
|
||||
});
|
||||
}
|
||||
|
||||
async create(ctx: ServiceContext, dto: CreateHorasTrabajadasDto): Promise<HorasTrabajadas> {
|
||||
const existing = await this.findByFecha(ctx, dto.fraccionamientoId, dto.fecha);
|
||||
if (existing) {
|
||||
throw new Error(`Ya existe un registro de horas para esta fecha y fraccionamiento`);
|
||||
}
|
||||
|
||||
const horas = this.repository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
fraccionamientoId: dto.fraccionamientoId,
|
||||
fecha: dto.fecha,
|
||||
horasTotales: dto.horasTotales,
|
||||
trabajadoresPromedio: dto.trabajadoresPromedio,
|
||||
fuente: dto.fuente || 'manual',
|
||||
});
|
||||
|
||||
return this.repository.save(horas);
|
||||
}
|
||||
|
||||
async upsert(ctx: ServiceContext, dto: CreateHorasTrabajadasDto): Promise<HorasTrabajadas> {
|
||||
const existing = await this.findByFecha(ctx, dto.fraccionamientoId, dto.fecha);
|
||||
|
||||
if (existing) {
|
||||
existing.horasTotales = dto.horasTotales;
|
||||
existing.trabajadoresPromedio = dto.trabajadoresPromedio;
|
||||
existing.fuente = dto.fuente || existing.fuente;
|
||||
return this.repository.save(existing);
|
||||
}
|
||||
|
||||
return this.create(ctx, dto);
|
||||
}
|
||||
|
||||
async update(ctx: ServiceContext, id: string, dto: UpdateHorasTrabajadasDto): Promise<HorasTrabajadas | null> {
|
||||
const horas = await this.findById(ctx, id);
|
||||
if (!horas) return null;
|
||||
|
||||
if (dto.horasTotales !== undefined) horas.horasTotales = dto.horasTotales;
|
||||
if (dto.trabajadoresPromedio !== undefined) horas.trabajadoresPromedio = dto.trabajadoresPromedio;
|
||||
if (dto.fuente) horas.fuente = dto.fuente;
|
||||
|
||||
return this.repository.save(horas);
|
||||
}
|
||||
|
||||
async delete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const horas = await this.findById(ctx, id);
|
||||
if (!horas) return false;
|
||||
|
||||
await this.repository.remove(horas);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ========== Consultas por Período ==========
|
||||
async getHorasByPeriodo(
|
||||
ctx: ServiceContext,
|
||||
fraccionamientoId: string,
|
||||
fechaInicio: Date,
|
||||
fechaFin: Date
|
||||
): Promise<HorasTrabajadas[]> {
|
||||
return this.repository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
fraccionamientoId,
|
||||
fecha: Between(fechaInicio, fechaFin),
|
||||
} as FindOptionsWhere<HorasTrabajadas>,
|
||||
order: { fecha: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async getTotalHorasByPeriodo(
|
||||
ctx: ServiceContext,
|
||||
fraccionamientoId: string,
|
||||
fechaInicio: Date,
|
||||
fechaFin: Date
|
||||
): Promise<number> {
|
||||
const result = await this.repository
|
||||
.createQueryBuilder('h')
|
||||
.select('SUM(h.horas_totales)', 'total')
|
||||
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('h.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId })
|
||||
.andWhere('h.fecha >= :fechaInicio', { fechaInicio })
|
||||
.andWhere('h.fecha <= :fechaFin', { fechaFin })
|
||||
.getRawOne();
|
||||
|
||||
return parseFloat(result?.total || '0');
|
||||
}
|
||||
|
||||
async getHorasByYear(ctx: ServiceContext, fraccionamientoId: string, year: number): Promise<HorasTrabajadas[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('h')
|
||||
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('h.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId })
|
||||
.andWhere('EXTRACT(YEAR FROM h.fecha) = :year', { year })
|
||||
.orderBy('h.fecha', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
// ========== Resumen Mensual ==========
|
||||
async getResumenMensual(
|
||||
ctx: ServiceContext,
|
||||
fraccionamientoId: string,
|
||||
year: number
|
||||
): Promise<HorasTrabajadasResumen[]> {
|
||||
const result = await this.repository
|
||||
.createQueryBuilder('h')
|
||||
.select([
|
||||
'h.fraccionamiento_id AS "fraccionamientoId"',
|
||||
"TO_CHAR(h.fecha, 'YYYY-MM') AS periodo",
|
||||
'SUM(h.horas_totales) AS "totalHoras"',
|
||||
'AVG(h.trabajadores_promedio) AS "promedioTrabajadores"',
|
||||
'COUNT(*) AS "diasRegistrados"',
|
||||
])
|
||||
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('h.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId })
|
||||
.andWhere('EXTRACT(YEAR FROM h.fecha) = :year', { year })
|
||||
.groupBy('h.fraccionamiento_id')
|
||||
.addGroupBy("TO_CHAR(h.fecha, 'YYYY-MM')")
|
||||
.orderBy('periodo', 'ASC')
|
||||
.getRawMany();
|
||||
|
||||
return result.map((r) => ({
|
||||
fraccionamientoId: r.fraccionamientoId,
|
||||
periodo: r.periodo,
|
||||
totalHoras: parseFloat(r.totalHoras || '0'),
|
||||
promedioTrabajadores: Math.round(parseFloat(r.promedioTrabajadores || '0')),
|
||||
diasRegistrados: parseInt(r.diasRegistrados, 10),
|
||||
}));
|
||||
}
|
||||
|
||||
// ========== Acumulados ==========
|
||||
async getHorasAcumuladasYear(ctx: ServiceContext, fraccionamientoId: string, year: number): Promise<number> {
|
||||
const result = await this.repository
|
||||
.createQueryBuilder('h')
|
||||
.select('SUM(h.horas_totales)', 'total')
|
||||
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('h.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId })
|
||||
.andWhere('EXTRACT(YEAR FROM h.fecha) = :year', { year })
|
||||
.getRawOne();
|
||||
|
||||
return parseFloat(result?.total || '0');
|
||||
}
|
||||
|
||||
async getHorasAcumuladasHistorico(ctx: ServiceContext, fraccionamientoId: string): Promise<number> {
|
||||
const result = await this.repository
|
||||
.createQueryBuilder('h')
|
||||
.select('SUM(h.horas_totales)', 'total')
|
||||
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('h.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId })
|
||||
.getRawOne();
|
||||
|
||||
return parseFloat(result?.total || '0');
|
||||
}
|
||||
|
||||
// ========== Estadísticas ==========
|
||||
async getStats(ctx: ServiceContext, fraccionamientoId: string): Promise<HorasTrabajadasStats> {
|
||||
const today = new Date();
|
||||
const ultimoDiaMesAnterior = new Date(today.getFullYear(), today.getMonth(), 0);
|
||||
const primerDiaMesAnterior = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
||||
|
||||
const totalHorasAcumuladas = await this.getHorasAcumuladasHistorico(ctx, fraccionamientoId);
|
||||
|
||||
const horasUltimoMes = await this.getTotalHorasByPeriodo(
|
||||
ctx,
|
||||
fraccionamientoId,
|
||||
primerDiaMesAnterior,
|
||||
ultimoDiaMesAnterior
|
||||
);
|
||||
|
||||
const registros = await this.repository
|
||||
.createQueryBuilder('h')
|
||||
.select([
|
||||
'AVG(h.trabajadores_promedio) AS "promedioTrabajadores"',
|
||||
'COUNT(*) AS "diasRegistrados"',
|
||||
])
|
||||
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('h.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId })
|
||||
.getRawOne();
|
||||
|
||||
const promedioTrabajadoresDiario = Math.round(parseFloat(registros?.promedioTrabajadores || '0'));
|
||||
const diasRegistrados = parseInt(registros?.diasRegistrados || '0', 10);
|
||||
|
||||
const mesesConDatos = await this.repository
|
||||
.createQueryBuilder('h')
|
||||
.select("COUNT(DISTINCT TO_CHAR(h.fecha, 'YYYY-MM'))", 'meses')
|
||||
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('h.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId })
|
||||
.getRawOne();
|
||||
|
||||
const numMeses = parseInt(mesesConDatos?.meses || '1', 10);
|
||||
const horasPromedioMensual = numMeses > 0 ? Math.round(totalHorasAcumuladas / numMeses) : 0;
|
||||
|
||||
return {
|
||||
totalHorasAcumuladas,
|
||||
horasUltimoMes,
|
||||
promedioTrabajadoresDiario,
|
||||
diasRegistrados,
|
||||
horasPromedioMensual,
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Importación desde Asistencia ==========
|
||||
async importarDesdeAsistencia(
|
||||
ctx: ServiceContext,
|
||||
fraccionamientoId: string,
|
||||
fecha: Date,
|
||||
horasTotales: number,
|
||||
trabajadoresPromedio: number
|
||||
): Promise<HorasTrabajadas> {
|
||||
return this.upsert(ctx, {
|
||||
fraccionamientoId,
|
||||
fecha,
|
||||
horasTotales,
|
||||
trabajadoresPromedio,
|
||||
fuente: 'asistencia',
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Cálculo de Índices ==========
|
||||
async calcularIndiceIncidencia(
|
||||
ctx: ServiceContext,
|
||||
fraccionamientoId: string,
|
||||
numeroIncidentes: number,
|
||||
fechaInicio: Date,
|
||||
fechaFin: Date
|
||||
): Promise<number> {
|
||||
const totalHoras = await this.getTotalHorasByPeriodo(ctx, fraccionamientoId, fechaInicio, fechaFin);
|
||||
if (totalHoras === 0) return 0;
|
||||
|
||||
return (numeroIncidentes * 1000000) / totalHoras;
|
||||
}
|
||||
|
||||
async calcularIndiceFrecuencia(
|
||||
ctx: ServiceContext,
|
||||
fraccionamientoId: string,
|
||||
accidentesConBaja: number,
|
||||
fechaInicio: Date,
|
||||
fechaFin: Date
|
||||
): Promise<number> {
|
||||
const totalHoras = await this.getTotalHorasByPeriodo(ctx, fraccionamientoId, fechaInicio, fechaFin);
|
||||
if (totalHoras === 0) return 0;
|
||||
|
||||
return (accidentesConBaja * 1000000) / totalHoras;
|
||||
}
|
||||
|
||||
async calcularIndiceGravedad(
|
||||
ctx: ServiceContext,
|
||||
fraccionamientoId: string,
|
||||
diasPerdidos: number,
|
||||
fechaInicio: Date,
|
||||
fechaFin: Date
|
||||
): Promise<number> {
|
||||
const totalHoras = await this.getTotalHorasByPeriodo(ctx, fraccionamientoId, fechaInicio, fechaFin);
|
||||
if (totalHoras === 0) return 0;
|
||||
|
||||
return (diasPerdidos * 1000000) / totalHoras;
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,7 @@
|
||||
*
|
||||
* Services for Health, Safety & Environment module
|
||||
* Based on RF-MAA017-001 to RF-MAA017-008
|
||||
* Updated: 2025-12-18
|
||||
* Updated: 2026-01-30
|
||||
*/
|
||||
|
||||
// RF-MAA017-001: Gestión de Incidentes
|
||||
@ -12,21 +12,45 @@ export * from './incidente.service';
|
||||
|
||||
// RF-MAA017-002: Control de Capacitaciones
|
||||
export * from './capacitacion.service';
|
||||
export * from './instructor.service';
|
||||
export * from './sesion-capacitacion.service';
|
||||
export * from './constancia-dc3.service';
|
||||
|
||||
// RF-MAA017-003: Inspecciones de Seguridad
|
||||
export * from './inspeccion.service';
|
||||
|
||||
// RF-MAA017-003: Gestión de Hallazgos (standalone)
|
||||
export * from './hallazgo.service';
|
||||
|
||||
// RF-MAA017-004: Control de EPP
|
||||
export * from './epp.service';
|
||||
|
||||
// RF-MAA017-005: Cumplimiento STPS
|
||||
export * from './stps.service';
|
||||
export * from './cumplimiento.service';
|
||||
|
||||
// RF-MAA017-005: Comisiones de Seguridad (NOM-019)
|
||||
export * from './comision-seguridad.service';
|
||||
|
||||
// RF-MAA017-005: Programas de Seguridad
|
||||
export * from './programa-seguridad.service';
|
||||
|
||||
// RF-MAA017-006: Gestión Ambiental
|
||||
export * from './ambiental.service';
|
||||
export * from './residuo-peligroso.service';
|
||||
export * from './almacen-temporal.service';
|
||||
export * from './proveedor-ambiental.service';
|
||||
export * from './auditoria-ambiental.service';
|
||||
|
||||
// RF-MAA017-007: Permisos de Trabajo
|
||||
export * from './permiso-trabajo.service';
|
||||
|
||||
// RF-MAA017-008: Indicadores HSE
|
||||
export * from './indicador.service';
|
||||
export * from './horas-trabajadas.service';
|
||||
|
||||
// RF-MAA017-008: Días Sin Accidente
|
||||
export * from './dias-sin-accidente.service';
|
||||
|
||||
// RF-MAA017-008: Reportes Programados
|
||||
export * from './reporte-programado.service';
|
||||
|
||||
189
src/modules/hse/services/instructor.service.ts
Normal file
189
src/modules/hse/services/instructor.service.ts
Normal file
@ -0,0 +1,189 @@
|
||||
/**
|
||||
* InstructorService - Servicio para gestión de instructores HSE
|
||||
*
|
||||
* RF-MAA017-002: Control de Capacitaciones - Instructores
|
||||
*
|
||||
* @module HSE
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { Instructor } from '../entities/instructor.entity';
|
||||
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||
|
||||
export interface CreateInstructorDto {
|
||||
nombre: string;
|
||||
registroStps?: string;
|
||||
especialidades?: string;
|
||||
esInterno?: boolean;
|
||||
employeeId?: string;
|
||||
contactoTelefono?: string;
|
||||
contactoEmail?: string;
|
||||
}
|
||||
|
||||
export interface UpdateInstructorDto {
|
||||
nombre?: string;
|
||||
registroStps?: string;
|
||||
especialidades?: string;
|
||||
esInterno?: boolean;
|
||||
employeeId?: string;
|
||||
contactoTelefono?: string;
|
||||
contactoEmail?: string;
|
||||
activo?: boolean;
|
||||
}
|
||||
|
||||
export interface InstructorFilters {
|
||||
esInterno?: boolean;
|
||||
activo?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class InstructorService {
|
||||
constructor(private readonly repository: Repository<Instructor>) {}
|
||||
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters: InstructorFilters = {},
|
||||
page: number = 1,
|
||||
limit: number = 20
|
||||
): Promise<PaginatedResult<Instructor>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.repository
|
||||
.createQueryBuilder('instructor')
|
||||
.leftJoinAndSelect('instructor.employee', 'employee')
|
||||
.where('instructor.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (filters.esInterno !== undefined) {
|
||||
queryBuilder.andWhere('instructor.es_interno = :esInterno', { esInterno: filters.esInterno });
|
||||
}
|
||||
|
||||
if (filters.activo !== undefined) {
|
||||
queryBuilder.andWhere('instructor.activo = :activo', { activo: filters.activo });
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
queryBuilder.andWhere(
|
||||
'(instructor.nombre ILIKE :search OR instructor.registro_stps ILIKE :search OR instructor.especialidades ILIKE :search)',
|
||||
{ search: `%${filters.search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
queryBuilder
|
||||
.orderBy('instructor.nombre', 'ASC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async findById(ctx: ServiceContext, id: string): Promise<Instructor | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
} as FindOptionsWhere<Instructor>,
|
||||
relations: ['employee'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByRegistroStps(ctx: ServiceContext, registroStps: string): Promise<Instructor | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
registroStps,
|
||||
tenantId: ctx.tenantId,
|
||||
} as FindOptionsWhere<Instructor>,
|
||||
});
|
||||
}
|
||||
|
||||
async create(ctx: ServiceContext, dto: CreateInstructorDto): Promise<Instructor> {
|
||||
if (dto.registroStps) {
|
||||
const existing = await this.findByRegistroStps(ctx, dto.registroStps);
|
||||
if (existing) {
|
||||
throw new Error(`Instructor with registro STPS ${dto.registroStps} already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
const instructor = this.repository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
nombre: dto.nombre,
|
||||
registroStps: dto.registroStps,
|
||||
especialidades: dto.especialidades,
|
||||
esInterno: dto.esInterno || false,
|
||||
employeeId: dto.employeeId,
|
||||
contactoTelefono: dto.contactoTelefono,
|
||||
contactoEmail: dto.contactoEmail,
|
||||
activo: true,
|
||||
});
|
||||
|
||||
return this.repository.save(instructor);
|
||||
}
|
||||
|
||||
async update(ctx: ServiceContext, id: string, dto: UpdateInstructorDto): Promise<Instructor | null> {
|
||||
const existing = await this.findById(ctx, id);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (dto.registroStps && dto.registroStps !== existing.registroStps) {
|
||||
const duplicate = await this.findByRegistroStps(ctx, dto.registroStps);
|
||||
if (duplicate) {
|
||||
throw new Error(`Instructor with registro STPS ${dto.registroStps} already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = this.repository.merge(existing, dto);
|
||||
return this.repository.save(updated);
|
||||
}
|
||||
|
||||
async toggleActive(ctx: ServiceContext, id: string): Promise<Instructor | null> {
|
||||
const existing = await this.findById(ctx, id);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
existing.activo = !existing.activo;
|
||||
return this.repository.save(existing);
|
||||
}
|
||||
|
||||
async getInstructoresActivos(ctx: ServiceContext): Promise<Instructor[]> {
|
||||
return this.repository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
activo: true,
|
||||
} as FindOptionsWhere<Instructor>,
|
||||
relations: ['employee'],
|
||||
order: { nombre: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async getInstructoresInternos(ctx: ServiceContext): Promise<Instructor[]> {
|
||||
return this.repository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
esInterno: true,
|
||||
activo: true,
|
||||
} as FindOptionsWhere<Instructor>,
|
||||
relations: ['employee'],
|
||||
order: { nombre: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async getInstructoresExternos(ctx: ServiceContext): Promise<Instructor[]> {
|
||||
return this.repository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
esInterno: false,
|
||||
activo: true,
|
||||
} as FindOptionsWhere<Instructor>,
|
||||
order: { nombre: 'ASC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
661
src/modules/hse/services/programa-seguridad.service.ts
Normal file
661
src/modules/hse/services/programa-seguridad.service.ts
Normal file
@ -0,0 +1,661 @@
|
||||
/**
|
||||
* ProgramaSeguridadService - Servicio para programas de seguridad
|
||||
*
|
||||
* RF-MAA017-005: Gestion de programas anuales de seguridad y sus actividades
|
||||
*
|
||||
* @module HSE
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { ProgramaSeguridad, EstadoPrograma } from '../entities/programa-seguridad.entity';
|
||||
import { ProgramaActividad, TipoActividadPrograma, EstadoActividad } from '../entities/programa-actividad.entity';
|
||||
import { ProgramaInspeccion, EstadoInspeccion } from '../entities/programa-inspeccion.entity';
|
||||
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||
|
||||
export interface ProgramaFilters {
|
||||
fraccionamientoId?: string;
|
||||
anio?: number;
|
||||
estado?: EstadoPrograma;
|
||||
}
|
||||
|
||||
export interface ActividadFilters {
|
||||
programaId?: string;
|
||||
tipo?: TipoActividadPrograma;
|
||||
estado?: EstadoActividad;
|
||||
responsableId?: string;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
}
|
||||
|
||||
export interface InspeccionProgramadaFilters {
|
||||
fraccionamientoId?: string;
|
||||
tipoInspeccionId?: string;
|
||||
inspectorId?: string;
|
||||
estado?: EstadoInspeccion;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
}
|
||||
|
||||
export interface CreateProgramaSeguridadDto {
|
||||
fraccionamientoId: string;
|
||||
anio: number;
|
||||
objetivoGeneral?: string;
|
||||
metas?: Record<string, unknown>;
|
||||
presupuesto?: number;
|
||||
}
|
||||
|
||||
export interface UpdateProgramaSeguridadDto {
|
||||
objetivoGeneral?: string;
|
||||
metas?: Record<string, unknown>;
|
||||
presupuesto?: number;
|
||||
}
|
||||
|
||||
export interface CreateActividadProgramaDto {
|
||||
programaId: string;
|
||||
actividad: string;
|
||||
tipo: TipoActividadPrograma;
|
||||
fechaProgramada: Date;
|
||||
responsableId?: string;
|
||||
recursos?: string;
|
||||
}
|
||||
|
||||
export interface UpdateActividadDto {
|
||||
actividad?: string;
|
||||
tipo?: TipoActividadPrograma;
|
||||
fechaProgramada?: Date;
|
||||
responsableId?: string;
|
||||
recursos?: string;
|
||||
estado?: EstadoActividad;
|
||||
evidenciaUrl?: string;
|
||||
}
|
||||
|
||||
export interface CreateInspeccionProgramadaDto {
|
||||
fraccionamientoId: string;
|
||||
tipoInspeccionId: string;
|
||||
inspectorId?: string;
|
||||
fechaProgramada: Date;
|
||||
horaProgramada?: string;
|
||||
zonaArea?: string;
|
||||
}
|
||||
|
||||
export interface ProgramaStats {
|
||||
totalProgramas: number;
|
||||
programasActivos: number;
|
||||
actividadesPendientes: number;
|
||||
actividadesCompletadas: number;
|
||||
cumplimiento: number;
|
||||
}
|
||||
|
||||
export interface CalendarioActividad {
|
||||
id: string;
|
||||
fecha: Date;
|
||||
titulo: string;
|
||||
tipo: TipoActividadPrograma | 'inspeccion';
|
||||
estado: string;
|
||||
responsable?: string;
|
||||
}
|
||||
|
||||
export class ProgramaSeguridadService {
|
||||
constructor(
|
||||
private readonly programaRepository: Repository<ProgramaSeguridad>,
|
||||
private readonly actividadRepository: Repository<ProgramaActividad>,
|
||||
private readonly inspeccionProgramadaRepository: Repository<ProgramaInspeccion>
|
||||
) {}
|
||||
|
||||
// ========== Programas ==========
|
||||
|
||||
/**
|
||||
* Lista programas con filtros
|
||||
*/
|
||||
async findProgramas(
|
||||
ctx: ServiceContext,
|
||||
filters: ProgramaFilters = {},
|
||||
page: number = 1,
|
||||
limit: number = 20
|
||||
): Promise<PaginatedResult<ProgramaSeguridad>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.programaRepository
|
||||
.createQueryBuilder('programa')
|
||||
.leftJoinAndSelect('programa.fraccionamiento', 'fraccionamiento')
|
||||
.leftJoinAndSelect('programa.aprobadoPor', 'aprobadoPor')
|
||||
.where('programa.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (filters.fraccionamientoId) {
|
||||
queryBuilder.andWhere('programa.fraccionamiento_id = :fraccionamientoId', {
|
||||
fraccionamientoId: filters.fraccionamientoId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.anio) {
|
||||
queryBuilder.andWhere('programa.anio = :anio', { anio: filters.anio });
|
||||
}
|
||||
|
||||
if (filters.estado) {
|
||||
queryBuilder.andWhere('programa.estado = :estado', { estado: filters.estado });
|
||||
}
|
||||
|
||||
queryBuilder
|
||||
.orderBy('programa.anio', 'DESC')
|
||||
.addOrderBy('programa.created_at', 'DESC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene programa por ID
|
||||
*/
|
||||
async findProgramaById(ctx: ServiceContext, id: string): Promise<ProgramaSeguridad | null> {
|
||||
return this.programaRepository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<ProgramaSeguridad>,
|
||||
relations: ['fraccionamiento', 'aprobadoPor'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene programa con todas sus actividades
|
||||
*/
|
||||
async findProgramaWithActividades(ctx: ServiceContext, id: string): Promise<ProgramaSeguridad | null> {
|
||||
return this.programaRepository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<ProgramaSeguridad>,
|
||||
relations: ['fraccionamiento', 'aprobadoPor', 'actividades', 'actividades.responsable'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un nuevo programa
|
||||
*/
|
||||
async createPrograma(ctx: ServiceContext, dto: CreateProgramaSeguridadDto): Promise<ProgramaSeguridad> {
|
||||
// Verificar si ya existe un programa para ese anio y fraccionamiento
|
||||
const existing = await this.programaRepository.findOne({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
fraccionamientoId: dto.fraccionamientoId,
|
||||
anio: dto.anio,
|
||||
} as FindOptionsWhere<ProgramaSeguridad>,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error(`Ya existe un programa de seguridad para el anio ${dto.anio} en esta obra`);
|
||||
}
|
||||
|
||||
const programa = this.programaRepository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
fraccionamientoId: dto.fraccionamientoId,
|
||||
anio: dto.anio,
|
||||
objetivoGeneral: dto.objetivoGeneral,
|
||||
metas: dto.metas,
|
||||
presupuesto: dto.presupuesto,
|
||||
estado: 'borrador',
|
||||
});
|
||||
|
||||
return this.programaRepository.save(programa);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza un programa
|
||||
*/
|
||||
async updatePrograma(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
dto: UpdateProgramaSeguridadDto
|
||||
): Promise<ProgramaSeguridad | null> {
|
||||
const programa = await this.findProgramaById(ctx, id);
|
||||
if (!programa) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (programa.estado === 'finalizado') {
|
||||
throw new Error('No se puede modificar un programa finalizado');
|
||||
}
|
||||
|
||||
const updated = this.programaRepository.merge(programa, dto);
|
||||
return this.programaRepository.save(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activa un programa
|
||||
*/
|
||||
async activarPrograma(ctx: ServiceContext, id: string): Promise<ProgramaSeguridad | null> {
|
||||
const programa = await this.findProgramaById(ctx, id);
|
||||
if (!programa) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (programa.estado !== 'borrador') {
|
||||
throw new Error('Solo se pueden activar programas en borrador');
|
||||
}
|
||||
|
||||
programa.estado = 'activo';
|
||||
programa.aprobadoPorId = ctx.userId || '';
|
||||
programa.fechaAprobacion = new Date();
|
||||
|
||||
return this.programaRepository.save(programa);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finaliza un programa
|
||||
*/
|
||||
async finalizarPrograma(ctx: ServiceContext, id: string): Promise<ProgramaSeguridad | null> {
|
||||
const programa = await this.findProgramaById(ctx, id);
|
||||
if (!programa) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (programa.estado !== 'activo') {
|
||||
throw new Error('Solo se pueden finalizar programas activos');
|
||||
}
|
||||
|
||||
programa.estado = 'finalizado';
|
||||
return this.programaRepository.save(programa);
|
||||
}
|
||||
|
||||
// ========== Actividades ==========
|
||||
|
||||
/**
|
||||
* Lista actividades con filtros
|
||||
*/
|
||||
async findActividades(
|
||||
ctx: ServiceContext,
|
||||
filters: ActividadFilters = {},
|
||||
page: number = 1,
|
||||
limit: number = 20
|
||||
): Promise<PaginatedResult<ProgramaActividad>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.actividadRepository
|
||||
.createQueryBuilder('actividad')
|
||||
.leftJoinAndSelect('actividad.programa', 'programa')
|
||||
.leftJoinAndSelect('actividad.responsable', 'responsable')
|
||||
.where('programa.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (filters.programaId) {
|
||||
queryBuilder.andWhere('actividad.programa_id = :programaId', {
|
||||
programaId: filters.programaId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.tipo) {
|
||||
queryBuilder.andWhere('actividad.tipo = :tipo', { tipo: filters.tipo });
|
||||
}
|
||||
|
||||
if (filters.estado) {
|
||||
queryBuilder.andWhere('actividad.estado = :estado', { estado: filters.estado });
|
||||
}
|
||||
|
||||
if (filters.responsableId) {
|
||||
queryBuilder.andWhere('actividad.responsable_id = :responsableId', {
|
||||
responsableId: filters.responsableId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.dateFrom) {
|
||||
queryBuilder.andWhere('actividad.fecha_programada >= :dateFrom', { dateFrom: filters.dateFrom });
|
||||
}
|
||||
|
||||
if (filters.dateTo) {
|
||||
queryBuilder.andWhere('actividad.fecha_programada <= :dateTo', { dateTo: filters.dateTo });
|
||||
}
|
||||
|
||||
queryBuilder
|
||||
.orderBy('actividad.fecha_programada', 'ASC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene actividad por ID
|
||||
*/
|
||||
async findActividadById(_ctx: ServiceContext, id: string): Promise<ProgramaActividad | null> {
|
||||
return this.actividadRepository.findOne({
|
||||
where: { id } as FindOptionsWhere<ProgramaActividad>,
|
||||
relations: ['programa', 'responsable'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea una nueva actividad
|
||||
*/
|
||||
async createActividad(ctx: ServiceContext, dto: CreateActividadProgramaDto): Promise<ProgramaActividad> {
|
||||
const programa = await this.findProgramaById(ctx, dto.programaId);
|
||||
if (!programa) {
|
||||
throw new Error('Programa no encontrado');
|
||||
}
|
||||
|
||||
if (programa.estado === 'finalizado') {
|
||||
throw new Error('No se pueden agregar actividades a un programa finalizado');
|
||||
}
|
||||
|
||||
const actividad = this.actividadRepository.create({
|
||||
programaId: dto.programaId,
|
||||
actividad: dto.actividad,
|
||||
tipo: dto.tipo,
|
||||
fechaProgramada: dto.fechaProgramada,
|
||||
responsableId: dto.responsableId,
|
||||
recursos: dto.recursos,
|
||||
estado: 'pendiente',
|
||||
});
|
||||
|
||||
return this.actividadRepository.save(actividad);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza una actividad
|
||||
*/
|
||||
async updateActividad(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
dto: UpdateActividadDto
|
||||
): Promise<ProgramaActividad | null> {
|
||||
const actividad = await this.findActividadById(ctx, id);
|
||||
if (!actividad) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updated = this.actividadRepository.merge(actividad, dto);
|
||||
|
||||
// Si se completa, registrar fecha
|
||||
if (dto.estado === 'completada' && actividad.estado !== 'completada') {
|
||||
updated.fechaRealizada = new Date();
|
||||
}
|
||||
|
||||
return this.actividadRepository.save(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marca actividad como completada
|
||||
*/
|
||||
async completarActividad(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
evidenciaUrl?: string
|
||||
): Promise<ProgramaActividad | null> {
|
||||
const actividad = await this.findActividadById(ctx, id);
|
||||
if (!actividad) {
|
||||
return null;
|
||||
}
|
||||
|
||||
actividad.estado = 'completada';
|
||||
actividad.fechaRealizada = new Date();
|
||||
if (evidenciaUrl) {
|
||||
actividad.evidenciaUrl = evidenciaUrl;
|
||||
}
|
||||
|
||||
return this.actividadRepository.save(actividad);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancela una actividad
|
||||
*/
|
||||
async cancelarActividad(ctx: ServiceContext, id: string): Promise<ProgramaActividad | null> {
|
||||
const actividad = await this.findActividadById(ctx, id);
|
||||
if (!actividad) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (actividad.estado === 'completada') {
|
||||
throw new Error('No se puede cancelar una actividad completada');
|
||||
}
|
||||
|
||||
actividad.estado = 'cancelada';
|
||||
return this.actividadRepository.save(actividad);
|
||||
}
|
||||
|
||||
// ========== Inspecciones Programadas ==========
|
||||
|
||||
/**
|
||||
* Lista inspecciones programadas
|
||||
*/
|
||||
async findInspeccionesProgramadas(
|
||||
ctx: ServiceContext,
|
||||
filters: InspeccionProgramadaFilters = {},
|
||||
page: number = 1,
|
||||
limit: number = 20
|
||||
): Promise<PaginatedResult<ProgramaInspeccion>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.inspeccionProgramadaRepository
|
||||
.createQueryBuilder('pi')
|
||||
.leftJoinAndSelect('pi.fraccionamiento', 'fraccionamiento')
|
||||
.leftJoinAndSelect('pi.tipoInspeccion', 'tipoInspeccion')
|
||||
.leftJoinAndSelect('pi.inspector', 'inspector')
|
||||
.where('pi.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (filters.fraccionamientoId) {
|
||||
queryBuilder.andWhere('pi.fraccionamiento_id = :fraccionamientoId', {
|
||||
fraccionamientoId: filters.fraccionamientoId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.tipoInspeccionId) {
|
||||
queryBuilder.andWhere('pi.tipo_inspeccion_id = :tipoInspeccionId', {
|
||||
tipoInspeccionId: filters.tipoInspeccionId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.inspectorId) {
|
||||
queryBuilder.andWhere('pi.inspector_id = :inspectorId', {
|
||||
inspectorId: filters.inspectorId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.estado) {
|
||||
queryBuilder.andWhere('pi.estado = :estado', { estado: filters.estado });
|
||||
}
|
||||
|
||||
if (filters.dateFrom) {
|
||||
queryBuilder.andWhere('pi.fecha_programada >= :dateFrom', { dateFrom: filters.dateFrom });
|
||||
}
|
||||
|
||||
if (filters.dateTo) {
|
||||
queryBuilder.andWhere('pi.fecha_programada <= :dateTo', { dateTo: filters.dateTo });
|
||||
}
|
||||
|
||||
queryBuilder
|
||||
.orderBy('pi.fecha_programada', 'ASC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea una inspeccion programada
|
||||
*/
|
||||
async createInspeccionProgramada(
|
||||
ctx: ServiceContext,
|
||||
dto: CreateInspeccionProgramadaDto
|
||||
): Promise<ProgramaInspeccion> {
|
||||
const inspeccion = this.inspeccionProgramadaRepository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
fraccionamientoId: dto.fraccionamientoId,
|
||||
tipoInspeccionId: dto.tipoInspeccionId,
|
||||
inspectorId: dto.inspectorId,
|
||||
fechaProgramada: dto.fechaProgramada,
|
||||
horaProgramada: dto.horaProgramada,
|
||||
zonaArea: dto.zonaArea,
|
||||
estado: 'programada',
|
||||
});
|
||||
|
||||
return this.inspeccionProgramadaRepository.save(inspeccion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza estado de inspeccion programada
|
||||
*/
|
||||
async updateInspeccionProgramadaEstado(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
estado: EstadoInspeccion,
|
||||
motivoCancelacion?: string
|
||||
): Promise<ProgramaInspeccion | null> {
|
||||
const inspeccion = await this.inspeccionProgramadaRepository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<ProgramaInspeccion>,
|
||||
});
|
||||
|
||||
if (!inspeccion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
inspeccion.estado = estado;
|
||||
if (estado === 'cancelada' && motivoCancelacion) {
|
||||
inspeccion.motivoCancelacion = motivoCancelacion;
|
||||
}
|
||||
|
||||
return this.inspeccionProgramadaRepository.save(inspeccion);
|
||||
}
|
||||
|
||||
// ========== Calendario y Estadisticas ==========
|
||||
|
||||
/**
|
||||
* Obtiene calendario de actividades e inspecciones
|
||||
*/
|
||||
async getCalendario(
|
||||
ctx: ServiceContext,
|
||||
fraccionamientoId: string,
|
||||
dateFrom: Date,
|
||||
dateTo: Date
|
||||
): Promise<CalendarioActividad[]> {
|
||||
const calendario: CalendarioActividad[] = [];
|
||||
|
||||
// Obtener actividades del programa
|
||||
const actividades = await this.actividadRepository
|
||||
.createQueryBuilder('a')
|
||||
.leftJoinAndSelect('a.programa', 'p')
|
||||
.leftJoinAndSelect('a.responsable', 'r')
|
||||
.where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('p.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId })
|
||||
.andWhere('a.fecha_programada >= :dateFrom', { dateFrom })
|
||||
.andWhere('a.fecha_programada <= :dateTo', { dateTo })
|
||||
.getMany();
|
||||
|
||||
for (const act of actividades) {
|
||||
calendario.push({
|
||||
id: act.id,
|
||||
fecha: act.fechaProgramada,
|
||||
titulo: act.actividad,
|
||||
tipo: act.tipo,
|
||||
estado: act.estado,
|
||||
responsable: act.responsable?.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Obtener inspecciones programadas
|
||||
const inspecciones = await this.inspeccionProgramadaRepository
|
||||
.createQueryBuilder('i')
|
||||
.leftJoinAndSelect('i.tipoInspeccion', 't')
|
||||
.leftJoinAndSelect('i.inspector', 'ins')
|
||||
.where('i.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('i.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId })
|
||||
.andWhere('i.fecha_programada >= :dateFrom', { dateFrom })
|
||||
.andWhere('i.fecha_programada <= :dateTo', { dateTo })
|
||||
.getMany();
|
||||
|
||||
for (const insp of inspecciones) {
|
||||
calendario.push({
|
||||
id: insp.id,
|
||||
fecha: insp.fechaProgramada,
|
||||
titulo: `Inspeccion: ${insp.tipoInspeccion?.nombre || 'General'}`,
|
||||
tipo: 'inspeccion',
|
||||
estado: insp.estado,
|
||||
responsable: insp.inspectorId,
|
||||
});
|
||||
}
|
||||
|
||||
// Ordenar por fecha
|
||||
calendario.sort((a, b) => new Date(a.fecha).getTime() - new Date(b.fecha).getTime());
|
||||
|
||||
return calendario;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene estadisticas del programa
|
||||
*/
|
||||
async getStats(ctx: ServiceContext, fraccionamientoId?: string, anio?: number): Promise<ProgramaStats> {
|
||||
const queryBuilder = this.programaRepository
|
||||
.createQueryBuilder('p')
|
||||
.where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (fraccionamientoId) {
|
||||
queryBuilder.andWhere('p.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
|
||||
}
|
||||
|
||||
if (anio) {
|
||||
queryBuilder.andWhere('p.anio = :anio', { anio });
|
||||
}
|
||||
|
||||
const totalProgramas = await queryBuilder.getCount();
|
||||
|
||||
const programasActivos = await this.programaRepository
|
||||
.createQueryBuilder('p')
|
||||
.where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('p.estado = :estado', { estado: 'activo' })
|
||||
.getCount();
|
||||
|
||||
// Estadisticas de actividades
|
||||
const actividadesQuery = this.actividadRepository
|
||||
.createQueryBuilder('a')
|
||||
.leftJoin('a.programa', 'p')
|
||||
.where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (fraccionamientoId) {
|
||||
actividadesQuery.andWhere('p.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
|
||||
}
|
||||
|
||||
if (anio) {
|
||||
actividadesQuery.andWhere('p.anio = :anio', { anio });
|
||||
}
|
||||
|
||||
const totalActividades = await actividadesQuery.getCount();
|
||||
|
||||
const actividadesCompletadas = await actividadesQuery
|
||||
.clone()
|
||||
.andWhere('a.estado = :estado', { estado: 'completada' })
|
||||
.getCount();
|
||||
|
||||
const actividadesPendientes = await actividadesQuery
|
||||
.clone()
|
||||
.andWhere('a.estado IN (:...estados)', { estados: ['pendiente', 'en_progreso'] })
|
||||
.getCount();
|
||||
|
||||
const cumplimiento = totalActividades > 0
|
||||
? Math.round((actividadesCompletadas / totalActividades) * 100)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
totalProgramas,
|
||||
programasActivos,
|
||||
actividadesPendientes,
|
||||
actividadesCompletadas,
|
||||
cumplimiento,
|
||||
};
|
||||
}
|
||||
}
|
||||
525
src/modules/hse/services/proveedor-ambiental.service.ts
Normal file
525
src/modules/hse/services/proveedor-ambiental.service.ts
Normal file
@ -0,0 +1,525 @@
|
||||
/**
|
||||
* ProveedorAmbientalService - Servicio para gestión de proveedores ambientales
|
||||
*
|
||||
* RF-MAA017-006: Gestión de proveedores autorizados para transporte,
|
||||
* reciclaje y confinamiento de residuos.
|
||||
*
|
||||
* @module HSE
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { ProveedorAmbiental, TipoProveedorAmbiental } from '../entities/proveedor-ambiental.entity';
|
||||
import { ManifiestoResiduos } from '../entities/manifiesto-residuos.entity';
|
||||
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||
|
||||
export interface ProveedorFilters {
|
||||
tipo?: TipoProveedorAmbiental;
|
||||
activo?: boolean;
|
||||
vigente?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface CreateProveedorDto {
|
||||
razonSocial: string;
|
||||
rfc?: string;
|
||||
tipo: TipoProveedorAmbiental;
|
||||
numeroAutorizacion?: string;
|
||||
entidadAutorizadora?: string;
|
||||
fechaAutorizacion?: Date;
|
||||
fechaVencimiento?: Date;
|
||||
servicios?: string;
|
||||
contactoNombre?: string;
|
||||
contactoTelefono?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProveedorDto {
|
||||
razonSocial?: string;
|
||||
rfc?: string;
|
||||
tipo?: TipoProveedorAmbiental;
|
||||
numeroAutorizacion?: string;
|
||||
entidadAutorizadora?: string;
|
||||
fechaAutorizacion?: Date;
|
||||
fechaVencimiento?: Date;
|
||||
servicios?: string;
|
||||
contactoNombre?: string;
|
||||
contactoTelefono?: string;
|
||||
activo?: boolean;
|
||||
}
|
||||
|
||||
export interface ProveedorConHistorial extends ProveedorAmbiental {
|
||||
totalManifiestos: number;
|
||||
ultimoServicio: Date | null;
|
||||
cantidadTotalKg: number;
|
||||
}
|
||||
|
||||
export interface ProveedorStats {
|
||||
totalProveedores: number;
|
||||
proveedoresActivos: number;
|
||||
proveedoresVencidos: number;
|
||||
proximosVencer: number;
|
||||
porTipo: { tipo: string; count: number }[];
|
||||
topProveedores: { id: string; nombre: string; manifiestos: number }[];
|
||||
}
|
||||
|
||||
export interface AlertaProveedor {
|
||||
proveedorId: string;
|
||||
razonSocial: string;
|
||||
tipo: 'vencimiento' | 'inactivo' | 'sin_autorizacion';
|
||||
mensaje: string;
|
||||
diasRestantes?: number;
|
||||
nivel: 'advertencia' | 'critico';
|
||||
}
|
||||
|
||||
export class ProveedorAmbientalService {
|
||||
constructor(
|
||||
private readonly proveedorRepository: Repository<ProveedorAmbiental>,
|
||||
private readonly manifiestoRepository: Repository<ManifiestoResiduos>
|
||||
) {}
|
||||
|
||||
// ========== CRUD de Proveedores ==========
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters: ProveedorFilters = {},
|
||||
page: number = 1,
|
||||
limit: number = 20
|
||||
): Promise<PaginatedResult<ProveedorAmbiental>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.proveedorRepository
|
||||
.createQueryBuilder('p')
|
||||
.where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (filters.tipo) {
|
||||
queryBuilder.andWhere('p.tipo = :tipo', { tipo: filters.tipo });
|
||||
}
|
||||
|
||||
if (filters.activo !== undefined) {
|
||||
queryBuilder.andWhere('p.activo = :activo', { activo: filters.activo });
|
||||
}
|
||||
|
||||
if (filters.vigente === true) {
|
||||
const today = new Date();
|
||||
queryBuilder.andWhere(
|
||||
'(p.fecha_vencimiento IS NULL OR p.fecha_vencimiento >= :today)',
|
||||
{ today }
|
||||
);
|
||||
} else if (filters.vigente === false) {
|
||||
const today = new Date();
|
||||
queryBuilder.andWhere('p.fecha_vencimiento < :today', { today });
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
queryBuilder.andWhere(
|
||||
'(p.razon_social ILIKE :search OR p.rfc ILIKE :search OR p.numero_autorizacion ILIKE :search)',
|
||||
{ search: `%${filters.search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
queryBuilder
|
||||
.orderBy('p.razon_social', 'ASC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async findById(ctx: ServiceContext, id: string): Promise<ProveedorAmbiental | null> {
|
||||
return this.proveedorRepository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<ProveedorAmbiental>,
|
||||
});
|
||||
}
|
||||
|
||||
async findByTipo(ctx: ServiceContext, tipo: TipoProveedorAmbiental): Promise<ProveedorAmbiental[]> {
|
||||
return this.proveedorRepository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
tipo,
|
||||
activo: true,
|
||||
} as FindOptionsWhere<ProveedorAmbiental>,
|
||||
order: { razonSocial: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findTransportistas(ctx: ServiceContext): Promise<ProveedorAmbiental[]> {
|
||||
return this.findByTipo(ctx, 'transportista');
|
||||
}
|
||||
|
||||
async findRecicladores(ctx: ServiceContext): Promise<ProveedorAmbiental[]> {
|
||||
return this.findByTipo(ctx, 'reciclador');
|
||||
}
|
||||
|
||||
async findConfinamientos(ctx: ServiceContext): Promise<ProveedorAmbiental[]> {
|
||||
return this.findByTipo(ctx, 'confinamiento');
|
||||
}
|
||||
|
||||
async findVigentes(ctx: ServiceContext, tipo?: TipoProveedorAmbiental): Promise<ProveedorAmbiental[]> {
|
||||
const today = new Date();
|
||||
const queryBuilder = this.proveedorRepository
|
||||
.createQueryBuilder('p')
|
||||
.where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('p.activo = true')
|
||||
.andWhere('(p.fecha_vencimiento IS NULL OR p.fecha_vencimiento >= :today)', { today });
|
||||
|
||||
if (tipo) {
|
||||
queryBuilder.andWhere('p.tipo = :tipo', { tipo });
|
||||
}
|
||||
|
||||
return queryBuilder.orderBy('p.razon_social', 'ASC').getMany();
|
||||
}
|
||||
|
||||
async create(ctx: ServiceContext, dto: CreateProveedorDto): Promise<ProveedorAmbiental> {
|
||||
// Validate RFC uniqueness within tenant
|
||||
if (dto.rfc) {
|
||||
const existing = await this.proveedorRepository.findOne({
|
||||
where: { tenantId: ctx.tenantId, rfc: dto.rfc } as FindOptionsWhere<ProveedorAmbiental>,
|
||||
});
|
||||
if (existing) {
|
||||
throw new Error('Ya existe un proveedor con este RFC');
|
||||
}
|
||||
}
|
||||
|
||||
const proveedor = this.proveedorRepository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
razonSocial: dto.razonSocial,
|
||||
rfc: dto.rfc,
|
||||
tipo: dto.tipo,
|
||||
numeroAutorizacion: dto.numeroAutorizacion,
|
||||
entidadAutorizadora: dto.entidadAutorizadora,
|
||||
fechaAutorizacion: dto.fechaAutorizacion,
|
||||
fechaVencimiento: dto.fechaVencimiento,
|
||||
servicios: dto.servicios,
|
||||
contactoNombre: dto.contactoNombre,
|
||||
contactoTelefono: dto.contactoTelefono,
|
||||
activo: true,
|
||||
});
|
||||
|
||||
return this.proveedorRepository.save(proveedor);
|
||||
}
|
||||
|
||||
async update(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
dto: UpdateProveedorDto
|
||||
): Promise<ProveedorAmbiental | null> {
|
||||
const proveedor = await this.findById(ctx, id);
|
||||
if (!proveedor) return null;
|
||||
|
||||
// Validate RFC uniqueness if changing
|
||||
if (dto.rfc && dto.rfc !== proveedor.rfc) {
|
||||
const existing = await this.proveedorRepository.findOne({
|
||||
where: { tenantId: ctx.tenantId, rfc: dto.rfc } as FindOptionsWhere<ProveedorAmbiental>,
|
||||
});
|
||||
if (existing && existing.id !== id) {
|
||||
throw new Error('Ya existe un proveedor con este RFC');
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.razonSocial !== undefined) proveedor.razonSocial = dto.razonSocial;
|
||||
if (dto.rfc !== undefined) proveedor.rfc = dto.rfc;
|
||||
if (dto.tipo !== undefined) proveedor.tipo = dto.tipo;
|
||||
if (dto.numeroAutorizacion !== undefined) proveedor.numeroAutorizacion = dto.numeroAutorizacion;
|
||||
if (dto.entidadAutorizadora !== undefined) proveedor.entidadAutorizadora = dto.entidadAutorizadora;
|
||||
if (dto.fechaAutorizacion !== undefined) proveedor.fechaAutorizacion = dto.fechaAutorizacion;
|
||||
if (dto.fechaVencimiento !== undefined) proveedor.fechaVencimiento = dto.fechaVencimiento;
|
||||
if (dto.servicios !== undefined) proveedor.servicios = dto.servicios;
|
||||
if (dto.contactoNombre !== undefined) proveedor.contactoNombre = dto.contactoNombre;
|
||||
if (dto.contactoTelefono !== undefined) proveedor.contactoTelefono = dto.contactoTelefono;
|
||||
if (dto.activo !== undefined) proveedor.activo = dto.activo;
|
||||
|
||||
return this.proveedorRepository.save(proveedor);
|
||||
}
|
||||
|
||||
async activate(ctx: ServiceContext, id: string): Promise<ProveedorAmbiental | null> {
|
||||
return this.update(ctx, id, { activo: true });
|
||||
}
|
||||
|
||||
async deactivate(ctx: ServiceContext, id: string): Promise<ProveedorAmbiental | null> {
|
||||
return this.update(ctx, id, { activo: false });
|
||||
}
|
||||
|
||||
async delete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
// Check if proveedor has manifiestos
|
||||
const manifiestoCount = await this.manifiestoRepository.count({
|
||||
where: [
|
||||
{ tenantId: ctx.tenantId, transportistaId: id },
|
||||
{ tenantId: ctx.tenantId, destinoId: id },
|
||||
] as FindOptionsWhere<ManifiestoResiduos>[],
|
||||
});
|
||||
|
||||
if (manifiestoCount > 0) {
|
||||
throw new Error('No se puede eliminar un proveedor con manifiestos asociados. Desactívelo en su lugar.');
|
||||
}
|
||||
|
||||
const result = await this.proveedorRepository.delete({
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
} as FindOptionsWhere<ProveedorAmbiental>);
|
||||
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
// ========== Renovación de Autorización ==========
|
||||
async renovarAutorizacion(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
nuevaFechaVencimiento: Date,
|
||||
nuevoNumeroAutorizacion?: string
|
||||
): Promise<ProveedorAmbiental | null> {
|
||||
const proveedor = await this.findById(ctx, id);
|
||||
if (!proveedor) return null;
|
||||
|
||||
proveedor.fechaVencimiento = nuevaFechaVencimiento;
|
||||
if (nuevoNumeroAutorizacion) {
|
||||
proveedor.numeroAutorizacion = nuevoNumeroAutorizacion;
|
||||
}
|
||||
proveedor.fechaAutorizacion = new Date();
|
||||
|
||||
return this.proveedorRepository.save(proveedor);
|
||||
}
|
||||
|
||||
// ========== Historial de Servicios ==========
|
||||
async getProveedorConHistorial(
|
||||
ctx: ServiceContext,
|
||||
id: string
|
||||
): Promise<ProveedorConHistorial | null> {
|
||||
const proveedor = await this.findById(ctx, id);
|
||||
if (!proveedor) return null;
|
||||
|
||||
// Count manifiestos where this proveedor is transportista or destino
|
||||
const totalManifiestos = await this.manifiestoRepository.count({
|
||||
where: [
|
||||
{ tenantId: ctx.tenantId, transportistaId: id },
|
||||
{ tenantId: ctx.tenantId, destinoId: id },
|
||||
] as FindOptionsWhere<ManifiestoResiduos>[],
|
||||
});
|
||||
|
||||
// Get last service date
|
||||
const ultimoManifiesto = await this.manifiestoRepository.findOne({
|
||||
where: [
|
||||
{ tenantId: ctx.tenantId, transportistaId: id },
|
||||
{ tenantId: ctx.tenantId, destinoId: id },
|
||||
] as FindOptionsWhere<ManifiestoResiduos>[],
|
||||
order: { fechaRecoleccion: 'DESC' },
|
||||
});
|
||||
|
||||
// Calculate total kg handled (approximate)
|
||||
const cantidadResult = await this.manifiestoRepository
|
||||
.createQueryBuilder('m')
|
||||
.innerJoin('m.detalles', 'd')
|
||||
.where('m.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('(m.transportista_id = :proveedorId OR m.destino_id = :proveedorId)', { proveedorId: id })
|
||||
.andWhere("d.unidad = 'kg'")
|
||||
.select('SUM(d.cantidad)', 'total')
|
||||
.getRawOne();
|
||||
|
||||
return {
|
||||
...proveedor,
|
||||
totalManifiestos,
|
||||
ultimoServicio: ultimoManifiesto?.fechaRecoleccion || null,
|
||||
cantidadTotalKg: parseFloat(cantidadResult?.total || '0'),
|
||||
};
|
||||
}
|
||||
|
||||
async getManifiestosProveedor(
|
||||
ctx: ServiceContext,
|
||||
proveedorId: string,
|
||||
page: number = 1,
|
||||
limit: number = 20
|
||||
): Promise<PaginatedResult<ManifiestoResiduos>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.manifiestoRepository
|
||||
.createQueryBuilder('m')
|
||||
.leftJoinAndSelect('m.fraccionamiento', 'fraccionamiento')
|
||||
.leftJoinAndSelect('m.transportista', 'transportista')
|
||||
.leftJoinAndSelect('m.destino', 'destino')
|
||||
.where('m.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('(m.transportista_id = :proveedorId OR m.destino_id = :proveedorId)', { proveedorId })
|
||||
.orderBy('m.fecha_recoleccion', 'DESC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Alertas ==========
|
||||
async getAlertas(ctx: ServiceContext, diasProximoVencer: number = 30): Promise<AlertaProveedor[]> {
|
||||
const alertas: AlertaProveedor[] = [];
|
||||
const today = new Date();
|
||||
const proximaFecha = new Date();
|
||||
proximaFecha.setDate(proximaFecha.getDate() + diasProximoVencer);
|
||||
|
||||
// Proveedores activos
|
||||
const proveedores = await this.proveedorRepository.find({
|
||||
where: { tenantId: ctx.tenantId, activo: true } as FindOptionsWhere<ProveedorAmbiental>,
|
||||
});
|
||||
|
||||
for (const proveedor of proveedores) {
|
||||
// Sin autorización
|
||||
if (!proveedor.numeroAutorizacion) {
|
||||
alertas.push({
|
||||
proveedorId: proveedor.id,
|
||||
razonSocial: proveedor.razonSocial,
|
||||
tipo: 'sin_autorizacion',
|
||||
mensaje: 'Proveedor sin numero de autorizacion registrado',
|
||||
nivel: 'critico',
|
||||
});
|
||||
}
|
||||
|
||||
// Vencidos
|
||||
if (proveedor.fechaVencimiento && proveedor.fechaVencimiento < today) {
|
||||
const diasVencido = Math.floor(
|
||||
(today.getTime() - proveedor.fechaVencimiento.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
alertas.push({
|
||||
proveedorId: proveedor.id,
|
||||
razonSocial: proveedor.razonSocial,
|
||||
tipo: 'vencimiento',
|
||||
mensaje: `Autorizacion vencida hace ${diasVencido} dias`,
|
||||
diasRestantes: -diasVencido,
|
||||
nivel: 'critico',
|
||||
});
|
||||
}
|
||||
// Próximos a vencer
|
||||
else if (proveedor.fechaVencimiento && proveedor.fechaVencimiento <= proximaFecha) {
|
||||
const diasRestantes = Math.floor(
|
||||
(proveedor.fechaVencimiento.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
alertas.push({
|
||||
proveedorId: proveedor.id,
|
||||
razonSocial: proveedor.razonSocial,
|
||||
tipo: 'vencimiento',
|
||||
mensaje: `Autorizacion vence en ${diasRestantes} dias`,
|
||||
diasRestantes,
|
||||
nivel: 'advertencia',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by urgency
|
||||
alertas.sort((a, b) => {
|
||||
if (a.nivel === 'critico' && b.nivel !== 'critico') return -1;
|
||||
if (a.nivel !== 'critico' && b.nivel === 'critico') return 1;
|
||||
return (a.diasRestantes ?? 999) - (b.diasRestantes ?? 999);
|
||||
});
|
||||
|
||||
return alertas;
|
||||
}
|
||||
|
||||
// ========== Estadísticas ==========
|
||||
async getStats(ctx: ServiceContext): Promise<ProveedorStats> {
|
||||
const today = new Date();
|
||||
const proximaFecha = new Date();
|
||||
proximaFecha.setDate(proximaFecha.getDate() + 30);
|
||||
|
||||
const totalProveedores = await this.proveedorRepository.count({
|
||||
where: { tenantId: ctx.tenantId } as FindOptionsWhere<ProveedorAmbiental>,
|
||||
});
|
||||
|
||||
const proveedoresActivos = await this.proveedorRepository.count({
|
||||
where: { tenantId: ctx.tenantId, activo: true } as FindOptionsWhere<ProveedorAmbiental>,
|
||||
});
|
||||
|
||||
const proveedoresVencidos = await this.proveedorRepository
|
||||
.createQueryBuilder('p')
|
||||
.where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('p.activo = true')
|
||||
.andWhere('p.fecha_vencimiento < :today', { today })
|
||||
.getCount();
|
||||
|
||||
const proximosVencer = await this.proveedorRepository
|
||||
.createQueryBuilder('p')
|
||||
.where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('p.activo = true')
|
||||
.andWhere('p.fecha_vencimiento >= :today', { today })
|
||||
.andWhere('p.fecha_vencimiento <= :proximaFecha', { proximaFecha })
|
||||
.getCount();
|
||||
|
||||
const porTipoRaw = await this.proveedorRepository
|
||||
.createQueryBuilder('p')
|
||||
.select('p.tipo', 'tipo')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('p.activo = true')
|
||||
.groupBy('p.tipo')
|
||||
.getRawMany();
|
||||
|
||||
const porTipo = porTipoRaw.map((t) => ({
|
||||
tipo: t.tipo,
|
||||
count: parseInt(t.count, 10),
|
||||
}));
|
||||
|
||||
// Top proveedores by manifiestos
|
||||
const topProveedoresRaw = await this.manifiestoRepository
|
||||
.createQueryBuilder('m')
|
||||
.innerJoin('m.transportista', 't')
|
||||
.select('t.id', 'id')
|
||||
.addSelect('t.razon_social', 'nombre')
|
||||
.addSelect('COUNT(*)', 'manifiestos')
|
||||
.where('m.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.groupBy('t.id')
|
||||
.addGroupBy('t.razon_social')
|
||||
.orderBy('COUNT(*)', 'DESC')
|
||||
.limit(5)
|
||||
.getRawMany();
|
||||
|
||||
const topProveedores = topProveedoresRaw.map((p) => ({
|
||||
id: p.id,
|
||||
nombre: p.nombre,
|
||||
manifiestos: parseInt(p.manifiestos, 10),
|
||||
}));
|
||||
|
||||
return {
|
||||
totalProveedores,
|
||||
proveedoresActivos,
|
||||
proveedoresVencidos,
|
||||
proximosVencer,
|
||||
porTipo,
|
||||
topProveedores,
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Validación para Manifiestos ==========
|
||||
async validarParaManifiesto(ctx: ServiceContext, proveedorId: string): Promise<{ valido: boolean; errores: string[] }> {
|
||||
const errores: string[] = [];
|
||||
const proveedor = await this.findById(ctx, proveedorId);
|
||||
|
||||
if (!proveedor) {
|
||||
return { valido: false, errores: ['Proveedor no encontrado'] };
|
||||
}
|
||||
|
||||
if (!proveedor.activo) {
|
||||
errores.push('Proveedor inactivo');
|
||||
}
|
||||
|
||||
if (!proveedor.numeroAutorizacion) {
|
||||
errores.push('Proveedor sin numero de autorizacion');
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
if (proveedor.fechaVencimiento && proveedor.fechaVencimiento < today) {
|
||||
errores.push('Autorizacion del proveedor vencida');
|
||||
}
|
||||
|
||||
return {
|
||||
valido: errores.length === 0,
|
||||
errores,
|
||||
};
|
||||
}
|
||||
}
|
||||
557
src/modules/hse/services/reporte-programado.service.ts
Normal file
557
src/modules/hse/services/reporte-programado.service.ts
Normal file
@ -0,0 +1,557 @@
|
||||
/**
|
||||
* ReporteProgramadoService - Servicio para gestión de reportes HSE programados
|
||||
*
|
||||
* RF-MAA017-008: Gestión de reportes programados para envío automático
|
||||
* con soporte para múltiples frecuencias y formatos.
|
||||
*
|
||||
* @module HSE
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { ReporteProgramado, TipoReporteHse, FormatoReporte } from '../entities/reporte-programado.entity';
|
||||
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||
|
||||
export interface ReporteProgramadoFilters {
|
||||
tipoReporte?: TipoReporteHse;
|
||||
formato?: FormatoReporte;
|
||||
activo?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface CreateReporteProgramadoDto {
|
||||
nombre: string;
|
||||
tipoReporte: TipoReporteHse;
|
||||
indicadores?: string[];
|
||||
fraccionamientos?: string[];
|
||||
destinatarios?: string[];
|
||||
diaEnvio?: number;
|
||||
horaEnvio?: string;
|
||||
formato?: FormatoReporte;
|
||||
}
|
||||
|
||||
export interface UpdateReporteProgramadoDto {
|
||||
nombre?: string;
|
||||
tipoReporte?: TipoReporteHse;
|
||||
indicadores?: string[];
|
||||
fraccionamientos?: string[];
|
||||
destinatarios?: string[];
|
||||
diaEnvio?: number;
|
||||
horaEnvio?: string;
|
||||
formato?: FormatoReporte;
|
||||
activo?: boolean;
|
||||
}
|
||||
|
||||
export interface ReporteConProximoEnvio extends ReporteProgramado {
|
||||
proximoEnvio: Date | null;
|
||||
diasParaEnvio: number | null;
|
||||
}
|
||||
|
||||
export interface ReporteProgramadoStats {
|
||||
totalReportes: number;
|
||||
reportesActivos: number;
|
||||
porTipo: { tipo: string; count: number }[];
|
||||
porFormato: { formato: string; count: number }[];
|
||||
proximosEnvios: { reporteId: string; nombre: string; proximoEnvio: Date }[];
|
||||
enviosUltimaSemana: number;
|
||||
}
|
||||
|
||||
export interface HistorialEnvio {
|
||||
reporteId: string;
|
||||
reporteNombre: string;
|
||||
fechaEnvio: Date;
|
||||
exitoso: boolean;
|
||||
destinatarios: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class ReporteProgramadoService {
|
||||
constructor(
|
||||
private readonly reporteRepository: Repository<ReporteProgramado>
|
||||
) {}
|
||||
|
||||
// ========== CRUD de Reportes Programados ==========
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters: ReporteProgramadoFilters = {},
|
||||
page: number = 1,
|
||||
limit: number = 20
|
||||
): Promise<PaginatedResult<ReporteProgramado>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.reporteRepository
|
||||
.createQueryBuilder('r')
|
||||
.where('r.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (filters.tipoReporte) {
|
||||
queryBuilder.andWhere('r.tipo_reporte = :tipoReporte', { tipoReporte: filters.tipoReporte });
|
||||
}
|
||||
|
||||
if (filters.formato) {
|
||||
queryBuilder.andWhere('r.formato = :formato', { formato: filters.formato });
|
||||
}
|
||||
|
||||
if (filters.activo !== undefined) {
|
||||
queryBuilder.andWhere('r.activo = :activo', { activo: filters.activo });
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
queryBuilder.andWhere('r.nombre ILIKE :search', { search: `%${filters.search}%` });
|
||||
}
|
||||
|
||||
queryBuilder
|
||||
.orderBy('r.nombre', 'ASC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async findById(ctx: ServiceContext, id: string): Promise<ReporteProgramado | null> {
|
||||
return this.reporteRepository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<ReporteProgramado>,
|
||||
});
|
||||
}
|
||||
|
||||
async findActivos(ctx: ServiceContext): Promise<ReporteProgramado[]> {
|
||||
return this.reporteRepository.find({
|
||||
where: { tenantId: ctx.tenantId, activo: true } as FindOptionsWhere<ReporteProgramado>,
|
||||
order: { nombre: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findByTipo(ctx: ServiceContext, tipoReporte: TipoReporteHse): Promise<ReporteProgramado[]> {
|
||||
return this.reporteRepository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
tipoReporte,
|
||||
activo: true,
|
||||
} as FindOptionsWhere<ReporteProgramado>,
|
||||
order: { nombre: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(ctx: ServiceContext, dto: CreateReporteProgramadoDto): Promise<ReporteProgramado> {
|
||||
// Validate destinatarios are email addresses
|
||||
if (dto.destinatarios) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
for (const email of dto.destinatarios) {
|
||||
if (!emailRegex.test(email)) {
|
||||
throw new Error(`Destinatario invalido: ${email}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set default diaEnvio based on tipo
|
||||
let diaEnvio = dto.diaEnvio;
|
||||
if (diaEnvio === undefined) {
|
||||
switch (dto.tipoReporte) {
|
||||
case 'semanal':
|
||||
diaEnvio = 1; // Monday
|
||||
break;
|
||||
case 'mensual':
|
||||
diaEnvio = 1; // First day
|
||||
break;
|
||||
case 'trimestral':
|
||||
diaEnvio = 1; // First day
|
||||
break;
|
||||
case 'anual':
|
||||
diaEnvio = 15; // Mid-January
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const reporte = this.reporteRepository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
nombre: dto.nombre,
|
||||
tipoReporte: dto.tipoReporte,
|
||||
indicadores: dto.indicadores,
|
||||
fraccionamientos: dto.fraccionamientos,
|
||||
destinatarios: dto.destinatarios,
|
||||
diaEnvio,
|
||||
horaEnvio: dto.horaEnvio || '08:00',
|
||||
formato: dto.formato || 'pdf',
|
||||
activo: true,
|
||||
});
|
||||
|
||||
return this.reporteRepository.save(reporte);
|
||||
}
|
||||
|
||||
async update(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
dto: UpdateReporteProgramadoDto
|
||||
): Promise<ReporteProgramado | null> {
|
||||
const reporte = await this.findById(ctx, id);
|
||||
if (!reporte) return null;
|
||||
|
||||
// Validate destinatarios if provided
|
||||
if (dto.destinatarios) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
for (const email of dto.destinatarios) {
|
||||
if (!emailRegex.test(email)) {
|
||||
throw new Error(`Destinatario invalido: ${email}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.nombre !== undefined) reporte.nombre = dto.nombre;
|
||||
if (dto.tipoReporte !== undefined) reporte.tipoReporte = dto.tipoReporte;
|
||||
if (dto.indicadores !== undefined) reporte.indicadores = dto.indicadores;
|
||||
if (dto.fraccionamientos !== undefined) reporte.fraccionamientos = dto.fraccionamientos;
|
||||
if (dto.destinatarios !== undefined) reporte.destinatarios = dto.destinatarios;
|
||||
if (dto.diaEnvio !== undefined) reporte.diaEnvio = dto.diaEnvio;
|
||||
if (dto.horaEnvio !== undefined) reporte.horaEnvio = dto.horaEnvio;
|
||||
if (dto.formato !== undefined) reporte.formato = dto.formato;
|
||||
if (dto.activo !== undefined) reporte.activo = dto.activo;
|
||||
|
||||
return this.reporteRepository.save(reporte);
|
||||
}
|
||||
|
||||
async activate(ctx: ServiceContext, id: string): Promise<ReporteProgramado | null> {
|
||||
return this.update(ctx, id, { activo: true });
|
||||
}
|
||||
|
||||
async deactivate(ctx: ServiceContext, id: string): Promise<ReporteProgramado | null> {
|
||||
return this.update(ctx, id, { activo: false });
|
||||
}
|
||||
|
||||
async delete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const result = await this.reporteRepository.delete({
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
} as FindOptionsWhere<ReporteProgramado>);
|
||||
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
// ========== Cálculo de Próximo Envío ==========
|
||||
private calcularProximoEnvio(reporte: ReporteProgramado): Date | null {
|
||||
if (!reporte.activo) return null;
|
||||
|
||||
const now = new Date();
|
||||
const proximoEnvio = new Date();
|
||||
|
||||
// Parse hora
|
||||
const [hora, minuto] = (reporte.horaEnvio || '08:00').split(':').map(Number);
|
||||
proximoEnvio.setHours(hora, minuto, 0, 0);
|
||||
|
||||
switch (reporte.tipoReporte) {
|
||||
case 'semanal': {
|
||||
// diaEnvio: 0 = Sunday, 1 = Monday, etc.
|
||||
const dia = reporte.diaEnvio || 1;
|
||||
const diasHastaEnvio = (dia - now.getDay() + 7) % 7 || 7;
|
||||
proximoEnvio.setDate(now.getDate() + diasHastaEnvio);
|
||||
if (proximoEnvio <= now) {
|
||||
proximoEnvio.setDate(proximoEnvio.getDate() + 7);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'mensual': {
|
||||
// diaEnvio: day of month (1-28)
|
||||
const dia = Math.min(reporte.diaEnvio || 1, 28);
|
||||
proximoEnvio.setDate(dia);
|
||||
if (proximoEnvio <= now) {
|
||||
proximoEnvio.setMonth(proximoEnvio.getMonth() + 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'trimestral': {
|
||||
// diaEnvio: day of month
|
||||
const dia = Math.min(reporte.diaEnvio || 1, 28);
|
||||
const currentQuarter = Math.floor(now.getMonth() / 3);
|
||||
const nextQuarterMonth = (currentQuarter + 1) * 3;
|
||||
proximoEnvio.setMonth(nextQuarterMonth % 12);
|
||||
proximoEnvio.setDate(dia);
|
||||
if (nextQuarterMonth >= 12) {
|
||||
proximoEnvio.setFullYear(proximoEnvio.getFullYear() + 1);
|
||||
}
|
||||
if (proximoEnvio <= now) {
|
||||
proximoEnvio.setMonth(proximoEnvio.getMonth() + 3);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'anual': {
|
||||
// diaEnvio: day of January
|
||||
const dia = Math.min(reporte.diaEnvio || 15, 28);
|
||||
proximoEnvio.setMonth(0, dia); // January
|
||||
if (proximoEnvio <= now) {
|
||||
proximoEnvio.setFullYear(proximoEnvio.getFullYear() + 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return proximoEnvio;
|
||||
}
|
||||
|
||||
async findWithProximoEnvio(
|
||||
ctx: ServiceContext,
|
||||
filters: ReporteProgramadoFilters = {}
|
||||
): Promise<ReporteConProximoEnvio[]> {
|
||||
const result = await this.findAll(ctx, filters, 1, 1000);
|
||||
const now = new Date();
|
||||
|
||||
return result.data.map((reporte) => {
|
||||
const proximoEnvio = this.calcularProximoEnvio(reporte);
|
||||
const diasParaEnvio = proximoEnvio
|
||||
? Math.ceil((proximoEnvio.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
: null;
|
||||
|
||||
return {
|
||||
...reporte,
|
||||
proximoEnvio,
|
||||
diasParaEnvio,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Reportes Pendientes de Envío ==========
|
||||
async findPendientesEnvio(ctx: ServiceContext): Promise<ReporteProgramado[]> {
|
||||
const reportes = await this.findActivos(ctx);
|
||||
const pendientes: ReporteProgramado[] = [];
|
||||
const now = new Date();
|
||||
|
||||
for (const reporte of reportes) {
|
||||
const proximoEnvio = this.calcularProximoEnvio(reporte);
|
||||
if (proximoEnvio && proximoEnvio <= now) {
|
||||
pendientes.push(reporte);
|
||||
}
|
||||
}
|
||||
|
||||
return pendientes;
|
||||
}
|
||||
|
||||
// ========== Marcar Envío ==========
|
||||
async marcarEnviado(ctx: ServiceContext, id: string): Promise<ReporteProgramado | null> {
|
||||
const reporte = await this.findById(ctx, id);
|
||||
if (!reporte) return null;
|
||||
|
||||
reporte.ultimoEnvio = new Date();
|
||||
return this.reporteRepository.save(reporte);
|
||||
}
|
||||
|
||||
// ========== Gestión de Destinatarios ==========
|
||||
async addDestinatario(ctx: ServiceContext, id: string, email: string): Promise<ReporteProgramado | null> {
|
||||
const reporte = await this.findById(ctx, id);
|
||||
if (!reporte) return null;
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
throw new Error('Email invalido');
|
||||
}
|
||||
|
||||
const destinatarios = reporte.destinatarios || [];
|
||||
if (!destinatarios.includes(email)) {
|
||||
destinatarios.push(email);
|
||||
reporte.destinatarios = destinatarios;
|
||||
return this.reporteRepository.save(reporte);
|
||||
}
|
||||
|
||||
return reporte;
|
||||
}
|
||||
|
||||
async removeDestinatario(ctx: ServiceContext, id: string, email: string): Promise<ReporteProgramado | null> {
|
||||
const reporte = await this.findById(ctx, id);
|
||||
if (!reporte) return null;
|
||||
|
||||
const destinatarios = reporte.destinatarios || [];
|
||||
const index = destinatarios.indexOf(email);
|
||||
if (index > -1) {
|
||||
destinatarios.splice(index, 1);
|
||||
reporte.destinatarios = destinatarios;
|
||||
return this.reporteRepository.save(reporte);
|
||||
}
|
||||
|
||||
return reporte;
|
||||
}
|
||||
|
||||
// ========== Gestión de Indicadores ==========
|
||||
async addIndicador(ctx: ServiceContext, id: string, indicadorId: string): Promise<ReporteProgramado | null> {
|
||||
const reporte = await this.findById(ctx, id);
|
||||
if (!reporte) return null;
|
||||
|
||||
const indicadores = reporte.indicadores || [];
|
||||
if (!indicadores.includes(indicadorId)) {
|
||||
indicadores.push(indicadorId);
|
||||
reporte.indicadores = indicadores;
|
||||
return this.reporteRepository.save(reporte);
|
||||
}
|
||||
|
||||
return reporte;
|
||||
}
|
||||
|
||||
async removeIndicador(ctx: ServiceContext, id: string, indicadorId: string): Promise<ReporteProgramado | null> {
|
||||
const reporte = await this.findById(ctx, id);
|
||||
if (!reporte) return null;
|
||||
|
||||
const indicadores = reporte.indicadores || [];
|
||||
const index = indicadores.indexOf(indicadorId);
|
||||
if (index > -1) {
|
||||
indicadores.splice(index, 1);
|
||||
reporte.indicadores = indicadores;
|
||||
return this.reporteRepository.save(reporte);
|
||||
}
|
||||
|
||||
return reporte;
|
||||
}
|
||||
|
||||
// ========== Gestión de Fraccionamientos ==========
|
||||
async addFraccionamiento(ctx: ServiceContext, id: string, fraccionamientoId: string): Promise<ReporteProgramado | null> {
|
||||
const reporte = await this.findById(ctx, id);
|
||||
if (!reporte) return null;
|
||||
|
||||
const fraccionamientos = reporte.fraccionamientos || [];
|
||||
if (!fraccionamientos.includes(fraccionamientoId)) {
|
||||
fraccionamientos.push(fraccionamientoId);
|
||||
reporte.fraccionamientos = fraccionamientos;
|
||||
return this.reporteRepository.save(reporte);
|
||||
}
|
||||
|
||||
return reporte;
|
||||
}
|
||||
|
||||
async removeFraccionamiento(ctx: ServiceContext, id: string, fraccionamientoId: string): Promise<ReporteProgramado | null> {
|
||||
const reporte = await this.findById(ctx, id);
|
||||
if (!reporte) return null;
|
||||
|
||||
const fraccionamientos = reporte.fraccionamientos || [];
|
||||
const index = fraccionamientos.indexOf(fraccionamientoId);
|
||||
if (index > -1) {
|
||||
fraccionamientos.splice(index, 1);
|
||||
reporte.fraccionamientos = fraccionamientos;
|
||||
return this.reporteRepository.save(reporte);
|
||||
}
|
||||
|
||||
return reporte;
|
||||
}
|
||||
|
||||
// ========== Duplicar Reporte ==========
|
||||
async duplicar(ctx: ServiceContext, id: string, nuevoNombre: string): Promise<ReporteProgramado | null> {
|
||||
const original = await this.findById(ctx, id);
|
||||
if (!original) return null;
|
||||
|
||||
const duplicado = this.reporteRepository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
nombre: nuevoNombre,
|
||||
tipoReporte: original.tipoReporte,
|
||||
indicadores: original.indicadores ? [...original.indicadores] : undefined,
|
||||
fraccionamientos: original.fraccionamientos ? [...original.fraccionamientos] : undefined,
|
||||
destinatarios: original.destinatarios ? [...original.destinatarios] : undefined,
|
||||
diaEnvio: original.diaEnvio,
|
||||
horaEnvio: original.horaEnvio,
|
||||
formato: original.formato,
|
||||
activo: false, // Start inactive
|
||||
});
|
||||
|
||||
return this.reporteRepository.save(duplicado);
|
||||
}
|
||||
|
||||
// ========== Estadísticas ==========
|
||||
async getStats(ctx: ServiceContext): Promise<ReporteProgramadoStats> {
|
||||
const totalReportes = await this.reporteRepository.count({
|
||||
where: { tenantId: ctx.tenantId } as FindOptionsWhere<ReporteProgramado>,
|
||||
});
|
||||
|
||||
const reportesActivos = await this.reporteRepository.count({
|
||||
where: { tenantId: ctx.tenantId, activo: true } as FindOptionsWhere<ReporteProgramado>,
|
||||
});
|
||||
|
||||
// Por tipo
|
||||
const porTipoRaw = await this.reporteRepository
|
||||
.createQueryBuilder('r')
|
||||
.select('r.tipo_reporte', 'tipo')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('r.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.groupBy('r.tipo_reporte')
|
||||
.getRawMany();
|
||||
|
||||
const porTipo = porTipoRaw.map((t) => ({
|
||||
tipo: t.tipo,
|
||||
count: parseInt(t.count, 10),
|
||||
}));
|
||||
|
||||
// Por formato
|
||||
const porFormatoRaw = await this.reporteRepository
|
||||
.createQueryBuilder('r')
|
||||
.select('r.formato', 'formato')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('r.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.groupBy('r.formato')
|
||||
.getRawMany();
|
||||
|
||||
const porFormato = porFormatoRaw.map((f) => ({
|
||||
formato: f.formato,
|
||||
count: parseInt(f.count, 10),
|
||||
}));
|
||||
|
||||
// Próximos envíos
|
||||
const reportesConProximo = await this.findWithProximoEnvio(ctx, { activo: true });
|
||||
const proximosEnvios = reportesConProximo
|
||||
.filter((r) => r.proximoEnvio !== null)
|
||||
.sort((a, b) => (a.proximoEnvio!.getTime() - b.proximoEnvio!.getTime()))
|
||||
.slice(0, 5)
|
||||
.map((r) => ({
|
||||
reporteId: r.id,
|
||||
nombre: r.nombre,
|
||||
proximoEnvio: r.proximoEnvio!,
|
||||
}));
|
||||
|
||||
// Envíos última semana
|
||||
const semanaAtras = new Date();
|
||||
semanaAtras.setDate(semanaAtras.getDate() - 7);
|
||||
|
||||
const enviosUltimaSemana = await this.reporteRepository
|
||||
.createQueryBuilder('r')
|
||||
.where('r.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('r.ultimo_envio >= :semanaAtras', { semanaAtras })
|
||||
.getCount();
|
||||
|
||||
return {
|
||||
totalReportes,
|
||||
reportesActivos,
|
||||
porTipo,
|
||||
porFormato,
|
||||
proximosEnvios,
|
||||
enviosUltimaSemana,
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Validación de Configuración ==========
|
||||
async validarConfiguracion(ctx: ServiceContext, id: string): Promise<{ valido: boolean; errores: string[] }> {
|
||||
const errores: string[] = [];
|
||||
const reporte = await this.findById(ctx, id);
|
||||
|
||||
if (!reporte) {
|
||||
return { valido: false, errores: ['Reporte no encontrado'] };
|
||||
}
|
||||
|
||||
if (!reporte.destinatarios || reporte.destinatarios.length === 0) {
|
||||
errores.push('El reporte no tiene destinatarios configurados');
|
||||
}
|
||||
|
||||
if (!reporte.indicadores || reporte.indicadores.length === 0) {
|
||||
errores.push('El reporte no tiene indicadores seleccionados');
|
||||
}
|
||||
|
||||
if (!reporte.fraccionamientos || reporte.fraccionamientos.length === 0) {
|
||||
errores.push('El reporte no tiene fraccionamientos seleccionados');
|
||||
}
|
||||
|
||||
if (!reporte.diaEnvio) {
|
||||
errores.push('El reporte no tiene dia de envio configurado');
|
||||
}
|
||||
|
||||
return {
|
||||
valido: errores.length === 0,
|
||||
errores,
|
||||
};
|
||||
}
|
||||
}
|
||||
499
src/modules/hse/services/residuo-peligroso.service.ts
Normal file
499
src/modules/hse/services/residuo-peligroso.service.ts
Normal file
@ -0,0 +1,499 @@
|
||||
/**
|
||||
* ResiduoPeligrosoService - Servicio para gestión de residuos peligrosos
|
||||
*
|
||||
* RF-MAA017-006: Gestión especializada de residuos peligrosos con seguimiento
|
||||
* completo desde generación hasta disposición final.
|
||||
*
|
||||
* @module HSE
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { ResiduoCatalogo, CategoriaResiduo } from '../entities/residuo-catalogo.entity';
|
||||
import { ResiduoGeneracion, UnidadResiduo, EstadoResiduo } from '../entities/residuo-generacion.entity';
|
||||
import { AlmacenTemporal } from '../entities/almacen-temporal.entity';
|
||||
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||
|
||||
export interface ResiduoPeligrosoFilters {
|
||||
fraccionamientoId?: string;
|
||||
residuoId?: string;
|
||||
estado?: EstadoResiduo;
|
||||
categoria?: CategoriaResiduo;
|
||||
areaGeneracion?: string;
|
||||
contenedorId?: string;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
vencidos?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateResiduoPeligrosoDto {
|
||||
fraccionamientoId: string;
|
||||
residuoId: string;
|
||||
fechaGeneracion: Date;
|
||||
cantidad: number;
|
||||
unidad: UnidadResiduo;
|
||||
areaGeneracion?: string;
|
||||
fuente?: string;
|
||||
contenedorId?: string;
|
||||
fotoUrl?: string;
|
||||
ubicacionGeo?: { lat: number; lng: number };
|
||||
}
|
||||
|
||||
export interface UpdateResiduoPeligrosoDto {
|
||||
cantidad?: number;
|
||||
unidad?: UnidadResiduo;
|
||||
areaGeneracion?: string;
|
||||
fuente?: string;
|
||||
contenedorId?: string;
|
||||
fotoUrl?: string;
|
||||
}
|
||||
|
||||
export interface TransferirResiduoDto {
|
||||
contenedorDestinoId: string;
|
||||
cantidad: number;
|
||||
observaciones?: string;
|
||||
}
|
||||
|
||||
export interface ResiduoPeligrosoStats {
|
||||
totalGenerado: number;
|
||||
totalAlmacenado: number;
|
||||
totalDispuesto: number;
|
||||
residuosVencidos: number;
|
||||
proximosVencer: number;
|
||||
porCategoria: { categoria: string; cantidad: number; unidad: string }[];
|
||||
porArea: { area: string; cantidad: number }[];
|
||||
tendenciaMensual: { mes: string; cantidad: number }[];
|
||||
}
|
||||
|
||||
export interface AlertaVencimiento {
|
||||
residuoGeneracionId: string;
|
||||
residuoNombre: string;
|
||||
fechaGeneracion: Date;
|
||||
diasAlmacenado: number;
|
||||
tiempoMaximo: number;
|
||||
diasRestantes: number;
|
||||
estado: 'normal' | 'proximo' | 'vencido';
|
||||
}
|
||||
|
||||
export class ResiduoPeligrosoService {
|
||||
constructor(
|
||||
private readonly residuoCatalogoRepository: Repository<ResiduoCatalogo>,
|
||||
private readonly generacionRepository: Repository<ResiduoGeneracion>,
|
||||
private readonly almacenRepository: Repository<AlmacenTemporal>
|
||||
) {}
|
||||
|
||||
// ========== Catálogo de Residuos Peligrosos ==========
|
||||
async findResiduosPeligrosos(
|
||||
search?: string,
|
||||
activo: boolean = true
|
||||
): Promise<ResiduoCatalogo[]> {
|
||||
const queryBuilder = this.residuoCatalogoRepository
|
||||
.createQueryBuilder('r')
|
||||
.where('r.categoria = :categoria', { categoria: 'peligroso' })
|
||||
.andWhere('r.activo = :activo', { activo });
|
||||
|
||||
if (search) {
|
||||
queryBuilder.andWhere(
|
||||
'(r.codigo ILIKE :search OR r.nombre ILIKE :search)',
|
||||
{ search: `%${search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
return queryBuilder.orderBy('r.nombre', 'ASC').getMany();
|
||||
}
|
||||
|
||||
async findResiduoById(id: string): Promise<ResiduoCatalogo | null> {
|
||||
return this.residuoCatalogoRepository.findOne({
|
||||
where: { id, categoria: 'peligroso' } as FindOptionsWhere<ResiduoCatalogo>,
|
||||
});
|
||||
}
|
||||
|
||||
async findResiduoByCodigo(codigo: string): Promise<ResiduoCatalogo | null> {
|
||||
return this.residuoCatalogoRepository.findOne({
|
||||
where: { codigo } as FindOptionsWhere<ResiduoCatalogo>,
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Generación de Residuos Peligrosos ==========
|
||||
async findGeneraciones(
|
||||
ctx: ServiceContext,
|
||||
filters: ResiduoPeligrosoFilters = {},
|
||||
page: number = 1,
|
||||
limit: number = 20
|
||||
): Promise<PaginatedResult<ResiduoGeneracion>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.generacionRepository
|
||||
.createQueryBuilder('g')
|
||||
.innerJoinAndSelect('g.residuo', 'residuo')
|
||||
.leftJoinAndSelect('g.fraccionamiento', 'fraccionamiento')
|
||||
.leftJoinAndSelect('g.createdBy', 'createdBy')
|
||||
.where('g.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('residuo.categoria = :categoria', { categoria: 'peligroso' });
|
||||
|
||||
if (filters.fraccionamientoId) {
|
||||
queryBuilder.andWhere('g.fraccionamiento_id = :fraccionamientoId', {
|
||||
fraccionamientoId: filters.fraccionamientoId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.residuoId) {
|
||||
queryBuilder.andWhere('g.residuo_id = :residuoId', {
|
||||
residuoId: filters.residuoId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.estado) {
|
||||
queryBuilder.andWhere('g.estado = :estado', { estado: filters.estado });
|
||||
}
|
||||
|
||||
if (filters.areaGeneracion) {
|
||||
queryBuilder.andWhere('g.area_generacion ILIKE :area', {
|
||||
area: `%${filters.areaGeneracion}%`,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.contenedorId) {
|
||||
queryBuilder.andWhere('g.contenedor_id = :contenedorId', {
|
||||
contenedorId: filters.contenedorId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.dateFrom) {
|
||||
queryBuilder.andWhere('g.fecha_generacion >= :dateFrom', {
|
||||
dateFrom: filters.dateFrom,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.dateTo) {
|
||||
queryBuilder.andWhere('g.fecha_generacion <= :dateTo', {
|
||||
dateTo: filters.dateTo,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.vencidos) {
|
||||
// Residuos que han excedido su tiempo máximo de almacenamiento
|
||||
queryBuilder.andWhere('g.estado = :estadoAlmacenado', { estadoAlmacenado: 'almacenado' });
|
||||
queryBuilder.andWhere(
|
||||
"g.fecha_generacion + (residuo.tiempo_max_almacen_dias || ' days')::interval < NOW()"
|
||||
);
|
||||
}
|
||||
|
||||
queryBuilder
|
||||
.orderBy('g.fecha_generacion', 'DESC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async findGeneracionById(ctx: ServiceContext, id: string): Promise<ResiduoGeneracion | null> {
|
||||
return this.generacionRepository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<ResiduoGeneracion>,
|
||||
relations: ['residuo', 'fraccionamiento', 'createdBy'],
|
||||
});
|
||||
}
|
||||
|
||||
async createGeneracion(ctx: ServiceContext, dto: CreateResiduoPeligrosoDto): Promise<ResiduoGeneracion> {
|
||||
// Validate that the residuo is peligroso
|
||||
const residuo = await this.residuoCatalogoRepository.findOne({
|
||||
where: { id: dto.residuoId } as FindOptionsWhere<ResiduoCatalogo>,
|
||||
});
|
||||
|
||||
if (!residuo) {
|
||||
throw new Error('Residuo no encontrado en el catálogo');
|
||||
}
|
||||
|
||||
if (residuo.categoria !== 'peligroso') {
|
||||
throw new Error('Este servicio solo gestiona residuos peligrosos');
|
||||
}
|
||||
|
||||
const generacion = this.generacionRepository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
fraccionamientoId: dto.fraccionamientoId,
|
||||
residuoId: dto.residuoId,
|
||||
fechaGeneracion: dto.fechaGeneracion,
|
||||
cantidad: dto.cantidad,
|
||||
unidad: dto.unidad,
|
||||
areaGeneracion: dto.areaGeneracion,
|
||||
fuente: dto.fuente,
|
||||
contenedorId: dto.contenedorId,
|
||||
fotoUrl: dto.fotoUrl,
|
||||
estado: 'almacenado',
|
||||
createdById: ctx.userId,
|
||||
});
|
||||
|
||||
if (dto.ubicacionGeo) {
|
||||
generacion.ubicacionGeo = `POINT(${dto.ubicacionGeo.lng} ${dto.ubicacionGeo.lat})`;
|
||||
}
|
||||
|
||||
return this.generacionRepository.save(generacion);
|
||||
}
|
||||
|
||||
async updateGeneracion(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
dto: UpdateResiduoPeligrosoDto
|
||||
): Promise<ResiduoGeneracion | null> {
|
||||
const generacion = await this.generacionRepository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<ResiduoGeneracion>,
|
||||
});
|
||||
|
||||
if (!generacion) return null;
|
||||
|
||||
if (generacion.estado !== 'almacenado') {
|
||||
throw new Error('Solo se pueden modificar residuos en estado almacenado');
|
||||
}
|
||||
|
||||
if (dto.cantidad !== undefined) generacion.cantidad = dto.cantidad;
|
||||
if (dto.unidad !== undefined) generacion.unidad = dto.unidad;
|
||||
if (dto.areaGeneracion !== undefined) generacion.areaGeneracion = dto.areaGeneracion;
|
||||
if (dto.fuente !== undefined) generacion.fuente = dto.fuente;
|
||||
if (dto.contenedorId !== undefined) generacion.contenedorId = dto.contenedorId;
|
||||
if (dto.fotoUrl !== undefined) generacion.fotoUrl = dto.fotoUrl;
|
||||
|
||||
return this.generacionRepository.save(generacion);
|
||||
}
|
||||
|
||||
async updateEstado(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
estado: EstadoResiduo
|
||||
): Promise<ResiduoGeneracion | null> {
|
||||
const generacion = await this.generacionRepository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<ResiduoGeneracion>,
|
||||
});
|
||||
|
||||
if (!generacion) return null;
|
||||
|
||||
generacion.estado = estado;
|
||||
return this.generacionRepository.save(generacion);
|
||||
}
|
||||
|
||||
async transferirContenedor(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
dto: TransferirResiduoDto
|
||||
): Promise<ResiduoGeneracion | null> {
|
||||
const generacion = await this.generacionRepository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<ResiduoGeneracion>,
|
||||
});
|
||||
|
||||
if (!generacion) return null;
|
||||
|
||||
if (generacion.estado !== 'almacenado') {
|
||||
throw new Error('Solo se pueden transferir residuos en estado almacenado');
|
||||
}
|
||||
|
||||
// Verify destination container exists
|
||||
const contenedorDestino = await this.almacenRepository.findOne({
|
||||
where: { id: dto.contenedorDestinoId, tenantId: ctx.tenantId } as FindOptionsWhere<AlmacenTemporal>,
|
||||
});
|
||||
|
||||
if (!contenedorDestino) {
|
||||
throw new Error('Contenedor destino no encontrado');
|
||||
}
|
||||
|
||||
if (contenedorDestino.estado === 'lleno') {
|
||||
throw new Error('El contenedor destino está lleno');
|
||||
}
|
||||
|
||||
generacion.contenedorId = dto.contenedorDestinoId;
|
||||
return this.generacionRepository.save(generacion);
|
||||
}
|
||||
|
||||
// ========== Alertas de Vencimiento ==========
|
||||
async getAlertasVencimiento(
|
||||
ctx: ServiceContext,
|
||||
fraccionamientoId?: string,
|
||||
diasProximoVencer: number = 7
|
||||
): Promise<AlertaVencimiento[]> {
|
||||
const queryBuilder = this.generacionRepository
|
||||
.createQueryBuilder('g')
|
||||
.innerJoinAndSelect('g.residuo', 'r')
|
||||
.where('g.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('g.estado = :estado', { estado: 'almacenado' })
|
||||
.andWhere('r.categoria = :categoria', { categoria: 'peligroso' })
|
||||
.andWhere('r.tiempo_max_almacen_dias IS NOT NULL');
|
||||
|
||||
if (fraccionamientoId) {
|
||||
queryBuilder.andWhere('g.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
|
||||
}
|
||||
|
||||
const generaciones = await queryBuilder.getMany();
|
||||
|
||||
const alertas: AlertaVencimiento[] = [];
|
||||
const today = new Date();
|
||||
|
||||
for (const gen of generaciones) {
|
||||
const fechaGen = new Date(gen.fechaGeneracion);
|
||||
const diasAlmacenado = Math.floor((today.getTime() - fechaGen.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const tiempoMax = gen.residuo.tiempoMaxAlmacenDias || 180; // Default 180 days
|
||||
const diasRestantes = tiempoMax - diasAlmacenado;
|
||||
|
||||
let estado: 'normal' | 'proximo' | 'vencido' = 'normal';
|
||||
if (diasRestantes <= 0) {
|
||||
estado = 'vencido';
|
||||
} else if (diasRestantes <= diasProximoVencer) {
|
||||
estado = 'proximo';
|
||||
}
|
||||
|
||||
if (estado !== 'normal') {
|
||||
alertas.push({
|
||||
residuoGeneracionId: gen.id,
|
||||
residuoNombre: gen.residuo.nombre,
|
||||
fechaGeneracion: gen.fechaGeneracion,
|
||||
diasAlmacenado,
|
||||
tiempoMaximo: tiempoMax,
|
||||
diasRestantes: Math.max(0, diasRestantes),
|
||||
estado,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by dias restantes (most urgent first)
|
||||
alertas.sort((a, b) => a.diasRestantes - b.diasRestantes);
|
||||
|
||||
return alertas;
|
||||
}
|
||||
|
||||
// ========== Estadísticas ==========
|
||||
async getStats(ctx: ServiceContext, fraccionamientoId?: string): Promise<ResiduoPeligrosoStats> {
|
||||
const baseQuery = () => {
|
||||
const qb = this.generacionRepository
|
||||
.createQueryBuilder('g')
|
||||
.innerJoin('g.residuo', 'r')
|
||||
.where('g.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('r.categoria = :categoria', { categoria: 'peligroso' });
|
||||
|
||||
if (fraccionamientoId) {
|
||||
qb.andWhere('g.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
|
||||
}
|
||||
return qb;
|
||||
};
|
||||
|
||||
// Total generado
|
||||
const totalGeneradoResult = await baseQuery()
|
||||
.select('SUM(g.cantidad)', 'total')
|
||||
.getRawOne();
|
||||
const totalGenerado = parseFloat(totalGeneradoResult?.total || '0');
|
||||
|
||||
// Total almacenado
|
||||
const totalAlmacenadoResult = await baseQuery()
|
||||
.andWhere('g.estado = :estado', { estado: 'almacenado' })
|
||||
.select('SUM(g.cantidad)', 'total')
|
||||
.getRawOne();
|
||||
const totalAlmacenado = parseFloat(totalAlmacenadoResult?.total || '0');
|
||||
|
||||
// Total dispuesto
|
||||
const totalDispuestoResult = await baseQuery()
|
||||
.andWhere('g.estado = :estado', { estado: 'dispuesto' })
|
||||
.select('SUM(g.cantidad)', 'total')
|
||||
.getRawOne();
|
||||
const totalDispuesto = parseFloat(totalDispuestoResult?.total || '0');
|
||||
|
||||
// Residuos vencidos
|
||||
const vencidosResult = await baseQuery()
|
||||
.andWhere('g.estado = :estado', { estado: 'almacenado' })
|
||||
.andWhere("g.fecha_generacion + (r.tiempo_max_almacen_dias || ' days')::interval < NOW()")
|
||||
.getCount();
|
||||
|
||||
// Próximos a vencer (7 días)
|
||||
const proximosResult = await baseQuery()
|
||||
.andWhere('g.estado = :estado', { estado: 'almacenado' })
|
||||
.andWhere("g.fecha_generacion + (r.tiempo_max_almacen_dias || ' days')::interval >= NOW()")
|
||||
.andWhere("g.fecha_generacion + ((r.tiempo_max_almacen_dias - 7) || ' days')::interval < NOW()")
|
||||
.getCount();
|
||||
|
||||
// Por categoría (subcategoría CRETIB)
|
||||
const porCategoriaRaw = await baseQuery()
|
||||
.select('r.caracteristicas_cretib', 'categoria')
|
||||
.addSelect('g.unidad', 'unidad')
|
||||
.addSelect('SUM(g.cantidad)', 'cantidad')
|
||||
.groupBy('r.caracteristicas_cretib')
|
||||
.addGroupBy('g.unidad')
|
||||
.getRawMany();
|
||||
|
||||
const porCategoria = porCategoriaRaw.map((c) => ({
|
||||
categoria: c.categoria || 'Sin clasificar',
|
||||
cantidad: parseFloat(c.cantidad || '0'),
|
||||
unidad: c.unidad,
|
||||
}));
|
||||
|
||||
// Por área de generación
|
||||
const porAreaRaw = await baseQuery()
|
||||
.select('g.area_generacion', 'area')
|
||||
.addSelect('SUM(g.cantidad)', 'cantidad')
|
||||
.groupBy('g.area_generacion')
|
||||
.getRawMany();
|
||||
|
||||
const porArea = porAreaRaw
|
||||
.filter((a) => a.area)
|
||||
.map((a) => ({
|
||||
area: a.area,
|
||||
cantidad: parseFloat(a.cantidad || '0'),
|
||||
}));
|
||||
|
||||
// Tendencia mensual (últimos 6 meses)
|
||||
const sixMonthsAgo = new Date();
|
||||
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
|
||||
|
||||
const tendenciaRaw = await baseQuery()
|
||||
.andWhere('g.fecha_generacion >= :sixMonthsAgo', { sixMonthsAgo })
|
||||
.select("TO_CHAR(g.fecha_generacion, 'YYYY-MM')", 'mes')
|
||||
.addSelect('SUM(g.cantidad)', 'cantidad')
|
||||
.groupBy("TO_CHAR(g.fecha_generacion, 'YYYY-MM')")
|
||||
.orderBy("TO_CHAR(g.fecha_generacion, 'YYYY-MM')", 'ASC')
|
||||
.getRawMany();
|
||||
|
||||
const tendenciaMensual = tendenciaRaw.map((t) => ({
|
||||
mes: t.mes,
|
||||
cantidad: parseFloat(t.cantidad || '0'),
|
||||
}));
|
||||
|
||||
return {
|
||||
totalGenerado,
|
||||
totalAlmacenado,
|
||||
totalDispuesto,
|
||||
residuosVencidos: vencidosResult,
|
||||
proximosVencer: proximosResult,
|
||||
porCategoria,
|
||||
porArea,
|
||||
tendenciaMensual,
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Trazabilidad ==========
|
||||
async getHistorialResiduo(ctx: ServiceContext, residuoId: string): Promise<ResiduoGeneracion[]> {
|
||||
return this.generacionRepository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
residuoId,
|
||||
} as FindOptionsWhere<ResiduoGeneracion>,
|
||||
relations: ['residuo', 'fraccionamiento', 'createdBy'],
|
||||
order: { fechaGeneracion: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async getGeneracionesPorContenedor(
|
||||
ctx: ServiceContext,
|
||||
contenedorId: string
|
||||
): Promise<ResiduoGeneracion[]> {
|
||||
return this.generacionRepository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
contenedorId,
|
||||
estado: 'almacenado',
|
||||
} as FindOptionsWhere<ResiduoGeneracion>,
|
||||
relations: ['residuo'],
|
||||
order: { fechaGeneracion: 'ASC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
419
src/modules/hse/services/sesion-capacitacion.service.ts
Normal file
419
src/modules/hse/services/sesion-capacitacion.service.ts
Normal file
@ -0,0 +1,419 @@
|
||||
/**
|
||||
* SesionCapacitacionService - Servicio para sesiones y asistencia de capacitación
|
||||
*
|
||||
* RF-MAA017-002: Control de Capacitaciones - Sesiones y Asistencia
|
||||
*
|
||||
* @module HSE
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { CapacitacionSesion, EstadoSesion } from '../entities/capacitacion-sesion.entity';
|
||||
import { CapacitacionAsistente } from '../entities/capacitacion-asistente.entity';
|
||||
import { CapacitacionMatriz } from '../entities/capacitacion-matriz.entity';
|
||||
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||
|
||||
export interface CreateSesionDto {
|
||||
capacitacionId: string;
|
||||
fraccionamientoId?: string;
|
||||
instructorId?: string;
|
||||
fechaProgramada: Date;
|
||||
horaInicio: string;
|
||||
horaFin: string;
|
||||
lugar?: string;
|
||||
cupoMaximo?: number;
|
||||
observaciones?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSesionDto {
|
||||
instructorId?: string;
|
||||
fechaProgramada?: Date;
|
||||
horaInicio?: string;
|
||||
horaFin?: string;
|
||||
lugar?: string;
|
||||
cupoMaximo?: number;
|
||||
observaciones?: string;
|
||||
}
|
||||
|
||||
export interface SesionFilters {
|
||||
capacitacionId?: string;
|
||||
fraccionamientoId?: string;
|
||||
instructorId?: string;
|
||||
estado?: EstadoSesion;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
}
|
||||
|
||||
export interface RegistrarAsistenciaDto {
|
||||
sesionId: string;
|
||||
employeeId: string;
|
||||
asistio: boolean;
|
||||
horaEntrada?: string;
|
||||
horaSalida?: string;
|
||||
observaciones?: string;
|
||||
}
|
||||
|
||||
export interface EvaluarAsistenteDto {
|
||||
calificacion: number;
|
||||
aprobado: boolean;
|
||||
observaciones?: string;
|
||||
}
|
||||
|
||||
export interface SesionStats {
|
||||
totalSesiones: number;
|
||||
sesionesProgramadas: number;
|
||||
sesionesCompletadas: number;
|
||||
sesionesCanceladas: number;
|
||||
totalAsistentes: number;
|
||||
asistentesAprobados: number;
|
||||
}
|
||||
|
||||
export class SesionCapacitacionService {
|
||||
constructor(
|
||||
private readonly sesionRepository: Repository<CapacitacionSesion>,
|
||||
private readonly asistenteRepository: Repository<CapacitacionAsistente>,
|
||||
private readonly matrizRepository: Repository<CapacitacionMatriz>
|
||||
) {}
|
||||
|
||||
// ========== Sesiones ==========
|
||||
async findSesiones(
|
||||
ctx: ServiceContext,
|
||||
filters: SesionFilters = {},
|
||||
page: number = 1,
|
||||
limit: number = 20
|
||||
): Promise<PaginatedResult<CapacitacionSesion>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.sesionRepository
|
||||
.createQueryBuilder('sesion')
|
||||
.leftJoinAndSelect('sesion.capacitacion', 'capacitacion')
|
||||
.leftJoinAndSelect('sesion.fraccionamiento', 'fraccionamiento')
|
||||
.leftJoinAndSelect('sesion.instructor', 'instructor')
|
||||
.where('sesion.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (filters.capacitacionId) {
|
||||
queryBuilder.andWhere('sesion.capacitacion_id = :capacitacionId', {
|
||||
capacitacionId: filters.capacitacionId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.fraccionamientoId) {
|
||||
queryBuilder.andWhere('sesion.fraccionamiento_id = :fraccionamientoId', {
|
||||
fraccionamientoId: filters.fraccionamientoId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.instructorId) {
|
||||
queryBuilder.andWhere('sesion.instructor_id = :instructorId', {
|
||||
instructorId: filters.instructorId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.estado) {
|
||||
queryBuilder.andWhere('sesion.estado = :estado', { estado: filters.estado });
|
||||
}
|
||||
|
||||
if (filters.dateFrom) {
|
||||
queryBuilder.andWhere('sesion.fecha_programada >= :dateFrom', { dateFrom: filters.dateFrom });
|
||||
}
|
||||
|
||||
if (filters.dateTo) {
|
||||
queryBuilder.andWhere('sesion.fecha_programada <= :dateTo', { dateTo: filters.dateTo });
|
||||
}
|
||||
|
||||
queryBuilder
|
||||
.orderBy('sesion.fecha_programada', 'DESC')
|
||||
.addOrderBy('sesion.hora_inicio', 'ASC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async findSesionById(ctx: ServiceContext, id: string): Promise<CapacitacionSesion | null> {
|
||||
return this.sesionRepository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<CapacitacionSesion>,
|
||||
relations: ['capacitacion', 'fraccionamiento', 'instructor', 'asistentes', 'asistentes.employee'],
|
||||
});
|
||||
}
|
||||
|
||||
async createSesion(ctx: ServiceContext, dto: CreateSesionDto): Promise<CapacitacionSesion> {
|
||||
const sesion = this.sesionRepository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
capacitacionId: dto.capacitacionId,
|
||||
fraccionamientoId: dto.fraccionamientoId,
|
||||
instructorId: dto.instructorId,
|
||||
fechaProgramada: dto.fechaProgramada,
|
||||
horaInicio: dto.horaInicio,
|
||||
horaFin: dto.horaFin,
|
||||
lugar: dto.lugar,
|
||||
cupoMaximo: dto.cupoMaximo,
|
||||
observaciones: dto.observaciones,
|
||||
estado: 'programada',
|
||||
});
|
||||
|
||||
return this.sesionRepository.save(sesion);
|
||||
}
|
||||
|
||||
async updateSesion(ctx: ServiceContext, id: string, dto: UpdateSesionDto): Promise<CapacitacionSesion | null> {
|
||||
const sesion = await this.findSesionById(ctx, id);
|
||||
if (!sesion) return null;
|
||||
|
||||
if (sesion.estado !== 'programada') {
|
||||
throw new Error('Solo se pueden modificar sesiones programadas');
|
||||
}
|
||||
|
||||
Object.assign(sesion, dto);
|
||||
return this.sesionRepository.save(sesion);
|
||||
}
|
||||
|
||||
async iniciarSesion(ctx: ServiceContext, id: string): Promise<CapacitacionSesion | null> {
|
||||
const sesion = await this.findSesionById(ctx, id);
|
||||
if (!sesion) return null;
|
||||
|
||||
if (sesion.estado !== 'programada') {
|
||||
throw new Error('Solo se pueden iniciar sesiones programadas');
|
||||
}
|
||||
|
||||
sesion.estado = 'en_curso';
|
||||
return this.sesionRepository.save(sesion);
|
||||
}
|
||||
|
||||
async completarSesion(ctx: ServiceContext, id: string): Promise<CapacitacionSesion | null> {
|
||||
const sesion = await this.findSesionById(ctx, id);
|
||||
if (!sesion) return null;
|
||||
|
||||
if (sesion.estado !== 'en_curso') {
|
||||
throw new Error('Solo se pueden completar sesiones en curso');
|
||||
}
|
||||
|
||||
sesion.estado = 'completada';
|
||||
return this.sesionRepository.save(sesion);
|
||||
}
|
||||
|
||||
async cancelarSesion(ctx: ServiceContext, id: string, observaciones?: string): Promise<CapacitacionSesion | null> {
|
||||
const sesion = await this.findSesionById(ctx, id);
|
||||
if (!sesion) return null;
|
||||
|
||||
if (sesion.estado === 'completada') {
|
||||
throw new Error('No se pueden cancelar sesiones completadas');
|
||||
}
|
||||
|
||||
sesion.estado = 'cancelada';
|
||||
if (observaciones) {
|
||||
sesion.observaciones = sesion.observaciones
|
||||
? `${sesion.observaciones}\nCancelación: ${observaciones}`
|
||||
: `Cancelación: ${observaciones}`;
|
||||
}
|
||||
return this.sesionRepository.save(sesion);
|
||||
}
|
||||
|
||||
// ========== Asistentes ==========
|
||||
async findAsistentesBySesion(sesionId: string): Promise<CapacitacionAsistente[]> {
|
||||
return this.asistenteRepository.find({
|
||||
where: { sesionId } as FindOptionsWhere<CapacitacionAsistente>,
|
||||
relations: ['employee'],
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async inscribirAsistente(sesionId: string, employeeId: string): Promise<CapacitacionAsistente> {
|
||||
const existing = await this.asistenteRepository.findOne({
|
||||
where: { sesionId, employeeId } as FindOptionsWhere<CapacitacionAsistente>,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error('El empleado ya está inscrito en esta sesión');
|
||||
}
|
||||
|
||||
const asistente = this.asistenteRepository.create({
|
||||
sesionId,
|
||||
employeeId,
|
||||
asistio: false,
|
||||
});
|
||||
|
||||
return this.asistenteRepository.save(asistente);
|
||||
}
|
||||
|
||||
async registrarAsistencia(dto: RegistrarAsistenciaDto): Promise<CapacitacionAsistente> {
|
||||
let asistente = await this.asistenteRepository.findOne({
|
||||
where: {
|
||||
sesionId: dto.sesionId,
|
||||
employeeId: dto.employeeId,
|
||||
} as FindOptionsWhere<CapacitacionAsistente>,
|
||||
});
|
||||
|
||||
if (!asistente) {
|
||||
asistente = this.asistenteRepository.create({
|
||||
sesionId: dto.sesionId,
|
||||
employeeId: dto.employeeId,
|
||||
});
|
||||
}
|
||||
|
||||
asistente.asistio = dto.asistio;
|
||||
if (dto.horaEntrada) asistente.horaEntrada = dto.horaEntrada;
|
||||
if (dto.horaSalida) asistente.horaSalida = dto.horaSalida;
|
||||
if (dto.observaciones) asistente.observaciones = dto.observaciones;
|
||||
|
||||
return this.asistenteRepository.save(asistente);
|
||||
}
|
||||
|
||||
async evaluarAsistente(asistenteId: string, dto: EvaluarAsistenteDto): Promise<CapacitacionAsistente | null> {
|
||||
const asistente = await this.asistenteRepository.findOne({
|
||||
where: { id: asistenteId } as FindOptionsWhere<CapacitacionAsistente>,
|
||||
});
|
||||
|
||||
if (!asistente) return null;
|
||||
|
||||
if (!asistente.asistio) {
|
||||
throw new Error('No se puede evaluar a un asistente que no asistió');
|
||||
}
|
||||
|
||||
asistente.calificacion = dto.calificacion;
|
||||
asistente.aprobado = dto.aprobado;
|
||||
if (dto.observaciones) {
|
||||
asistente.observaciones = dto.observaciones;
|
||||
}
|
||||
|
||||
return this.asistenteRepository.save(asistente);
|
||||
}
|
||||
|
||||
async eliminarAsistente(asistenteId: string): Promise<boolean> {
|
||||
const result = await this.asistenteRepository.delete(asistenteId);
|
||||
return (result.affected || 0) > 0;
|
||||
}
|
||||
|
||||
// ========== Matriz de Capacitación ==========
|
||||
async findMatrizByPuesto(ctx: ServiceContext, puestoId: string): Promise<CapacitacionMatriz[]> {
|
||||
return this.matrizRepository.find({
|
||||
where: { tenantId: ctx.tenantId, puestoId } as FindOptionsWhere<CapacitacionMatriz>,
|
||||
relations: ['capacitacion'],
|
||||
order: { esObligatoria: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async addCapacitacionToMatriz(
|
||||
ctx: ServiceContext,
|
||||
puestoId: string,
|
||||
capacitacionId: string,
|
||||
esObligatoria: boolean = true,
|
||||
plazoDias: number = 30
|
||||
): Promise<CapacitacionMatriz> {
|
||||
const existing = await this.matrizRepository.findOne({
|
||||
where: { tenantId: ctx.tenantId, puestoId, capacitacionId } as FindOptionsWhere<CapacitacionMatriz>,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error('La capacitación ya está en la matriz para este puesto');
|
||||
}
|
||||
|
||||
const matriz = this.matrizRepository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
puestoId,
|
||||
capacitacionId,
|
||||
esObligatoria,
|
||||
plazoDias,
|
||||
});
|
||||
|
||||
return this.matrizRepository.save(matriz);
|
||||
}
|
||||
|
||||
async removeCapacitacionFromMatriz(ctx: ServiceContext, matrizId: string): Promise<boolean> {
|
||||
const matriz = await this.matrizRepository.findOne({
|
||||
where: { id: matrizId, tenantId: ctx.tenantId } as FindOptionsWhere<CapacitacionMatriz>,
|
||||
});
|
||||
|
||||
if (!matriz) return false;
|
||||
|
||||
await this.matrizRepository.remove(matriz);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ========== Estadísticas ==========
|
||||
async getStats(ctx: ServiceContext, fraccionamientoId?: string): Promise<SesionStats> {
|
||||
const baseQuery = this.sesionRepository
|
||||
.createQueryBuilder('s')
|
||||
.where('s.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (fraccionamientoId) {
|
||||
baseQuery.andWhere('s.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
|
||||
}
|
||||
|
||||
const totalSesiones = await baseQuery.getCount();
|
||||
|
||||
const sesionesProgramadas = await this.sesionRepository
|
||||
.createQueryBuilder('s')
|
||||
.where('s.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('s.estado = :estado', { estado: 'programada' })
|
||||
.andWhere(fraccionamientoId ? 's.fraccionamiento_id = :fraccionamientoId' : '1=1', { fraccionamientoId })
|
||||
.getCount();
|
||||
|
||||
const sesionesCompletadas = await this.sesionRepository
|
||||
.createQueryBuilder('s')
|
||||
.where('s.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('s.estado = :estado', { estado: 'completada' })
|
||||
.andWhere(fraccionamientoId ? 's.fraccionamiento_id = :fraccionamientoId' : '1=1', { fraccionamientoId })
|
||||
.getCount();
|
||||
|
||||
const sesionesCanceladas = await this.sesionRepository
|
||||
.createQueryBuilder('s')
|
||||
.where('s.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('s.estado = :estado', { estado: 'cancelada' })
|
||||
.andWhere(fraccionamientoId ? 's.fraccionamiento_id = :fraccionamientoId' : '1=1', { fraccionamientoId })
|
||||
.getCount();
|
||||
|
||||
const asistentesQuery = this.asistenteRepository
|
||||
.createQueryBuilder('a')
|
||||
.innerJoin('a.sesion', 's')
|
||||
.where('s.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (fraccionamientoId) {
|
||||
asistentesQuery.andWhere('s.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
|
||||
}
|
||||
|
||||
const totalAsistentes = await asistentesQuery.andWhere('a.asistio = true').getCount();
|
||||
|
||||
const asistentesAprobados = await this.asistenteRepository
|
||||
.createQueryBuilder('a')
|
||||
.innerJoin('a.sesion', 's')
|
||||
.where('s.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('a.aprobado = true')
|
||||
.andWhere(fraccionamientoId ? 's.fraccionamiento_id = :fraccionamientoId' : '1=1', { fraccionamientoId })
|
||||
.getCount();
|
||||
|
||||
return {
|
||||
totalSesiones,
|
||||
sesionesProgramadas,
|
||||
sesionesCompletadas,
|
||||
sesionesCanceladas,
|
||||
totalAsistentes,
|
||||
asistentesAprobados,
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Sesiones Próximas ==========
|
||||
async getSesionesProximas(ctx: ServiceContext, dias: number = 7): Promise<CapacitacionSesion[]> {
|
||||
const fechaLimite = new Date();
|
||||
fechaLimite.setDate(fechaLimite.getDate() + dias);
|
||||
|
||||
return this.sesionRepository
|
||||
.createQueryBuilder('s')
|
||||
.leftJoinAndSelect('s.capacitacion', 'capacitacion')
|
||||
.leftJoinAndSelect('s.fraccionamiento', 'fraccionamiento')
|
||||
.leftJoinAndSelect('s.instructor', 'instructor')
|
||||
.where('s.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('s.estado = :estado', { estado: 'programada' })
|
||||
.andWhere('s.fecha_programada >= :hoy', { hoy: new Date() })
|
||||
.andWhere('s.fecha_programada <= :fechaLimite', { fechaLimite })
|
||||
.orderBy('s.fecha_programada', 'ASC')
|
||||
.addOrderBy('s.hora_inicio', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user