diff --git a/src/modules/hse/services/almacen-temporal.service.ts b/src/modules/hse/services/almacen-temporal.service.ts new file mode 100644 index 0000000..bcbac85 --- /dev/null +++ b/src/modules/hse/services/almacen-temporal.service.ts @@ -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, + private readonly generacionRepository: Repository + ) {} + + // ========== CRUD de Almacenes ========== + async findAll( + ctx: ServiceContext, + filters: AlmacenFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + 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 { + return this.almacenRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + relations: ['fraccionamiento'], + }); + } + + async findByFraccionamiento( + ctx: ServiceContext, + fraccionamientoId: string + ): Promise { + return this.almacenRepository.find({ + where: { + tenantId: ctx.tenantId, + fraccionamientoId, + } as FindOptionsWhere, + order: { nombre: 'ASC' }, + }); + } + + async create(ctx: ServiceContext, dto: CreateAlmacenDto): Promise { + 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 { + 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 { + 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 { + // Check if there are residuos stored + const residuosCount = await this.generacionRepository.count({ + where: { + tenantId: ctx.tenantId, + contenedorId: id, + estado: 'almacenado', + } as FindOptionsWhere, + }); + + 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); + + return (result.affected ?? 0) > 0; + } + + // ========== Inspección de Almacén ========== + async registrarInspeccion( + ctx: ServiceContext, + id: string, + dto: InspeccionAlmacenDto + ): Promise { + 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 { + 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 { + 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, + }); + + 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, + }); + + 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 { + 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 { + 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, + }; + } +} diff --git a/src/modules/hse/services/auditoria-ambiental.service.ts b/src/modules/hse/services/auditoria-ambiental.service.ts new file mode 100644 index 0000000..ab5bd95 --- /dev/null +++ b/src/modules/hse/services/auditoria-ambiental.service.ts @@ -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 + ) {} + + // ========== CRUD de Auditorías ========== + async findAll( + ctx: ServiceContext, + filters: AuditoriaAmbientalFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + 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 { + return this.auditoriaRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + relations: ['fraccionamiento'], + }); + } + + async findPendientes(ctx: ServiceContext, fraccionamientoId?: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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); + + return (result.affected ?? 0) > 0; + } + + // ========== Completar Auditoría ========== + async completar( + ctx: ServiceContext, + id: string, + dto: CompletarAuditoriaDto + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 = {}; + + 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 { + 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 { + 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 { + return this.auditoriaRepository.find({ + where: { + tenantId: ctx.tenantId, + fraccionamientoId, + } as FindOptionsWhere, + 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), + }; + }); + } +} diff --git a/src/modules/hse/services/comision-seguridad.service.ts b/src/modules/hse/services/comision-seguridad.service.ts new file mode 100644 index 0000000..4fe4a09 --- /dev/null +++ b/src/modules/hse/services/comision-seguridad.service.ts @@ -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, + private readonly integranteRepository: Repository, + private readonly recorridoRepository: Repository + ) {} + + // ========== Comisiones ========== + async findComisiones( + ctx: ServiceContext, + filters: ComisionSeguridadFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + 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 { + return this.comisionRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + relations: ['fraccionamiento', 'integrantes', 'integrantes.employee', 'recorridos'], + }); + } + + async findVigenteByFraccionamiento(ctx: ServiceContext, fraccionamientoId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const where: FindOptionsWhere = { 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 { + const existing = await this.integranteRepository.findOne({ + where: { + comisionId, + employeeId: dto.employeeId, + activo: true, + } as FindOptionsWhere, + }); + + 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 { + const integrante = await this.integranteRepository.findOne({ + where: { id: integranteId } as FindOptionsWhere, + }); + + if (!integrante) return null; + + if (rol) integrante.rol = rol; + if (representacion) integrante.representacion = representacion; + + return this.integranteRepository.save(integrante); + } + + async bajaIntegrante(integranteId: string): Promise { + const integrante = await this.integranteRepository.findOne({ + where: { id: integranteId } as FindOptionsWhere, + }); + + if (!integrante) return null; + + integrante.activo = false; + + return this.integranteRepository.save(integrante); + } + + // ========== Recorridos ========== + async findRecorridosByComision(comisionId: string): Promise { + return this.recorridoRepository.find({ + where: { comisionId } as FindOptionsWhere, + order: { fechaProgramada: 'DESC' }, + }); + } + + async findRecorridoById(id: string): Promise { + return this.recorridoRepository.findOne({ + where: { id } as FindOptionsWhere, + relations: ['comision', 'comision.fraccionamiento'], + }); + } + + async createRecorrido(dto: CreateRecorridoComisionDto): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/src/modules/hse/services/constancia-dc3.service.ts b/src/modules/hse/services/constancia-dc3.service.ts new file mode 100644 index 0000000..b6b0ed7 --- /dev/null +++ b/src/modules/hse/services/constancia-dc3.service.ts @@ -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) {} + + async findAll( + ctx: ServiceContext, + filters: ConstanciaDc3Filters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + 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 { + return this.repository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + relations: ['employee', 'capacitacion', 'asistente'], + }); + } + + async findByFolio(ctx: ServiceContext, folio: string): Promise { + return this.repository.findOne({ + where: { tenantId: ctx.tenantId, folio } as FindOptionsWhere, + relations: ['employee', 'capacitacion'], + }); + } + + async create(ctx: ServiceContext, dto: CreateConstanciaDc3Dto): Promise { + 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 { + 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 { + return this.repository.find({ + where: { tenantId: ctx.tenantId, employeeId } as FindOptionsWhere, + relations: ['capacitacion'], + order: { fechaEmision: 'DESC' }, + }); + } + + async findVigenteByEmployeeAndCapacitacion( + ctx: ServiceContext, + employeeId: string, + capacitacionId: string + ): Promise { + 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 { + const today = new Date(); + + return this.repository.find({ + where: { + tenantId: ctx.tenantId, + fechaVencimiento: LessThan(today), + } as FindOptionsWhere, + relations: ['employee', 'capacitacion'], + order: { fechaVencimiento: 'ASC' }, + }); + } + + async getConstanciasPorVencer(ctx: ServiceContext, dias: number = 30): Promise { + 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 { + 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, + }); + + 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, + }); + + 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 { + 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')}`; + } +} diff --git a/src/modules/hse/services/cumplimiento.service.ts b/src/modules/hse/services/cumplimiento.service.ts new file mode 100644 index 0000000..b7281b4 --- /dev/null +++ b/src/modules/hse/services/cumplimiento.service.ts @@ -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, + private readonly normaRepository: Repository, + private readonly requisitoRepository: Repository + ) {} + + // ========== Normas ========== + async findNormas(soloAplicaConstruccion: boolean = true): Promise { + const where: FindOptionsWhere = { activo: true }; + if (soloAplicaConstruccion) { + where.aplicaConstruccion = true; + } + + return this.normaRepository.find({ + where, + order: { codigo: 'ASC' }, + }); + } + + async findNormaById(id: string): Promise { + return this.normaRepository.findOne({ + where: { id } as FindOptionsWhere, + relations: ['requisitos'], + }); + } + + async findRequisitosByNorma(normaId: string): Promise { + return this.requisitoRepository.find({ + where: { normaId } as FindOptionsWhere, + order: { numero: 'ASC' }, + }); + } + + // ========== Cumplimiento ========== + async findCumplimientos( + ctx: ServiceContext, + filters: CumplimientoObraFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + 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 { + return this.cumplimientoRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + relations: ['fraccionamiento', 'norma', 'requisito', 'evaluador'], + }); + } + + async create(ctx: ServiceContext, dto: CreateCumplimientoObraDto): Promise { + 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 { + 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 { + 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 { + 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, + }); + + 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 { + 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, + }); + + 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 { + 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 { + 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 { + 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(); + } +} diff --git a/src/modules/hse/services/dias-sin-accidente.service.ts b/src/modules/hse/services/dias-sin-accidente.service.ts new file mode 100644 index 0000000..e17c6ba --- /dev/null +++ b/src/modules/hse/services/dias-sin-accidente.service.ts @@ -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, + private readonly incidenteRepository: Repository + ) {} + + /** + * Obtiene el contador de dias sin accidente para una obra + */ + async findByFraccionamiento( + ctx: ServiceContext, + fraccionamientoId: string + ): Promise { + return this.diasSinAccidenteRepository.findOne({ + where: { + tenantId: ctx.tenantId, + fraccionamientoId, + } as FindOptionsWhere, + relations: ['fraccionamiento', 'ultimoIncidente'], + }); + } + + /** + * Lista todos los contadores de dias sin accidente del tenant + */ + async findAll(ctx: ServiceContext): Promise { + return this.diasSinAccidenteRepository.find({ + where: { tenantId: ctx.tenantId } as FindOptionsWhere, + relations: ['fraccionamiento'], + order: { diasAcumulados: 'DESC' }, + }); + } + + /** + * Inicializa el contador para una obra + */ + async initialize( + ctx: ServiceContext, + dto: CreateDiasSinAccidenteDto + ): Promise { + // 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 { + 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 { + 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 { + 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 { + 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, + 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 { + 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, + 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))); + } +} diff --git a/src/modules/hse/services/hallazgo.service.ts b/src/modules/hse/services/hallazgo.service.ts new file mode 100644 index 0000000..1248d59 --- /dev/null +++ b/src/modules/hse/services/hallazgo.service.ts @@ -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, + private readonly evidenciaRepository: Repository + ) {} + + 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> { + 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 { + return this.hallazgoRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + relations: ['inspeccion', 'responsableCorreccion', 'verificador'], + }); + } + + /** + * Obtiene hallazgo con todas las relaciones + */ + async findWithDetails(ctx: ServiceContext, id: string): Promise { + return this.hallazgoRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + relations: [ + 'inspeccion', + 'inspeccion.fraccionamiento', + 'inspeccion.tipoInspeccion', + 'evaluacion', + 'responsableCorreccion', + 'verificador', + 'evidencias', + 'evidencias.createdBy', + ], + }); + } + + /** + * Crea un nuevo hallazgo + */ + async create(ctx: ServiceContext, dto: CreateHallazgoServiceDto): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const hallazgo = await this.findById(ctx, hallazgoId); + if (!hallazgo) { + return []; + } + + return this.evidenciaRepository.find({ + where: { hallazgoId } as FindOptionsWhere, + relations: ['createdBy'], + order: { createdAt: 'DESC' }, + }); + } + + /** + * Elimina evidencia + */ + async removeEvidencia(ctx: ServiceContext, hallazgoId: string, evidenciaId: string): Promise { + 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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/src/modules/hse/services/horas-trabajadas.service.ts b/src/modules/hse/services/horas-trabajadas.service.ts new file mode 100644 index 0000000..5c7373b --- /dev/null +++ b/src/modules/hse/services/horas-trabajadas.service.ts @@ -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) {} + + async findAll( + ctx: ServiceContext, + filters: HorasTrabajadasFilters = {}, + page: number = 1, + limit: number = 50 + ): Promise> { + 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 { + return this.repository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + relations: ['fraccionamiento'], + }); + } + + async findByFecha( + ctx: ServiceContext, + fraccionamientoId: string, + fecha: Date + ): Promise { + return this.repository.findOne({ + where: { + tenantId: ctx.tenantId, + fraccionamientoId, + fecha, + } as FindOptionsWhere, + }); + } + + async create(ctx: ServiceContext, dto: CreateHorasTrabajadasDto): Promise { + 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 { + 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 { + 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 { + 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 { + return this.repository.find({ + where: { + tenantId: ctx.tenantId, + fraccionamientoId, + fecha: Between(fechaInicio, fechaFin), + } as FindOptionsWhere, + order: { fecha: 'ASC' }, + }); + } + + async getTotalHorasByPeriodo( + ctx: ServiceContext, + fraccionamientoId: string, + fechaInicio: Date, + fechaFin: Date + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const totalHoras = await this.getTotalHorasByPeriodo(ctx, fraccionamientoId, fechaInicio, fechaFin); + if (totalHoras === 0) return 0; + + return (diasPerdidos * 1000000) / totalHoras; + } +} diff --git a/src/modules/hse/services/index.ts b/src/modules/hse/services/index.ts index ef62c53..6fad180 100644 --- a/src/modules/hse/services/index.ts +++ b/src/modules/hse/services/index.ts @@ -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'; diff --git a/src/modules/hse/services/instructor.service.ts b/src/modules/hse/services/instructor.service.ts new file mode 100644 index 0000000..064f8a2 --- /dev/null +++ b/src/modules/hse/services/instructor.service.ts @@ -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) {} + + async findAll( + ctx: ServiceContext, + filters: InstructorFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + 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 { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + relations: ['employee'], + }); + } + + async findByRegistroStps(ctx: ServiceContext, registroStps: string): Promise { + return this.repository.findOne({ + where: { + registroStps, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + }); + } + + async create(ctx: ServiceContext, dto: CreateInstructorDto): Promise { + 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 { + 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 { + 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 { + return this.repository.find({ + where: { + tenantId: ctx.tenantId, + activo: true, + } as FindOptionsWhere, + relations: ['employee'], + order: { nombre: 'ASC' }, + }); + } + + async getInstructoresInternos(ctx: ServiceContext): Promise { + return this.repository.find({ + where: { + tenantId: ctx.tenantId, + esInterno: true, + activo: true, + } as FindOptionsWhere, + relations: ['employee'], + order: { nombre: 'ASC' }, + }); + } + + async getInstructoresExternos(ctx: ServiceContext): Promise { + return this.repository.find({ + where: { + tenantId: ctx.tenantId, + esInterno: false, + activo: true, + } as FindOptionsWhere, + order: { nombre: 'ASC' }, + }); + } +} diff --git a/src/modules/hse/services/programa-seguridad.service.ts b/src/modules/hse/services/programa-seguridad.service.ts new file mode 100644 index 0000000..4bb7b1a --- /dev/null +++ b/src/modules/hse/services/programa-seguridad.service.ts @@ -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; + presupuesto?: number; +} + +export interface UpdateProgramaSeguridadDto { + objetivoGeneral?: string; + metas?: Record; + 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, + private readonly actividadRepository: Repository, + private readonly inspeccionProgramadaRepository: Repository + ) {} + + // ========== Programas ========== + + /** + * Lista programas con filtros + */ + async findProgramas( + ctx: ServiceContext, + filters: ProgramaFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + 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 { + return this.programaRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + relations: ['fraccionamiento', 'aprobadoPor'], + }); + } + + /** + * Obtiene programa con todas sus actividades + */ + async findProgramaWithActividades(ctx: ServiceContext, id: string): Promise { + return this.programaRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + relations: ['fraccionamiento', 'aprobadoPor', 'actividades', 'actividades.responsable'], + }); + } + + /** + * Crea un nuevo programa + */ + async createPrograma(ctx: ServiceContext, dto: CreateProgramaSeguridadDto): Promise { + // 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, + }); + + 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 { + 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 { + 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 { + 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> { + 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 { + return this.actividadRepository.findOne({ + where: { id } as FindOptionsWhere, + relations: ['programa', 'responsable'], + }); + } + + /** + * Crea una nueva actividad + */ + async createActividad(ctx: ServiceContext, dto: CreateActividadProgramaDto): Promise { + 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 { + 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 { + 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 { + 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> { + 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 { + 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 { + const inspeccion = await this.inspeccionProgramadaRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + + 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 { + 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 { + 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, + }; + } +} diff --git a/src/modules/hse/services/proveedor-ambiental.service.ts b/src/modules/hse/services/proveedor-ambiental.service.ts new file mode 100644 index 0000000..7f85ce4 --- /dev/null +++ b/src/modules/hse/services/proveedor-ambiental.service.ts @@ -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, + private readonly manifiestoRepository: Repository + ) {} + + // ========== CRUD de Proveedores ========== + async findAll( + ctx: ServiceContext, + filters: ProveedorFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + 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 { + return this.proveedorRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + } + + async findByTipo(ctx: ServiceContext, tipo: TipoProveedorAmbiental): Promise { + return this.proveedorRepository.find({ + where: { + tenantId: ctx.tenantId, + tipo, + activo: true, + } as FindOptionsWhere, + order: { razonSocial: 'ASC' }, + }); + } + + async findTransportistas(ctx: ServiceContext): Promise { + return this.findByTipo(ctx, 'transportista'); + } + + async findRecicladores(ctx: ServiceContext): Promise { + return this.findByTipo(ctx, 'reciclador'); + } + + async findConfinamientos(ctx: ServiceContext): Promise { + return this.findByTipo(ctx, 'confinamiento'); + } + + async findVigentes(ctx: ServiceContext, tipo?: TipoProveedorAmbiental): Promise { + 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 { + // Validate RFC uniqueness within tenant + if (dto.rfc) { + const existing = await this.proveedorRepository.findOne({ + where: { tenantId: ctx.tenantId, rfc: dto.rfc } as FindOptionsWhere, + }); + 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 { + 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, + }); + 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 { + return this.update(ctx, id, { activo: true }); + } + + async deactivate(ctx: ServiceContext, id: string): Promise { + return this.update(ctx, id, { activo: false }); + } + + async delete(ctx: ServiceContext, id: string): Promise { + // Check if proveedor has manifiestos + const manifiestoCount = await this.manifiestoRepository.count({ + where: [ + { tenantId: ctx.tenantId, transportistaId: id }, + { tenantId: ctx.tenantId, destinoId: id }, + ] as FindOptionsWhere[], + }); + + 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); + + return (result.affected ?? 0) > 0; + } + + // ========== Renovación de Autorización ========== + async renovarAutorizacion( + ctx: ServiceContext, + id: string, + nuevaFechaVencimiento: Date, + nuevoNumeroAutorizacion?: string + ): Promise { + 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 { + 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[], + }); + + // Get last service date + const ultimoManifiesto = await this.manifiestoRepository.findOne({ + where: [ + { tenantId: ctx.tenantId, transportistaId: id }, + { tenantId: ctx.tenantId, destinoId: id }, + ] as FindOptionsWhere[], + 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> { + 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 { + 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, + }); + + 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 { + 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, + }); + + const proveedoresActivos = await this.proveedorRepository.count({ + where: { tenantId: ctx.tenantId, activo: true } as FindOptionsWhere, + }); + + 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, + }; + } +} diff --git a/src/modules/hse/services/reporte-programado.service.ts b/src/modules/hse/services/reporte-programado.service.ts new file mode 100644 index 0000000..2620978 --- /dev/null +++ b/src/modules/hse/services/reporte-programado.service.ts @@ -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 + ) {} + + // ========== CRUD de Reportes Programados ========== + async findAll( + ctx: ServiceContext, + filters: ReporteProgramadoFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + 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 { + return this.reporteRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + } + + async findActivos(ctx: ServiceContext): Promise { + return this.reporteRepository.find({ + where: { tenantId: ctx.tenantId, activo: true } as FindOptionsWhere, + order: { nombre: 'ASC' }, + }); + } + + async findByTipo(ctx: ServiceContext, tipoReporte: TipoReporteHse): Promise { + return this.reporteRepository.find({ + where: { + tenantId: ctx.tenantId, + tipoReporte, + activo: true, + } as FindOptionsWhere, + order: { nombre: 'ASC' }, + }); + } + + async create(ctx: ServiceContext, dto: CreateReporteProgramadoDto): Promise { + // 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 { + 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 { + return this.update(ctx, id, { activo: true }); + } + + async deactivate(ctx: ServiceContext, id: string): Promise { + return this.update(ctx, id, { activo: false }); + } + + async delete(ctx: ServiceContext, id: string): Promise { + const result = await this.reporteRepository.delete({ + id, + tenantId: ctx.tenantId, + } as FindOptionsWhere); + + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const totalReportes = await this.reporteRepository.count({ + where: { tenantId: ctx.tenantId } as FindOptionsWhere, + }); + + const reportesActivos = await this.reporteRepository.count({ + where: { tenantId: ctx.tenantId, activo: true } as FindOptionsWhere, + }); + + // 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, + }; + } +} diff --git a/src/modules/hse/services/residuo-peligroso.service.ts b/src/modules/hse/services/residuo-peligroso.service.ts new file mode 100644 index 0000000..bb2d3c8 --- /dev/null +++ b/src/modules/hse/services/residuo-peligroso.service.ts @@ -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, + private readonly generacionRepository: Repository, + private readonly almacenRepository: Repository + ) {} + + // ========== Catálogo de Residuos Peligrosos ========== + async findResiduosPeligrosos( + search?: string, + activo: boolean = true + ): Promise { + 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 { + return this.residuoCatalogoRepository.findOne({ + where: { id, categoria: 'peligroso' } as FindOptionsWhere, + }); + } + + async findResiduoByCodigo(codigo: string): Promise { + return this.residuoCatalogoRepository.findOne({ + where: { codigo } as FindOptionsWhere, + }); + } + + // ========== Generación de Residuos Peligrosos ========== + async findGeneraciones( + ctx: ServiceContext, + filters: ResiduoPeligrosoFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + 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 { + return this.generacionRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + relations: ['residuo', 'fraccionamiento', 'createdBy'], + }); + } + + async createGeneracion(ctx: ServiceContext, dto: CreateResiduoPeligrosoDto): Promise { + // Validate that the residuo is peligroso + const residuo = await this.residuoCatalogoRepository.findOne({ + where: { id: dto.residuoId } as FindOptionsWhere, + }); + + 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 { + const generacion = await this.generacionRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + + 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 { + const generacion = await this.generacionRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + + if (!generacion) return null; + + generacion.estado = estado; + return this.generacionRepository.save(generacion); + } + + async transferirContenedor( + ctx: ServiceContext, + id: string, + dto: TransferirResiduoDto + ): Promise { + const generacion = await this.generacionRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + + 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, + }); + + 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 { + 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 { + 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 { + return this.generacionRepository.find({ + where: { + tenantId: ctx.tenantId, + residuoId, + } as FindOptionsWhere, + relations: ['residuo', 'fraccionamiento', 'createdBy'], + order: { fechaGeneracion: 'DESC' }, + }); + } + + async getGeneracionesPorContenedor( + ctx: ServiceContext, + contenedorId: string + ): Promise { + return this.generacionRepository.find({ + where: { + tenantId: ctx.tenantId, + contenedorId, + estado: 'almacenado', + } as FindOptionsWhere, + relations: ['residuo'], + order: { fechaGeneracion: 'ASC' }, + }); + } +} diff --git a/src/modules/hse/services/sesion-capacitacion.service.ts b/src/modules/hse/services/sesion-capacitacion.service.ts new file mode 100644 index 0000000..43023a9 --- /dev/null +++ b/src/modules/hse/services/sesion-capacitacion.service.ts @@ -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, + private readonly asistenteRepository: Repository, + private readonly matrizRepository: Repository + ) {} + + // ========== Sesiones ========== + async findSesiones( + ctx: ServiceContext, + filters: SesionFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + 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 { + return this.sesionRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + relations: ['capacitacion', 'fraccionamiento', 'instructor', 'asistentes', 'asistentes.employee'], + }); + } + + async createSesion(ctx: ServiceContext, dto: CreateSesionDto): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + return this.asistenteRepository.find({ + where: { sesionId } as FindOptionsWhere, + relations: ['employee'], + order: { createdAt: 'ASC' }, + }); + } + + async inscribirAsistente(sesionId: string, employeeId: string): Promise { + const existing = await this.asistenteRepository.findOne({ + where: { sesionId, employeeId } as FindOptionsWhere, + }); + + 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 { + let asistente = await this.asistenteRepository.findOne({ + where: { + sesionId: dto.sesionId, + employeeId: dto.employeeId, + } as FindOptionsWhere, + }); + + 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 { + const asistente = await this.asistenteRepository.findOne({ + where: { id: asistenteId } as FindOptionsWhere, + }); + + 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 { + const result = await this.asistenteRepository.delete(asistenteId); + return (result.affected || 0) > 0; + } + + // ========== Matriz de Capacitación ========== + async findMatrizByPuesto(ctx: ServiceContext, puestoId: string): Promise { + return this.matrizRepository.find({ + where: { tenantId: ctx.tenantId, puestoId } as FindOptionsWhere, + relations: ['capacitacion'], + order: { esObligatoria: 'DESC' }, + }); + } + + async addCapacitacionToMatriz( + ctx: ServiceContext, + puestoId: string, + capacitacionId: string, + esObligatoria: boolean = true, + plazoDias: number = 30 + ): Promise { + const existing = await this.matrizRepository.findOne({ + where: { tenantId: ctx.tenantId, puestoId, capacitacionId } as FindOptionsWhere, + }); + + 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 { + const matriz = await this.matrizRepository.findOne({ + where: { id: matrizId, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + + if (!matriz) return false; + + await this.matrizRepository.remove(matriz); + return true; + } + + // ========== Estadísticas ========== + async getStats(ctx: ServiceContext, fraccionamientoId?: string): Promise { + 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 { + 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(); + } +}