/** * 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, }; } }