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