erp-construccion-backend-v2/src/modules/hse/services/horas-trabajadas.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

387 lines
12 KiB
TypeScript

/**
* HorasTrabajadasService - Servicio para registro de horas trabajadas
*
* RF-MAA017-008: Indicadores HSE - Horas Hombre Trabajadas
*
* @module HSE
*/
import { Repository, FindOptionsWhere, Between } from 'typeorm';
import { HorasTrabajadas, FuenteHoras } from '../entities/horas-trabajadas.entity';
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
export interface CreateHorasTrabajadasDto {
fraccionamientoId: string;
fecha: Date;
horasTotales: number;
trabajadoresPromedio: number;
fuente?: FuenteHoras;
}
export interface UpdateHorasTrabajadasDto {
horasTotales?: number;
trabajadoresPromedio?: number;
fuente?: FuenteHoras;
}
export interface HorasTrabajadasFilters {
fraccionamientoId?: string;
fuente?: FuenteHoras;
fechaDesde?: Date;
fechaHasta?: Date;
year?: number;
month?: number;
}
export interface HorasTrabajadasResumen {
fraccionamientoId: string;
fraccionamientoNombre?: string;
periodo: string;
totalHoras: number;
promedioTrabajadores: number;
diasRegistrados: number;
}
export interface HorasTrabajadasStats {
totalHorasAcumuladas: number;
horasUltimoMes: number;
promedioTrabajadoresDiario: number;
diasRegistrados: number;
horasPromedioMensual: number;
}
export class HorasTrabajadasService {
constructor(private readonly repository: Repository<HorasTrabajadas>) {}
async findAll(
ctx: ServiceContext,
filters: HorasTrabajadasFilters = {},
page: number = 1,
limit: number = 50
): Promise<PaginatedResult<HorasTrabajadas>> {
const skip = (page - 1) * limit;
const queryBuilder = this.repository
.createQueryBuilder('h')
.leftJoinAndSelect('h.fraccionamiento', 'fraccionamiento')
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (filters.fraccionamientoId) {
queryBuilder.andWhere('h.fraccionamiento_id = :fraccionamientoId', {
fraccionamientoId: filters.fraccionamientoId,
});
}
if (filters.fuente) {
queryBuilder.andWhere('h.fuente = :fuente', { fuente: filters.fuente });
}
if (filters.fechaDesde) {
queryBuilder.andWhere('h.fecha >= :fechaDesde', { fechaDesde: filters.fechaDesde });
}
if (filters.fechaHasta) {
queryBuilder.andWhere('h.fecha <= :fechaHasta', { fechaHasta: filters.fechaHasta });
}
if (filters.year) {
queryBuilder.andWhere('EXTRACT(YEAR FROM h.fecha) = :year', { year: filters.year });
}
if (filters.month) {
queryBuilder.andWhere('EXTRACT(MONTH FROM h.fecha) = :month', { month: filters.month });
}
queryBuilder
.orderBy('h.fecha', 'DESC')
.skip(skip)
.take(limit);
const [data, total] = await queryBuilder.getManyAndCount();
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findById(ctx: ServiceContext, id: string): Promise<HorasTrabajadas | null> {
return this.repository.findOne({
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<HorasTrabajadas>,
relations: ['fraccionamiento'],
});
}
async findByFecha(
ctx: ServiceContext,
fraccionamientoId: string,
fecha: Date
): Promise<HorasTrabajadas | null> {
return this.repository.findOne({
where: {
tenantId: ctx.tenantId,
fraccionamientoId,
fecha,
} as FindOptionsWhere<HorasTrabajadas>,
});
}
async create(ctx: ServiceContext, dto: CreateHorasTrabajadasDto): Promise<HorasTrabajadas> {
const existing = await this.findByFecha(ctx, dto.fraccionamientoId, dto.fecha);
if (existing) {
throw new Error(`Ya existe un registro de horas para esta fecha y fraccionamiento`);
}
const horas = this.repository.create({
tenantId: ctx.tenantId,
fraccionamientoId: dto.fraccionamientoId,
fecha: dto.fecha,
horasTotales: dto.horasTotales,
trabajadoresPromedio: dto.trabajadoresPromedio,
fuente: dto.fuente || 'manual',
});
return this.repository.save(horas);
}
async upsert(ctx: ServiceContext, dto: CreateHorasTrabajadasDto): Promise<HorasTrabajadas> {
const existing = await this.findByFecha(ctx, dto.fraccionamientoId, dto.fecha);
if (existing) {
existing.horasTotales = dto.horasTotales;
existing.trabajadoresPromedio = dto.trabajadoresPromedio;
existing.fuente = dto.fuente || existing.fuente;
return this.repository.save(existing);
}
return this.create(ctx, dto);
}
async update(ctx: ServiceContext, id: string, dto: UpdateHorasTrabajadasDto): Promise<HorasTrabajadas | null> {
const horas = await this.findById(ctx, id);
if (!horas) return null;
if (dto.horasTotales !== undefined) horas.horasTotales = dto.horasTotales;
if (dto.trabajadoresPromedio !== undefined) horas.trabajadoresPromedio = dto.trabajadoresPromedio;
if (dto.fuente) horas.fuente = dto.fuente;
return this.repository.save(horas);
}
async delete(ctx: ServiceContext, id: string): Promise<boolean> {
const horas = await this.findById(ctx, id);
if (!horas) return false;
await this.repository.remove(horas);
return true;
}
// ========== Consultas por Período ==========
async getHorasByPeriodo(
ctx: ServiceContext,
fraccionamientoId: string,
fechaInicio: Date,
fechaFin: Date
): Promise<HorasTrabajadas[]> {
return this.repository.find({
where: {
tenantId: ctx.tenantId,
fraccionamientoId,
fecha: Between(fechaInicio, fechaFin),
} as FindOptionsWhere<HorasTrabajadas>,
order: { fecha: 'ASC' },
});
}
async getTotalHorasByPeriodo(
ctx: ServiceContext,
fraccionamientoId: string,
fechaInicio: Date,
fechaFin: Date
): Promise<number> {
const result = await this.repository
.createQueryBuilder('h')
.select('SUM(h.horas_totales)', 'total')
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('h.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId })
.andWhere('h.fecha >= :fechaInicio', { fechaInicio })
.andWhere('h.fecha <= :fechaFin', { fechaFin })
.getRawOne();
return parseFloat(result?.total || '0');
}
async getHorasByYear(ctx: ServiceContext, fraccionamientoId: string, year: number): Promise<HorasTrabajadas[]> {
return this.repository
.createQueryBuilder('h')
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('h.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId })
.andWhere('EXTRACT(YEAR FROM h.fecha) = :year', { year })
.orderBy('h.fecha', 'ASC')
.getMany();
}
// ========== Resumen Mensual ==========
async getResumenMensual(
ctx: ServiceContext,
fraccionamientoId: string,
year: number
): Promise<HorasTrabajadasResumen[]> {
const result = await this.repository
.createQueryBuilder('h')
.select([
'h.fraccionamiento_id AS "fraccionamientoId"',
"TO_CHAR(h.fecha, 'YYYY-MM') AS periodo",
'SUM(h.horas_totales) AS "totalHoras"',
'AVG(h.trabajadores_promedio) AS "promedioTrabajadores"',
'COUNT(*) AS "diasRegistrados"',
])
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('h.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId })
.andWhere('EXTRACT(YEAR FROM h.fecha) = :year', { year })
.groupBy('h.fraccionamiento_id')
.addGroupBy("TO_CHAR(h.fecha, 'YYYY-MM')")
.orderBy('periodo', 'ASC')
.getRawMany();
return result.map((r) => ({
fraccionamientoId: r.fraccionamientoId,
periodo: r.periodo,
totalHoras: parseFloat(r.totalHoras || '0'),
promedioTrabajadores: Math.round(parseFloat(r.promedioTrabajadores || '0')),
diasRegistrados: parseInt(r.diasRegistrados, 10),
}));
}
// ========== Acumulados ==========
async getHorasAcumuladasYear(ctx: ServiceContext, fraccionamientoId: string, year: number): Promise<number> {
const result = await this.repository
.createQueryBuilder('h')
.select('SUM(h.horas_totales)', 'total')
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('h.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId })
.andWhere('EXTRACT(YEAR FROM h.fecha) = :year', { year })
.getRawOne();
return parseFloat(result?.total || '0');
}
async getHorasAcumuladasHistorico(ctx: ServiceContext, fraccionamientoId: string): Promise<number> {
const result = await this.repository
.createQueryBuilder('h')
.select('SUM(h.horas_totales)', 'total')
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('h.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId })
.getRawOne();
return parseFloat(result?.total || '0');
}
// ========== Estadísticas ==========
async getStats(ctx: ServiceContext, fraccionamientoId: string): Promise<HorasTrabajadasStats> {
const today = new Date();
const ultimoDiaMesAnterior = new Date(today.getFullYear(), today.getMonth(), 0);
const primerDiaMesAnterior = new Date(today.getFullYear(), today.getMonth() - 1, 1);
const totalHorasAcumuladas = await this.getHorasAcumuladasHistorico(ctx, fraccionamientoId);
const horasUltimoMes = await this.getTotalHorasByPeriodo(
ctx,
fraccionamientoId,
primerDiaMesAnterior,
ultimoDiaMesAnterior
);
const registros = await this.repository
.createQueryBuilder('h')
.select([
'AVG(h.trabajadores_promedio) AS "promedioTrabajadores"',
'COUNT(*) AS "diasRegistrados"',
])
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('h.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId })
.getRawOne();
const promedioTrabajadoresDiario = Math.round(parseFloat(registros?.promedioTrabajadores || '0'));
const diasRegistrados = parseInt(registros?.diasRegistrados || '0', 10);
const mesesConDatos = await this.repository
.createQueryBuilder('h')
.select("COUNT(DISTINCT TO_CHAR(h.fecha, 'YYYY-MM'))", 'meses')
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('h.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId })
.getRawOne();
const numMeses = parseInt(mesesConDatos?.meses || '1', 10);
const horasPromedioMensual = numMeses > 0 ? Math.round(totalHorasAcumuladas / numMeses) : 0;
return {
totalHorasAcumuladas,
horasUltimoMes,
promedioTrabajadoresDiario,
diasRegistrados,
horasPromedioMensual,
};
}
// ========== Importación desde Asistencia ==========
async importarDesdeAsistencia(
ctx: ServiceContext,
fraccionamientoId: string,
fecha: Date,
horasTotales: number,
trabajadoresPromedio: number
): Promise<HorasTrabajadas> {
return this.upsert(ctx, {
fraccionamientoId,
fecha,
horasTotales,
trabajadoresPromedio,
fuente: 'asistencia',
});
}
// ========== Cálculo de Índices ==========
async calcularIndiceIncidencia(
ctx: ServiceContext,
fraccionamientoId: string,
numeroIncidentes: number,
fechaInicio: Date,
fechaFin: Date
): Promise<number> {
const totalHoras = await this.getTotalHorasByPeriodo(ctx, fraccionamientoId, fechaInicio, fechaFin);
if (totalHoras === 0) return 0;
return (numeroIncidentes * 1000000) / totalHoras;
}
async calcularIndiceFrecuencia(
ctx: ServiceContext,
fraccionamientoId: string,
accidentesConBaja: number,
fechaInicio: Date,
fechaFin: Date
): Promise<number> {
const totalHoras = await this.getTotalHorasByPeriodo(ctx, fraccionamientoId, fechaInicio, fechaFin);
if (totalHoras === 0) return 0;
return (accidentesConBaja * 1000000) / totalHoras;
}
async calcularIndiceGravedad(
ctx: ServiceContext,
fraccionamientoId: string,
diasPerdidos: number,
fechaInicio: Date,
fechaFin: Date
): Promise<number> {
const totalHoras = await this.getTotalHorasByPeriodo(ctx, fraccionamientoId, fechaInicio, fechaFin);
if (totalHoras === 0) return 0;
return (diasPerdidos * 1000000) / totalHoras;
}
}