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>
387 lines
12 KiB
TypeScript
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;
|
|
}
|
|
}
|