erp-construccion-backend-v2/src/modules/hse/services/programa-seguridad.service.ts
Adrian Flores Cortes 88e1c4e9b6 [ERP-CONSTRUCCION] feat(hse): Add 14 services for safety, training, and environmental management
Safety:
- dias-sin-accidente.service: Days without accidents counter
- hallazgo.service: Safety findings with workflow
- programa-seguridad.service: Annual safety programs

Training:
- instructor.service: Training instructors management
- sesion-capacitacion.service: Training sessions and attendance
- constancia-dc3.service: DC-3 certificates (STPS)
- cumplimiento.service: Regulatory compliance tracking
- comision-seguridad.service: Safety commissions (NOM-019)
- horas-trabajadas.service: Work hours for safety indices

Environmental:
- residuo-peligroso.service: Hazardous waste management
- almacen-temporal.service: Temporary storage facilities
- proveedor-ambiental.service: Environmental providers
- auditoria-ambiental.service: Environmental audits
- reporte-programado.service: Scheduled HSE reports

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:33:51 -06:00

662 lines
18 KiB
TypeScript

/**
* ProgramaSeguridadService - Servicio para programas de seguridad
*
* RF-MAA017-005: Gestion de programas anuales de seguridad y sus actividades
*
* @module HSE
*/
import { Repository, FindOptionsWhere } from 'typeorm';
import { ProgramaSeguridad, EstadoPrograma } from '../entities/programa-seguridad.entity';
import { ProgramaActividad, TipoActividadPrograma, EstadoActividad } from '../entities/programa-actividad.entity';
import { ProgramaInspeccion, EstadoInspeccion } from '../entities/programa-inspeccion.entity';
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
export interface ProgramaFilters {
fraccionamientoId?: string;
anio?: number;
estado?: EstadoPrograma;
}
export interface ActividadFilters {
programaId?: string;
tipo?: TipoActividadPrograma;
estado?: EstadoActividad;
responsableId?: string;
dateFrom?: Date;
dateTo?: Date;
}
export interface InspeccionProgramadaFilters {
fraccionamientoId?: string;
tipoInspeccionId?: string;
inspectorId?: string;
estado?: EstadoInspeccion;
dateFrom?: Date;
dateTo?: Date;
}
export interface CreateProgramaSeguridadDto {
fraccionamientoId: string;
anio: number;
objetivoGeneral?: string;
metas?: Record<string, unknown>;
presupuesto?: number;
}
export interface UpdateProgramaSeguridadDto {
objetivoGeneral?: string;
metas?: Record<string, unknown>;
presupuesto?: number;
}
export interface CreateActividadProgramaDto {
programaId: string;
actividad: string;
tipo: TipoActividadPrograma;
fechaProgramada: Date;
responsableId?: string;
recursos?: string;
}
export interface UpdateActividadDto {
actividad?: string;
tipo?: TipoActividadPrograma;
fechaProgramada?: Date;
responsableId?: string;
recursos?: string;
estado?: EstadoActividad;
evidenciaUrl?: string;
}
export interface CreateInspeccionProgramadaDto {
fraccionamientoId: string;
tipoInspeccionId: string;
inspectorId?: string;
fechaProgramada: Date;
horaProgramada?: string;
zonaArea?: string;
}
export interface ProgramaStats {
totalProgramas: number;
programasActivos: number;
actividadesPendientes: number;
actividadesCompletadas: number;
cumplimiento: number;
}
export interface CalendarioActividad {
id: string;
fecha: Date;
titulo: string;
tipo: TipoActividadPrograma | 'inspeccion';
estado: string;
responsable?: string;
}
export class ProgramaSeguridadService {
constructor(
private readonly programaRepository: Repository<ProgramaSeguridad>,
private readonly actividadRepository: Repository<ProgramaActividad>,
private readonly inspeccionProgramadaRepository: Repository<ProgramaInspeccion>
) {}
// ========== Programas ==========
/**
* Lista programas con filtros
*/
async findProgramas(
ctx: ServiceContext,
filters: ProgramaFilters = {},
page: number = 1,
limit: number = 20
): Promise<PaginatedResult<ProgramaSeguridad>> {
const skip = (page - 1) * limit;
const queryBuilder = this.programaRepository
.createQueryBuilder('programa')
.leftJoinAndSelect('programa.fraccionamiento', 'fraccionamiento')
.leftJoinAndSelect('programa.aprobadoPor', 'aprobadoPor')
.where('programa.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (filters.fraccionamientoId) {
queryBuilder.andWhere('programa.fraccionamiento_id = :fraccionamientoId', {
fraccionamientoId: filters.fraccionamientoId,
});
}
if (filters.anio) {
queryBuilder.andWhere('programa.anio = :anio', { anio: filters.anio });
}
if (filters.estado) {
queryBuilder.andWhere('programa.estado = :estado', { estado: filters.estado });
}
queryBuilder
.orderBy('programa.anio', 'DESC')
.addOrderBy('programa.created_at', 'DESC')
.skip(skip)
.take(limit);
const [data, total] = await queryBuilder.getManyAndCount();
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
/**
* Obtiene programa por ID
*/
async findProgramaById(ctx: ServiceContext, id: string): Promise<ProgramaSeguridad | null> {
return this.programaRepository.findOne({
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<ProgramaSeguridad>,
relations: ['fraccionamiento', 'aprobadoPor'],
});
}
/**
* Obtiene programa con todas sus actividades
*/
async findProgramaWithActividades(ctx: ServiceContext, id: string): Promise<ProgramaSeguridad | null> {
return this.programaRepository.findOne({
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<ProgramaSeguridad>,
relations: ['fraccionamiento', 'aprobadoPor', 'actividades', 'actividades.responsable'],
});
}
/**
* Crea un nuevo programa
*/
async createPrograma(ctx: ServiceContext, dto: CreateProgramaSeguridadDto): Promise<ProgramaSeguridad> {
// Verificar si ya existe un programa para ese anio y fraccionamiento
const existing = await this.programaRepository.findOne({
where: {
tenantId: ctx.tenantId,
fraccionamientoId: dto.fraccionamientoId,
anio: dto.anio,
} as FindOptionsWhere<ProgramaSeguridad>,
});
if (existing) {
throw new Error(`Ya existe un programa de seguridad para el anio ${dto.anio} en esta obra`);
}
const programa = this.programaRepository.create({
tenantId: ctx.tenantId,
fraccionamientoId: dto.fraccionamientoId,
anio: dto.anio,
objetivoGeneral: dto.objetivoGeneral,
metas: dto.metas,
presupuesto: dto.presupuesto,
estado: 'borrador',
});
return this.programaRepository.save(programa);
}
/**
* Actualiza un programa
*/
async updatePrograma(
ctx: ServiceContext,
id: string,
dto: UpdateProgramaSeguridadDto
): Promise<ProgramaSeguridad | null> {
const programa = await this.findProgramaById(ctx, id);
if (!programa) {
return null;
}
if (programa.estado === 'finalizado') {
throw new Error('No se puede modificar un programa finalizado');
}
const updated = this.programaRepository.merge(programa, dto);
return this.programaRepository.save(updated);
}
/**
* Activa un programa
*/
async activarPrograma(ctx: ServiceContext, id: string): Promise<ProgramaSeguridad | null> {
const programa = await this.findProgramaById(ctx, id);
if (!programa) {
return null;
}
if (programa.estado !== 'borrador') {
throw new Error('Solo se pueden activar programas en borrador');
}
programa.estado = 'activo';
programa.aprobadoPorId = ctx.userId || '';
programa.fechaAprobacion = new Date();
return this.programaRepository.save(programa);
}
/**
* Finaliza un programa
*/
async finalizarPrograma(ctx: ServiceContext, id: string): Promise<ProgramaSeguridad | null> {
const programa = await this.findProgramaById(ctx, id);
if (!programa) {
return null;
}
if (programa.estado !== 'activo') {
throw new Error('Solo se pueden finalizar programas activos');
}
programa.estado = 'finalizado';
return this.programaRepository.save(programa);
}
// ========== Actividades ==========
/**
* Lista actividades con filtros
*/
async findActividades(
ctx: ServiceContext,
filters: ActividadFilters = {},
page: number = 1,
limit: number = 20
): Promise<PaginatedResult<ProgramaActividad>> {
const skip = (page - 1) * limit;
const queryBuilder = this.actividadRepository
.createQueryBuilder('actividad')
.leftJoinAndSelect('actividad.programa', 'programa')
.leftJoinAndSelect('actividad.responsable', 'responsable')
.where('programa.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (filters.programaId) {
queryBuilder.andWhere('actividad.programa_id = :programaId', {
programaId: filters.programaId,
});
}
if (filters.tipo) {
queryBuilder.andWhere('actividad.tipo = :tipo', { tipo: filters.tipo });
}
if (filters.estado) {
queryBuilder.andWhere('actividad.estado = :estado', { estado: filters.estado });
}
if (filters.responsableId) {
queryBuilder.andWhere('actividad.responsable_id = :responsableId', {
responsableId: filters.responsableId,
});
}
if (filters.dateFrom) {
queryBuilder.andWhere('actividad.fecha_programada >= :dateFrom', { dateFrom: filters.dateFrom });
}
if (filters.dateTo) {
queryBuilder.andWhere('actividad.fecha_programada <= :dateTo', { dateTo: filters.dateTo });
}
queryBuilder
.orderBy('actividad.fecha_programada', 'ASC')
.skip(skip)
.take(limit);
const [data, total] = await queryBuilder.getManyAndCount();
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
/**
* Obtiene actividad por ID
*/
async findActividadById(_ctx: ServiceContext, id: string): Promise<ProgramaActividad | null> {
return this.actividadRepository.findOne({
where: { id } as FindOptionsWhere<ProgramaActividad>,
relations: ['programa', 'responsable'],
});
}
/**
* Crea una nueva actividad
*/
async createActividad(ctx: ServiceContext, dto: CreateActividadProgramaDto): Promise<ProgramaActividad> {
const programa = await this.findProgramaById(ctx, dto.programaId);
if (!programa) {
throw new Error('Programa no encontrado');
}
if (programa.estado === 'finalizado') {
throw new Error('No se pueden agregar actividades a un programa finalizado');
}
const actividad = this.actividadRepository.create({
programaId: dto.programaId,
actividad: dto.actividad,
tipo: dto.tipo,
fechaProgramada: dto.fechaProgramada,
responsableId: dto.responsableId,
recursos: dto.recursos,
estado: 'pendiente',
});
return this.actividadRepository.save(actividad);
}
/**
* Actualiza una actividad
*/
async updateActividad(
ctx: ServiceContext,
id: string,
dto: UpdateActividadDto
): Promise<ProgramaActividad | null> {
const actividad = await this.findActividadById(ctx, id);
if (!actividad) {
return null;
}
const updated = this.actividadRepository.merge(actividad, dto);
// Si se completa, registrar fecha
if (dto.estado === 'completada' && actividad.estado !== 'completada') {
updated.fechaRealizada = new Date();
}
return this.actividadRepository.save(updated);
}
/**
* Marca actividad como completada
*/
async completarActividad(
ctx: ServiceContext,
id: string,
evidenciaUrl?: string
): Promise<ProgramaActividad | null> {
const actividad = await this.findActividadById(ctx, id);
if (!actividad) {
return null;
}
actividad.estado = 'completada';
actividad.fechaRealizada = new Date();
if (evidenciaUrl) {
actividad.evidenciaUrl = evidenciaUrl;
}
return this.actividadRepository.save(actividad);
}
/**
* Cancela una actividad
*/
async cancelarActividad(ctx: ServiceContext, id: string): Promise<ProgramaActividad | null> {
const actividad = await this.findActividadById(ctx, id);
if (!actividad) {
return null;
}
if (actividad.estado === 'completada') {
throw new Error('No se puede cancelar una actividad completada');
}
actividad.estado = 'cancelada';
return this.actividadRepository.save(actividad);
}
// ========== Inspecciones Programadas ==========
/**
* Lista inspecciones programadas
*/
async findInspeccionesProgramadas(
ctx: ServiceContext,
filters: InspeccionProgramadaFilters = {},
page: number = 1,
limit: number = 20
): Promise<PaginatedResult<ProgramaInspeccion>> {
const skip = (page - 1) * limit;
const queryBuilder = this.inspeccionProgramadaRepository
.createQueryBuilder('pi')
.leftJoinAndSelect('pi.fraccionamiento', 'fraccionamiento')
.leftJoinAndSelect('pi.tipoInspeccion', 'tipoInspeccion')
.leftJoinAndSelect('pi.inspector', 'inspector')
.where('pi.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (filters.fraccionamientoId) {
queryBuilder.andWhere('pi.fraccionamiento_id = :fraccionamientoId', {
fraccionamientoId: filters.fraccionamientoId,
});
}
if (filters.tipoInspeccionId) {
queryBuilder.andWhere('pi.tipo_inspeccion_id = :tipoInspeccionId', {
tipoInspeccionId: filters.tipoInspeccionId,
});
}
if (filters.inspectorId) {
queryBuilder.andWhere('pi.inspector_id = :inspectorId', {
inspectorId: filters.inspectorId,
});
}
if (filters.estado) {
queryBuilder.andWhere('pi.estado = :estado', { estado: filters.estado });
}
if (filters.dateFrom) {
queryBuilder.andWhere('pi.fecha_programada >= :dateFrom', { dateFrom: filters.dateFrom });
}
if (filters.dateTo) {
queryBuilder.andWhere('pi.fecha_programada <= :dateTo', { dateTo: filters.dateTo });
}
queryBuilder
.orderBy('pi.fecha_programada', 'ASC')
.skip(skip)
.take(limit);
const [data, total] = await queryBuilder.getManyAndCount();
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
/**
* Crea una inspeccion programada
*/
async createInspeccionProgramada(
ctx: ServiceContext,
dto: CreateInspeccionProgramadaDto
): Promise<ProgramaInspeccion> {
const inspeccion = this.inspeccionProgramadaRepository.create({
tenantId: ctx.tenantId,
fraccionamientoId: dto.fraccionamientoId,
tipoInspeccionId: dto.tipoInspeccionId,
inspectorId: dto.inspectorId,
fechaProgramada: dto.fechaProgramada,
horaProgramada: dto.horaProgramada,
zonaArea: dto.zonaArea,
estado: 'programada',
});
return this.inspeccionProgramadaRepository.save(inspeccion);
}
/**
* Actualiza estado de inspeccion programada
*/
async updateInspeccionProgramadaEstado(
ctx: ServiceContext,
id: string,
estado: EstadoInspeccion,
motivoCancelacion?: string
): Promise<ProgramaInspeccion | null> {
const inspeccion = await this.inspeccionProgramadaRepository.findOne({
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<ProgramaInspeccion>,
});
if (!inspeccion) {
return null;
}
inspeccion.estado = estado;
if (estado === 'cancelada' && motivoCancelacion) {
inspeccion.motivoCancelacion = motivoCancelacion;
}
return this.inspeccionProgramadaRepository.save(inspeccion);
}
// ========== Calendario y Estadisticas ==========
/**
* Obtiene calendario de actividades e inspecciones
*/
async getCalendario(
ctx: ServiceContext,
fraccionamientoId: string,
dateFrom: Date,
dateTo: Date
): Promise<CalendarioActividad[]> {
const calendario: CalendarioActividad[] = [];
// Obtener actividades del programa
const actividades = await this.actividadRepository
.createQueryBuilder('a')
.leftJoinAndSelect('a.programa', 'p')
.leftJoinAndSelect('a.responsable', 'r')
.where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('p.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId })
.andWhere('a.fecha_programada >= :dateFrom', { dateFrom })
.andWhere('a.fecha_programada <= :dateTo', { dateTo })
.getMany();
for (const act of actividades) {
calendario.push({
id: act.id,
fecha: act.fechaProgramada,
titulo: act.actividad,
tipo: act.tipo,
estado: act.estado,
responsable: act.responsable?.id,
});
}
// Obtener inspecciones programadas
const inspecciones = await this.inspeccionProgramadaRepository
.createQueryBuilder('i')
.leftJoinAndSelect('i.tipoInspeccion', 't')
.leftJoinAndSelect('i.inspector', 'ins')
.where('i.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('i.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId })
.andWhere('i.fecha_programada >= :dateFrom', { dateFrom })
.andWhere('i.fecha_programada <= :dateTo', { dateTo })
.getMany();
for (const insp of inspecciones) {
calendario.push({
id: insp.id,
fecha: insp.fechaProgramada,
titulo: `Inspeccion: ${insp.tipoInspeccion?.nombre || 'General'}`,
tipo: 'inspeccion',
estado: insp.estado,
responsable: insp.inspectorId,
});
}
// Ordenar por fecha
calendario.sort((a, b) => new Date(a.fecha).getTime() - new Date(b.fecha).getTime());
return calendario;
}
/**
* Obtiene estadisticas del programa
*/
async getStats(ctx: ServiceContext, fraccionamientoId?: string, anio?: number): Promise<ProgramaStats> {
const queryBuilder = this.programaRepository
.createQueryBuilder('p')
.where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (fraccionamientoId) {
queryBuilder.andWhere('p.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
}
if (anio) {
queryBuilder.andWhere('p.anio = :anio', { anio });
}
const totalProgramas = await queryBuilder.getCount();
const programasActivos = await this.programaRepository
.createQueryBuilder('p')
.where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('p.estado = :estado', { estado: 'activo' })
.getCount();
// Estadisticas de actividades
const actividadesQuery = this.actividadRepository
.createQueryBuilder('a')
.leftJoin('a.programa', 'p')
.where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (fraccionamientoId) {
actividadesQuery.andWhere('p.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
}
if (anio) {
actividadesQuery.andWhere('p.anio = :anio', { anio });
}
const totalActividades = await actividadesQuery.getCount();
const actividadesCompletadas = await actividadesQuery
.clone()
.andWhere('a.estado = :estado', { estado: 'completada' })
.getCount();
const actividadesPendientes = await actividadesQuery
.clone()
.andWhere('a.estado IN (:...estados)', { estados: ['pendiente', 'en_progreso'] })
.getCount();
const cumplimiento = totalActividades > 0
? Math.round((actividadesCompletadas / totalActividades) * 100)
: 0;
return {
totalProgramas,
programasActivos,
actividadesPendientes,
actividadesCompletadas,
cumplimiento,
};
}
}