[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>
This commit is contained in:
Adrian Flores Cortes 2026-01-30 19:33:51 -06:00
parent e5f63495e8
commit 88e1c4e9b6
15 changed files with 6247 additions and 1 deletions

View File

@ -0,0 +1,481 @@
/**
* AlmacenTemporalService - Servicio para gestión de almacenes temporales de residuos
*
* RF-MAA017-006: Gestión de almacenes temporales con control de capacidad,
* inspecciones y cumplimiento normativo.
*
* @module HSE
*/
import { Repository, FindOptionsWhere } from 'typeorm';
import { AlmacenTemporal, EstadoAlmacen } from '../entities/almacen-temporal.entity';
import { ResiduoGeneracion } from '../entities/residuo-generacion.entity';
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
export interface AlmacenFilters {
fraccionamientoId?: string;
estado?: EstadoAlmacen;
tieneContencion?: boolean;
tieneTecho?: boolean;
search?: string;
}
export interface CreateAlmacenDto {
fraccionamientoId: string;
nombre: string;
ubicacion?: string;
capacidadM3?: number;
tieneContencion?: boolean;
tieneTecho?: boolean;
senalizacionOk?: boolean;
}
export interface UpdateAlmacenDto {
nombre?: string;
ubicacion?: string;
capacidadM3?: number;
tieneContencion?: boolean;
tieneTecho?: boolean;
senalizacionOk?: boolean;
estado?: EstadoAlmacen;
}
export interface InspeccionAlmacenDto {
tieneContencion: boolean;
tieneTecho: boolean;
senalizacionOk: boolean;
observaciones?: string;
}
export interface AlmacenConOcupacion extends AlmacenTemporal {
ocupacionActual: number;
porcentajeOcupacion: number;
residuosAlmacenados: number;
}
export interface AlmacenStats {
totalAlmacenes: number;
almacenesOperativos: number;
almacenesLlenos: number;
almacenesMantenimiento: number;
capacidadTotalM3: number;
ocupacionTotalM3: number;
porcentajeOcupacionGlobal: number;
almacenesSinContencion: number;
almacenesSinTecho: number;
almacenesSinSenalizacion: number;
}
export interface AlertaAlmacen {
almacenId: string;
nombre: string;
tipo: 'capacidad' | 'infraestructura' | 'normativo';
mensaje: string;
nivel: 'advertencia' | 'critico';
}
export class AlmacenTemporalService {
constructor(
private readonly almacenRepository: Repository<AlmacenTemporal>,
private readonly generacionRepository: Repository<ResiduoGeneracion>
) {}
// ========== CRUD de Almacenes ==========
async findAll(
ctx: ServiceContext,
filters: AlmacenFilters = {},
page: number = 1,
limit: number = 20
): Promise<PaginatedResult<AlmacenTemporal>> {
const skip = (page - 1) * limit;
const queryBuilder = this.almacenRepository
.createQueryBuilder('a')
.leftJoinAndSelect('a.fraccionamiento', 'fraccionamiento')
.where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (filters.fraccionamientoId) {
queryBuilder.andWhere('a.fraccionamiento_id = :fraccionamientoId', {
fraccionamientoId: filters.fraccionamientoId,
});
}
if (filters.estado) {
queryBuilder.andWhere('a.estado = :estado', { estado: filters.estado });
}
if (filters.tieneContencion !== undefined) {
queryBuilder.andWhere('a.tiene_contencion = :tieneContencion', {
tieneContencion: filters.tieneContencion,
});
}
if (filters.tieneTecho !== undefined) {
queryBuilder.andWhere('a.tiene_techo = :tieneTecho', {
tieneTecho: filters.tieneTecho,
});
}
if (filters.search) {
queryBuilder.andWhere(
'(a.nombre ILIKE :search OR a.ubicacion ILIKE :search)',
{ search: `%${filters.search}%` }
);
}
queryBuilder
.orderBy('a.nombre', 'ASC')
.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<AlmacenTemporal | null> {
return this.almacenRepository.findOne({
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<AlmacenTemporal>,
relations: ['fraccionamiento'],
});
}
async findByFraccionamiento(
ctx: ServiceContext,
fraccionamientoId: string
): Promise<AlmacenTemporal[]> {
return this.almacenRepository.find({
where: {
tenantId: ctx.tenantId,
fraccionamientoId,
} as FindOptionsWhere<AlmacenTemporal>,
order: { nombre: 'ASC' },
});
}
async create(ctx: ServiceContext, dto: CreateAlmacenDto): Promise<AlmacenTemporal> {
const almacen = this.almacenRepository.create({
tenantId: ctx.tenantId,
fraccionamientoId: dto.fraccionamientoId,
nombre: dto.nombre,
ubicacion: dto.ubicacion,
capacidadM3: dto.capacidadM3,
tieneContencion: dto.tieneContencion ?? true,
tieneTecho: dto.tieneTecho ?? true,
senalizacionOk: dto.senalizacionOk ?? true,
estado: 'operativo',
});
return this.almacenRepository.save(almacen);
}
async update(
ctx: ServiceContext,
id: string,
dto: UpdateAlmacenDto
): Promise<AlmacenTemporal | null> {
const almacen = await this.findById(ctx, id);
if (!almacen) return null;
if (dto.nombre !== undefined) almacen.nombre = dto.nombre;
if (dto.ubicacion !== undefined) almacen.ubicacion = dto.ubicacion;
if (dto.capacidadM3 !== undefined) almacen.capacidadM3 = dto.capacidadM3;
if (dto.tieneContencion !== undefined) almacen.tieneContencion = dto.tieneContencion;
if (dto.tieneTecho !== undefined) almacen.tieneTecho = dto.tieneTecho;
if (dto.senalizacionOk !== undefined) almacen.senalizacionOk = dto.senalizacionOk;
if (dto.estado !== undefined) almacen.estado = dto.estado;
return this.almacenRepository.save(almacen);
}
async updateEstado(
ctx: ServiceContext,
id: string,
estado: EstadoAlmacen
): Promise<AlmacenTemporal | null> {
const almacen = await this.findById(ctx, id);
if (!almacen) return null;
almacen.estado = estado;
return this.almacenRepository.save(almacen);
}
async delete(ctx: ServiceContext, id: string): Promise<boolean> {
// Check if there are residuos stored
const residuosCount = await this.generacionRepository.count({
where: {
tenantId: ctx.tenantId,
contenedorId: id,
estado: 'almacenado',
} as FindOptionsWhere<ResiduoGeneracion>,
});
if (residuosCount > 0) {
throw new Error('No se puede eliminar un almacen con residuos almacenados');
}
const result = await this.almacenRepository.delete({
id,
tenantId: ctx.tenantId,
} as FindOptionsWhere<AlmacenTemporal>);
return (result.affected ?? 0) > 0;
}
// ========== Inspección de Almacén ==========
async registrarInspeccion(
ctx: ServiceContext,
id: string,
dto: InspeccionAlmacenDto
): Promise<AlmacenTemporal | null> {
const almacen = await this.findById(ctx, id);
if (!almacen) return null;
almacen.tieneContencion = dto.tieneContencion;
almacen.tieneTecho = dto.tieneTecho;
almacen.senalizacionOk = dto.senalizacionOk;
// Auto-update estado based on inspection
if (!dto.tieneContencion || !dto.tieneTecho || !dto.senalizacionOk) {
almacen.estado = 'mantenimiento';
}
return this.almacenRepository.save(almacen);
}
// ========== Ocupación y Capacidad ==========
async getAlmacenConOcupacion(
ctx: ServiceContext,
id: string
): Promise<AlmacenConOcupacion | null> {
const almacen = await this.findById(ctx, id);
if (!almacen) return null;
const ocupacion = await this.calcularOcupacion(ctx, id);
return {
...almacen,
ocupacionActual: ocupacion.volumenM3,
porcentajeOcupacion: ocupacion.porcentaje,
residuosAlmacenados: ocupacion.cantidadResiduos,
};
}
async findAllConOcupacion(
ctx: ServiceContext,
fraccionamientoId?: string
): Promise<AlmacenConOcupacion[]> {
const filters: AlmacenFilters = {};
if (fraccionamientoId) filters.fraccionamientoId = fraccionamientoId;
const result = await this.findAll(ctx, filters, 1, 1000);
const almacenes = result.data;
const almacenesConOcupacion: AlmacenConOcupacion[] = [];
for (const almacen of almacenes) {
const ocupacion = await this.calcularOcupacion(ctx, almacen.id);
almacenesConOcupacion.push({
...almacen,
ocupacionActual: ocupacion.volumenM3,
porcentajeOcupacion: ocupacion.porcentaje,
residuosAlmacenados: ocupacion.cantidadResiduos,
});
}
return almacenesConOcupacion;
}
private async calcularOcupacion(
ctx: ServiceContext,
almacenId: string
): Promise<{ volumenM3: number; porcentaje: number; cantidadResiduos: number }> {
const almacen = await this.almacenRepository.findOne({
where: { id: almacenId, tenantId: ctx.tenantId } as FindOptionsWhere<AlmacenTemporal>,
});
if (!almacen) {
return { volumenM3: 0, porcentaje: 0, cantidadResiduos: 0 };
}
// Get all residuos stored in this almacen
const residuos = await this.generacionRepository
.createQueryBuilder('g')
.where('g.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('g.contenedor_id = :contenedorId', { contenedorId: almacenId })
.andWhere('g.estado = :estado', { estado: 'almacenado' })
.select('g.unidad', 'unidad')
.addSelect('SUM(g.cantidad)', 'total')
.groupBy('g.unidad')
.getRawMany();
// Convert to m3 (approximate conversions)
let volumenM3 = 0;
for (const r of residuos) {
const cantidad = parseFloat(r.total || '0');
switch (r.unidad) {
case 'm3':
volumenM3 += cantidad;
break;
case 'litros':
volumenM3 += cantidad / 1000;
break;
case 'kg':
// Approximate: 1 kg = 0.001 m3 (depends on density)
volumenM3 += cantidad / 1000;
break;
case 'piezas':
// Approximate: 1 piece = 0.01 m3
volumenM3 += cantidad * 0.01;
break;
}
}
const cantidadResiduos = await this.generacionRepository.count({
where: {
tenantId: ctx.tenantId,
contenedorId: almacenId,
estado: 'almacenado',
} as FindOptionsWhere<ResiduoGeneracion>,
});
const capacidad = almacen.capacidadM3 || 1;
const porcentaje = Math.min(100, Math.round((volumenM3 / capacidad) * 100));
return { volumenM3, porcentaje, cantidadResiduos };
}
// ========== Alertas ==========
async getAlertas(ctx: ServiceContext, fraccionamientoId?: string): Promise<AlertaAlmacen[]> {
const almacenes = await this.findAllConOcupacion(ctx, fraccionamientoId);
const alertas: AlertaAlmacen[] = [];
for (const almacen of almacenes) {
// Alerta de capacidad
if (almacen.porcentajeOcupacion >= 90) {
alertas.push({
almacenId: almacen.id,
nombre: almacen.nombre,
tipo: 'capacidad',
mensaje: `Almacen al ${almacen.porcentajeOcupacion}% de capacidad`,
nivel: almacen.porcentajeOcupacion >= 95 ? 'critico' : 'advertencia',
});
}
// Alertas de infraestructura
if (!almacen.tieneContencion) {
alertas.push({
almacenId: almacen.id,
nombre: almacen.nombre,
tipo: 'infraestructura',
mensaje: 'Almacen sin sistema de contencion',
nivel: 'critico',
});
}
if (!almacen.tieneTecho) {
alertas.push({
almacenId: almacen.id,
nombre: almacen.nombre,
tipo: 'infraestructura',
mensaje: 'Almacen sin techo de proteccion',
nivel: 'advertencia',
});
}
// Alertas normativas
if (!almacen.senalizacionOk) {
alertas.push({
almacenId: almacen.id,
nombre: almacen.nombre,
tipo: 'normativo',
mensaje: 'Senalizacion incompleta o danada',
nivel: 'advertencia',
});
}
if (almacen.estado === 'mantenimiento') {
alertas.push({
almacenId: almacen.id,
nombre: almacen.nombre,
tipo: 'infraestructura',
mensaje: 'Almacen en mantenimiento',
nivel: 'advertencia',
});
}
}
return alertas;
}
// ========== Estadísticas ==========
async getStats(ctx: ServiceContext, fraccionamientoId?: string): Promise<AlmacenStats> {
const baseQuery = () => {
const qb = this.almacenRepository
.createQueryBuilder('a')
.where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (fraccionamientoId) {
qb.andWhere('a.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
}
return qb;
};
const totalAlmacenes = await baseQuery().getCount();
const almacenesOperativos = await baseQuery()
.andWhere('a.estado = :estado', { estado: 'operativo' })
.getCount();
const almacenesLlenos = await baseQuery()
.andWhere('a.estado = :estado', { estado: 'lleno' })
.getCount();
const almacenesMantenimiento = await baseQuery()
.andWhere('a.estado = :estado', { estado: 'mantenimiento' })
.getCount();
const capacidadResult = await baseQuery()
.select('SUM(a.capacidad_m3)', 'total')
.getRawOne();
const capacidadTotalM3 = parseFloat(capacidadResult?.total || '0');
// Calculate total occupation
const almacenes = await this.findAllConOcupacion(ctx, fraccionamientoId);
const ocupacionTotalM3 = almacenes.reduce((sum, a) => sum + a.ocupacionActual, 0);
const porcentajeOcupacionGlobal = capacidadTotalM3 > 0
? Math.round((ocupacionTotalM3 / capacidadTotalM3) * 100)
: 0;
const almacenesSinContencion = await baseQuery()
.andWhere('a.tiene_contencion = false')
.getCount();
const almacenesSinTecho = await baseQuery()
.andWhere('a.tiene_techo = false')
.getCount();
const almacenesSinSenalizacion = await baseQuery()
.andWhere('a.senalizacion_ok = false')
.getCount();
return {
totalAlmacenes,
almacenesOperativos,
almacenesLlenos,
almacenesMantenimiento,
capacidadTotalM3,
ocupacionTotalM3,
porcentajeOcupacionGlobal,
almacenesSinContencion,
almacenesSinTecho,
almacenesSinSenalizacion,
};
}
}

View File

@ -0,0 +1,585 @@
/**
* AuditoriaAmbientalService - Servicio para gestión de auditorías ambientales
*
* RF-MAA017-006: Gestión de auditorías ambientales con programación,
* seguimiento de hallazgos y cumplimiento normativo.
*
* @module HSE
*/
import { Repository, FindOptionsWhere } from 'typeorm';
import { Auditoria, TipoAuditoria, ResultadoAuditoria } from '../entities/auditoria.entity';
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
export interface AuditoriaAmbientalFilters {
fraccionamientoId?: string;
tipo?: TipoAuditoria;
resultado?: ResultadoAuditoria;
pendiente?: boolean;
dateFrom?: Date;
dateTo?: Date;
anio?: number;
}
export interface CreateAuditoriaAmbientalDto {
fraccionamientoId: string;
tipo: TipoAuditoria;
fechaProgramada: Date;
auditor?: string;
observaciones?: string;
}
export interface UpdateAuditoriaAmbientalDto {
tipo?: TipoAuditoria;
fechaProgramada?: Date;
auditor?: string;
observaciones?: string;
}
export interface CompletarAuditoriaDto {
resultado: ResultadoAuditoria;
noConformidades: number;
observaciones?: string;
informeUrl?: string;
}
export interface ProgramarAuditoriaSerieDto {
fraccionamientoId: string;
tipo: TipoAuditoria;
fechaInicio: Date;
periodicidadMeses: number;
cantidad: number;
auditor?: string;
}
export interface AuditoriaAmbientalStats {
totalAuditorias: number;
auditoriasRealizadas: number;
auditoriasPendientes: number;
auditoriasVencidas: number;
porResultado: { resultado: string; count: number }[];
porTipo: { tipo: string; count: number }[];
totalNoConformidades: number;
promedioNoConformidades: number;
tendenciaMensual: { mes: string; realizadas: number; noConformidades: number }[];
}
export interface CalendarioAuditoria {
mes: string;
auditorias: {
id: string;
tipo: TipoAuditoria;
fechaProgramada: Date;
fraccionamientoNombre: string;
auditor: string | null;
estado: 'programada' | 'realizada' | 'vencida';
resultado?: ResultadoAuditoria;
}[];
}
export interface AlertaAuditoria {
auditoriaId: string;
tipo: TipoAuditoria;
fraccionamientoNombre: string;
tipoAlerta: 'vencida' | 'proxima' | 'sin_auditor';
mensaje: string;
diasRestantes?: number;
nivel: 'advertencia' | 'critico';
}
export class AuditoriaAmbientalService {
constructor(
private readonly auditoriaRepository: Repository<Auditoria>
) {}
// ========== CRUD de Auditorías ==========
async findAll(
ctx: ServiceContext,
filters: AuditoriaAmbientalFilters = {},
page: number = 1,
limit: number = 20
): Promise<PaginatedResult<Auditoria>> {
const skip = (page - 1) * limit;
const queryBuilder = this.auditoriaRepository
.createQueryBuilder('a')
.leftJoinAndSelect('a.fraccionamiento', 'fraccionamiento')
.where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (filters.fraccionamientoId) {
queryBuilder.andWhere('a.fraccionamiento_id = :fraccionamientoId', {
fraccionamientoId: filters.fraccionamientoId,
});
}
if (filters.tipo) {
queryBuilder.andWhere('a.tipo = :tipo', { tipo: filters.tipo });
}
if (filters.resultado) {
queryBuilder.andWhere('a.resultado = :resultado', { resultado: filters.resultado });
}
if (filters.pendiente === true) {
queryBuilder.andWhere('a.resultado IS NULL');
} else if (filters.pendiente === false) {
queryBuilder.andWhere('a.resultado IS NOT NULL');
}
if (filters.dateFrom) {
queryBuilder.andWhere('a.fecha_programada >= :dateFrom', { dateFrom: filters.dateFrom });
}
if (filters.dateTo) {
queryBuilder.andWhere('a.fecha_programada <= :dateTo', { dateTo: filters.dateTo });
}
if (filters.anio) {
queryBuilder.andWhere('EXTRACT(YEAR FROM a.fecha_programada) = :anio', { anio: filters.anio });
}
queryBuilder
.orderBy('a.fecha_programada', '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<Auditoria | null> {
return this.auditoriaRepository.findOne({
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<Auditoria>,
relations: ['fraccionamiento'],
});
}
async findPendientes(ctx: ServiceContext, fraccionamientoId?: string): Promise<Auditoria[]> {
const queryBuilder = this.auditoriaRepository
.createQueryBuilder('a')
.leftJoinAndSelect('a.fraccionamiento', 'fraccionamiento')
.where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('a.resultado IS NULL');
if (fraccionamientoId) {
queryBuilder.andWhere('a.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
}
return queryBuilder.orderBy('a.fecha_programada', 'ASC').getMany();
}
async findVencidas(ctx: ServiceContext): Promise<Auditoria[]> {
const today = new Date();
return this.auditoriaRepository
.createQueryBuilder('a')
.leftJoinAndSelect('a.fraccionamiento', 'fraccionamiento')
.where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('a.resultado IS NULL')
.andWhere('a.fecha_programada < :today', { today })
.orderBy('a.fecha_programada', 'ASC')
.getMany();
}
async create(ctx: ServiceContext, dto: CreateAuditoriaAmbientalDto): Promise<Auditoria> {
const auditoria = this.auditoriaRepository.create({
tenantId: ctx.tenantId,
fraccionamientoId: dto.fraccionamientoId,
tipo: dto.tipo,
fechaProgramada: dto.fechaProgramada,
auditor: dto.auditor,
observaciones: dto.observaciones,
});
return this.auditoriaRepository.save(auditoria);
}
async update(
ctx: ServiceContext,
id: string,
dto: UpdateAuditoriaAmbientalDto
): Promise<Auditoria | null> {
const auditoria = await this.findById(ctx, id);
if (!auditoria) return null;
if (auditoria.resultado) {
throw new Error('No se puede modificar una auditoria ya completada');
}
if (dto.tipo !== undefined) auditoria.tipo = dto.tipo;
if (dto.fechaProgramada !== undefined) auditoria.fechaProgramada = dto.fechaProgramada;
if (dto.auditor !== undefined) auditoria.auditor = dto.auditor;
if (dto.observaciones !== undefined) auditoria.observaciones = dto.observaciones;
return this.auditoriaRepository.save(auditoria);
}
async delete(ctx: ServiceContext, id: string): Promise<boolean> {
const auditoria = await this.findById(ctx, id);
if (!auditoria) return false;
if (auditoria.resultado) {
throw new Error('No se puede eliminar una auditoria ya completada');
}
const result = await this.auditoriaRepository.delete({
id,
tenantId: ctx.tenantId,
} as FindOptionsWhere<Auditoria>);
return (result.affected ?? 0) > 0;
}
// ========== Completar Auditoría ==========
async completar(
ctx: ServiceContext,
id: string,
dto: CompletarAuditoriaDto
): Promise<Auditoria | null> {
const auditoria = await this.findById(ctx, id);
if (!auditoria) return null;
if (auditoria.resultado) {
throw new Error('La auditoria ya fue completada');
}
auditoria.fechaRealizada = new Date();
auditoria.resultado = dto.resultado;
auditoria.noConformidades = dto.noConformidades;
if (dto.observaciones) auditoria.observaciones = dto.observaciones;
if (dto.informeUrl) auditoria.informeUrl = dto.informeUrl;
return this.auditoriaRepository.save(auditoria);
}
async asignarAuditor(ctx: ServiceContext, id: string, auditor: string): Promise<Auditoria | null> {
const auditoria = await this.findById(ctx, id);
if (!auditoria) return null;
auditoria.auditor = auditor;
return this.auditoriaRepository.save(auditoria);
}
async reprogramar(ctx: ServiceContext, id: string, nuevaFecha: Date): Promise<Auditoria | null> {
const auditoria = await this.findById(ctx, id);
if (!auditoria) return null;
if (auditoria.resultado) {
throw new Error('No se puede reprogramar una auditoria ya completada');
}
auditoria.fechaProgramada = nuevaFecha;
return this.auditoriaRepository.save(auditoria);
}
// ========== Programación en Serie ==========
async programarSerie(ctx: ServiceContext, dto: ProgramarAuditoriaSerieDto): Promise<Auditoria[]> {
const auditorias: Auditoria[] = [];
let fecha = new Date(dto.fechaInicio);
for (let i = 0; i < dto.cantidad; i++) {
const auditoria = this.auditoriaRepository.create({
tenantId: ctx.tenantId,
fraccionamientoId: dto.fraccionamientoId,
tipo: dto.tipo,
fechaProgramada: new Date(fecha),
auditor: dto.auditor,
});
const saved = await this.auditoriaRepository.save(auditoria);
auditorias.push(saved);
// Advance to next date
fecha.setMonth(fecha.getMonth() + dto.periodicidadMeses);
}
return auditorias;
}
// ========== Calendario ==========
async getCalendario(ctx: ServiceContext, anio: number, fraccionamientoId?: string): Promise<CalendarioAuditoria[]> {
const queryBuilder = this.auditoriaRepository
.createQueryBuilder('a')
.leftJoinAndSelect('a.fraccionamiento', 'fraccionamiento')
.where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('EXTRACT(YEAR FROM a.fecha_programada) = :anio', { anio });
if (fraccionamientoId) {
queryBuilder.andWhere('a.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
}
const auditorias = await queryBuilder.orderBy('a.fecha_programada', 'ASC').getMany();
const today = new Date();
const calendario: CalendarioAuditoria[] = [];
// Group by month
const meses: Record<string, CalendarioAuditoria['auditorias']> = {};
for (const audit of auditorias) {
const fecha = new Date(audit.fechaProgramada);
const mesKey = `${fecha.getFullYear()}-${String(fecha.getMonth() + 1).padStart(2, '0')}`;
if (!meses[mesKey]) {
meses[mesKey] = [];
}
let estado: 'programada' | 'realizada' | 'vencida' = 'programada';
if (audit.resultado) {
estado = 'realizada';
} else if (fecha < today) {
estado = 'vencida';
}
meses[mesKey].push({
id: audit.id,
tipo: audit.tipo,
fechaProgramada: audit.fechaProgramada,
fraccionamientoNombre: audit.fraccionamiento?.nombre || 'Sin asignar',
auditor: audit.auditor,
estado,
resultado: audit.resultado,
});
}
// Convert to array
for (const [mes, auditoriasDelMes] of Object.entries(meses)) {
calendario.push({ mes, auditorias: auditoriasDelMes });
}
return calendario;
}
// ========== Alertas ==========
async getAlertas(ctx: ServiceContext, diasProximo: number = 7): Promise<AlertaAuditoria[]> {
const alertas: AlertaAuditoria[] = [];
const today = new Date();
const proximaFecha = new Date();
proximaFecha.setDate(proximaFecha.getDate() + diasProximo);
const pendientes = await this.auditoriaRepository
.createQueryBuilder('a')
.leftJoinAndSelect('a.fraccionamiento', 'fraccionamiento')
.where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('a.resultado IS NULL')
.getMany();
for (const auditoria of pendientes) {
const fecha = new Date(auditoria.fechaProgramada);
const fracNombre = auditoria.fraccionamiento?.nombre || 'Sin asignar';
// Vencidas
if (fecha < today) {
const diasVencido = Math.floor((today.getTime() - fecha.getTime()) / (1000 * 60 * 60 * 24));
alertas.push({
auditoriaId: auditoria.id,
tipo: auditoria.tipo,
fraccionamientoNombre: fracNombre,
tipoAlerta: 'vencida',
mensaje: `Auditoria ${auditoria.tipo} vencida hace ${diasVencido} dias`,
diasRestantes: -diasVencido,
nivel: 'critico',
});
}
// Próximas
else if (fecha <= proximaFecha) {
const diasRestantes = Math.floor((fecha.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
alertas.push({
auditoriaId: auditoria.id,
tipo: auditoria.tipo,
fraccionamientoNombre: fracNombre,
tipoAlerta: 'proxima',
mensaje: `Auditoria ${auditoria.tipo} programada en ${diasRestantes} dias`,
diasRestantes,
nivel: 'advertencia',
});
}
// Sin auditor
if (!auditoria.auditor && fecha >= today) {
alertas.push({
auditoriaId: auditoria.id,
tipo: auditoria.tipo,
fraccionamientoNombre: fracNombre,
tipoAlerta: 'sin_auditor',
mensaje: `Auditoria ${auditoria.tipo} sin auditor asignado`,
nivel: 'advertencia',
});
}
}
// Sort by urgency
alertas.sort((a, b) => {
if (a.nivel === 'critico' && b.nivel !== 'critico') return -1;
if (a.nivel !== 'critico' && b.nivel === 'critico') return 1;
return (a.diasRestantes ?? 999) - (b.diasRestantes ?? 999);
});
return alertas;
}
// ========== Estadísticas ==========
async getStats(ctx: ServiceContext, fraccionamientoId?: string, anio?: number): Promise<AuditoriaAmbientalStats> {
const baseQuery = () => {
const qb = this.auditoriaRepository
.createQueryBuilder('a')
.where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (fraccionamientoId) {
qb.andWhere('a.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
}
if (anio) {
qb.andWhere('EXTRACT(YEAR FROM a.fecha_programada) = :anio', { anio });
}
return qb;
};
const today = new Date();
const totalAuditorias = await baseQuery().getCount();
const auditoriasRealizadas = await baseQuery()
.andWhere('a.resultado IS NOT NULL')
.getCount();
const auditoriasPendientes = await baseQuery()
.andWhere('a.resultado IS NULL')
.andWhere('a.fecha_programada >= :today', { today })
.getCount();
const auditoriasVencidas = await baseQuery()
.andWhere('a.resultado IS NULL')
.andWhere('a.fecha_programada < :today', { today })
.getCount();
// Por resultado
const porResultadoRaw = await baseQuery()
.andWhere('a.resultado IS NOT NULL')
.select('a.resultado', 'resultado')
.addSelect('COUNT(*)', 'count')
.groupBy('a.resultado')
.getRawMany();
const porResultado = porResultadoRaw.map((r) => ({
resultado: r.resultado,
count: parseInt(r.count, 10),
}));
// Por tipo
const porTipoRaw = await baseQuery()
.select('a.tipo', 'tipo')
.addSelect('COUNT(*)', 'count')
.groupBy('a.tipo')
.getRawMany();
const porTipo = porTipoRaw.map((t) => ({
tipo: t.tipo,
count: parseInt(t.count, 10),
}));
// No conformidades
const noConfResult = await baseQuery()
.andWhere('a.resultado IS NOT NULL')
.select('SUM(a.no_conformidades)', 'total')
.addSelect('AVG(a.no_conformidades)', 'promedio')
.getRawOne();
const totalNoConformidades = parseInt(noConfResult?.total || '0', 10);
const promedioNoConformidades = parseFloat(noConfResult?.promedio || '0');
// Tendencia mensual (últimos 12 meses)
const doceMesesAtras = new Date();
doceMesesAtras.setMonth(doceMesesAtras.getMonth() - 12);
const tendenciaRaw = await this.auditoriaRepository
.createQueryBuilder('a')
.where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('a.fecha_programada >= :doceMesesAtras', { doceMesesAtras })
.andWhere('a.resultado IS NOT NULL')
.select("TO_CHAR(a.fecha_realizada, 'YYYY-MM')", 'mes')
.addSelect('COUNT(*)', 'realizadas')
.addSelect('SUM(a.no_conformidades)', 'noConformidades')
.groupBy("TO_CHAR(a.fecha_realizada, 'YYYY-MM')")
.orderBy("TO_CHAR(a.fecha_realizada, 'YYYY-MM')", 'ASC')
.getRawMany();
const tendenciaMensual = tendenciaRaw.map((t) => ({
mes: t.mes,
realizadas: parseInt(t.realizadas, 10),
noConformidades: parseInt(t.noConformidades || '0', 10),
}));
return {
totalAuditorias,
auditoriasRealizadas,
auditoriasPendientes,
auditoriasVencidas,
porResultado,
porTipo,
totalNoConformidades,
promedioNoConformidades: Math.round(promedioNoConformidades * 100) / 100,
tendenciaMensual,
};
}
// ========== Historial por Fraccionamiento ==========
async getHistorialFraccionamiento(
ctx: ServiceContext,
fraccionamientoId: string
): Promise<Auditoria[]> {
return this.auditoriaRepository.find({
where: {
tenantId: ctx.tenantId,
fraccionamientoId,
} as FindOptionsWhere<Auditoria>,
order: { fechaProgramada: 'DESC' },
});
}
// ========== Comparativa entre Obras ==========
async getComparativaObras(ctx: ServiceContext, anio: number): Promise<{
fraccionamientoId: string;
fraccionamientoNombre: string;
totalAuditorias: number;
aprobadas: number;
noAprobadas: number;
porcentajeAprobacion: number;
totalNoConformidades: number;
}[]> {
const result = await this.auditoriaRepository
.createQueryBuilder('a')
.leftJoin('a.fraccionamiento', 'f')
.where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('EXTRACT(YEAR FROM a.fecha_programada) = :anio', { anio })
.andWhere('a.resultado IS NOT NULL')
.select('a.fraccionamiento_id', 'fraccionamientoId')
.addSelect('f.nombre', 'fraccionamientoNombre')
.addSelect('COUNT(*)', 'totalAuditorias')
.addSelect("SUM(CASE WHEN a.resultado IN ('aprobada', 'aprobada_observaciones') THEN 1 ELSE 0 END)", 'aprobadas')
.addSelect("SUM(CASE WHEN a.resultado = 'no_aprobada' THEN 1 ELSE 0 END)", 'noAprobadas')
.addSelect('SUM(a.no_conformidades)', 'totalNoConformidades')
.groupBy('a.fraccionamiento_id')
.addGroupBy('f.nombre')
.getRawMany();
return result.map((r) => {
const total = parseInt(r.totalAuditorias, 10);
const aprobadas = parseInt(r.aprobadas, 10);
return {
fraccionamientoId: r.fraccionamientoId,
fraccionamientoNombre: r.fraccionamientoNombre || 'Sin nombre',
totalAuditorias: total,
aprobadas,
noAprobadas: parseInt(r.noAprobadas, 10),
porcentajeAprobacion: total > 0 ? Math.round((aprobadas / total) * 100) : 0,
totalNoConformidades: parseInt(r.totalNoConformidades || '0', 10),
};
});
}
}

View File

@ -0,0 +1,450 @@
/**
* ComisionSeguridadService - Servicio para comisiones de seguridad e higiene
*
* RF-MAA017-005: Cumplimiento STPS - Comisiones de Seguridad
*
* @module HSE
*/
import { Repository, FindOptionsWhere } from 'typeorm';
import { ComisionSeguridad, EstadoComision } from '../entities/comision-seguridad.entity';
import { ComisionIntegrante, RolComision, Representacion } from '../entities/comision-integrante.entity';
import { ComisionRecorrido } from '../entities/comision-recorrido.entity';
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
export interface CreateComisionSeguridadDto {
fraccionamientoId: string;
fechaConstitucion: Date;
numeroActa?: string;
vigenciaInicio: Date;
vigenciaFin: Date;
documentoActaUrl?: string;
}
export interface UpdateComisionSeguridadDto {
numeroActa?: string;
vigenciaInicio?: Date;
vigenciaFin?: Date;
documentoActaUrl?: string;
}
export interface ComisionSeguridadFilters {
fraccionamientoId?: string;
estado?: EstadoComision;
vigente?: boolean;
}
export interface AddIntegranteComisionDto {
employeeId: string;
rol: RolComision;
representacion: Representacion;
fechaNombramiento: Date;
}
export interface CreateRecorridoComisionDto {
comisionId: string;
fechaProgramada: Date;
areasRecorridas?: string;
}
export interface CompletarRecorridoDto {
fechaRealizada?: Date;
hallazgos?: string;
recomendaciones?: string;
numeroActa?: string;
documentoActaUrl?: string;
}
export interface ComisionStats {
totalComisiones: number;
comisionesActivas: number;
comisionesVencidas: number;
totalIntegrantes: number;
recorridosProgramados: number;
recorridosRealizados: number;
recorridosPendientes: number;
}
export type EstadoRecorrido = 'programado' | 'realizado' | 'cancelado' | 'pendiente';
export class ComisionSeguridadService {
constructor(
private readonly comisionRepository: Repository<ComisionSeguridad>,
private readonly integranteRepository: Repository<ComisionIntegrante>,
private readonly recorridoRepository: Repository<ComisionRecorrido>
) {}
// ========== Comisiones ==========
async findComisiones(
ctx: ServiceContext,
filters: ComisionSeguridadFilters = {},
page: number = 1,
limit: number = 20
): Promise<PaginatedResult<ComisionSeguridad>> {
const skip = (page - 1) * limit;
const today = new Date();
const queryBuilder = this.comisionRepository
.createQueryBuilder('c')
.leftJoinAndSelect('c.fraccionamiento', 'fraccionamiento')
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (filters.fraccionamientoId) {
queryBuilder.andWhere('c.fraccionamiento_id = :fraccionamientoId', {
fraccionamientoId: filters.fraccionamientoId,
});
}
if (filters.estado) {
queryBuilder.andWhere('c.estado = :estado', { estado: filters.estado });
}
if (filters.vigente === true) {
queryBuilder.andWhere('c.vigencia_fin >= :today', { today });
queryBuilder.andWhere('c.estado = :estado', { estado: 'activa' });
}
if (filters.vigente === false) {
queryBuilder.andWhere('c.vigencia_fin < :today', { today });
}
queryBuilder
.orderBy('c.fecha_constitucion', '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<ComisionSeguridad | null> {
return this.comisionRepository.findOne({
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<ComisionSeguridad>,
relations: ['fraccionamiento', 'integrantes', 'integrantes.employee', 'recorridos'],
});
}
async findVigenteByFraccionamiento(ctx: ServiceContext, fraccionamientoId: string): Promise<ComisionSeguridad | null> {
const today = new Date();
return this.comisionRepository
.createQueryBuilder('c')
.leftJoinAndSelect('c.integrantes', 'integrantes', 'integrantes.activo = true')
.leftJoinAndSelect('integrantes.employee', 'employee')
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('c.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId })
.andWhere('c.estado = :estado', { estado: 'activa' })
.andWhere('c.vigencia_fin >= :today', { today })
.getOne();
}
async create(ctx: ServiceContext, dto: CreateComisionSeguridadDto): Promise<ComisionSeguridad> {
const existingActive = await this.findVigenteByFraccionamiento(ctx, dto.fraccionamientoId);
if (existingActive) {
throw new Error('Ya existe una comisión activa para este fraccionamiento');
}
const comision = this.comisionRepository.create({
tenantId: ctx.tenantId,
fraccionamientoId: dto.fraccionamientoId,
fechaConstitucion: dto.fechaConstitucion,
numeroActa: dto.numeroActa,
vigenciaInicio: dto.vigenciaInicio,
vigenciaFin: dto.vigenciaFin,
documentoActaUrl: dto.documentoActaUrl,
estado: 'activa',
});
return this.comisionRepository.save(comision);
}
async update(ctx: ServiceContext, id: string, dto: UpdateComisionSeguridadDto): Promise<ComisionSeguridad | null> {
const comision = await this.findById(ctx, id);
if (!comision) return null;
if (dto.numeroActa !== undefined) comision.numeroActa = dto.numeroActa;
if (dto.vigenciaInicio) comision.vigenciaInicio = dto.vigenciaInicio;
if (dto.vigenciaFin) comision.vigenciaFin = dto.vigenciaFin;
if (dto.documentoActaUrl !== undefined) comision.documentoActaUrl = dto.documentoActaUrl;
return this.comisionRepository.save(comision);
}
async updateEstado(ctx: ServiceContext, id: string, estado: EstadoComision): Promise<ComisionSeguridad | null> {
const comision = await this.findById(ctx, id);
if (!comision) return null;
comision.estado = estado;
return this.comisionRepository.save(comision);
}
async renovarComision(
ctx: ServiceContext,
comisionAnteriorId: string,
dto: CreateComisionSeguridadDto
): Promise<ComisionSeguridad> {
const comisionAnterior = await this.findById(ctx, comisionAnteriorId);
if (!comisionAnterior) {
throw new Error('Comisión anterior no encontrada');
}
comisionAnterior.estado = 'renovada';
await this.comisionRepository.save(comisionAnterior);
return this.create(ctx, dto);
}
// ========== Integrantes ==========
async findIntegrantesByComision(comisionId: string, soloActivos: boolean = true): Promise<ComisionIntegrante[]> {
const where: FindOptionsWhere<ComisionIntegrante> = { comisionId };
if (soloActivos) {
where.activo = true;
}
return this.integranteRepository.find({
where,
relations: ['employee'],
order: { rol: 'ASC', representacion: 'ASC' },
});
}
async addIntegrante(comisionId: string, dto: AddIntegranteComisionDto): Promise<ComisionIntegrante> {
const existing = await this.integranteRepository.findOne({
where: {
comisionId,
employeeId: dto.employeeId,
activo: true,
} as FindOptionsWhere<ComisionIntegrante>,
});
if (existing) {
throw new Error('El empleado ya es integrante activo de esta comisión');
}
const integrante = this.integranteRepository.create({
comisionId,
employeeId: dto.employeeId,
rol: dto.rol,
representacion: dto.representacion,
fechaNombramiento: dto.fechaNombramiento,
activo: true,
});
return this.integranteRepository.save(integrante);
}
async updateIntegrante(
integranteId: string,
rol?: RolComision,
representacion?: Representacion
): Promise<ComisionIntegrante | null> {
const integrante = await this.integranteRepository.findOne({
where: { id: integranteId } as FindOptionsWhere<ComisionIntegrante>,
});
if (!integrante) return null;
if (rol) integrante.rol = rol;
if (representacion) integrante.representacion = representacion;
return this.integranteRepository.save(integrante);
}
async bajaIntegrante(integranteId: string): Promise<ComisionIntegrante | null> {
const integrante = await this.integranteRepository.findOne({
where: { id: integranteId } as FindOptionsWhere<ComisionIntegrante>,
});
if (!integrante) return null;
integrante.activo = false;
return this.integranteRepository.save(integrante);
}
// ========== Recorridos ==========
async findRecorridosByComision(comisionId: string): Promise<ComisionRecorrido[]> {
return this.recorridoRepository.find({
where: { comisionId } as FindOptionsWhere<ComisionRecorrido>,
order: { fechaProgramada: 'DESC' },
});
}
async findRecorridoById(id: string): Promise<ComisionRecorrido | null> {
return this.recorridoRepository.findOne({
where: { id } as FindOptionsWhere<ComisionRecorrido>,
relations: ['comision', 'comision.fraccionamiento'],
});
}
async createRecorrido(dto: CreateRecorridoComisionDto): Promise<ComisionRecorrido> {
const recorrido = this.recorridoRepository.create({
comisionId: dto.comisionId,
fechaProgramada: dto.fechaProgramada,
areasRecorridas: dto.areasRecorridas,
estado: 'programado',
});
return this.recorridoRepository.save(recorrido);
}
async completarRecorrido(id: string, dto: CompletarRecorridoDto): Promise<ComisionRecorrido | null> {
const recorrido = await this.findRecorridoById(id);
if (!recorrido) return null;
recorrido.fechaRealizada = dto.fechaRealizada || new Date();
if (dto.hallazgos) recorrido.hallazgos = dto.hallazgos;
if (dto.recomendaciones) recorrido.recomendaciones = dto.recomendaciones;
if (dto.numeroActa) recorrido.numeroActa = dto.numeroActa;
if (dto.documentoActaUrl) recorrido.documentoActaUrl = dto.documentoActaUrl;
recorrido.estado = 'realizado';
return this.recorridoRepository.save(recorrido);
}
async cancelarRecorrido(id: string): Promise<ComisionRecorrido | null> {
const recorrido = await this.findRecorridoById(id);
if (!recorrido) return null;
if (recorrido.estado === 'realizado') {
throw new Error('No se puede cancelar un recorrido ya realizado');
}
recorrido.estado = 'cancelado';
return this.recorridoRepository.save(recorrido);
}
async getRecorridosPendientes(ctx: ServiceContext, fraccionamientoId?: string): Promise<ComisionRecorrido[]> {
const today = new Date();
const queryBuilder = this.recorridoRepository
.createQueryBuilder('r')
.innerJoinAndSelect('r.comision', 'comision')
.leftJoinAndSelect('comision.fraccionamiento', 'fraccionamiento')
.where('comision.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('r.estado IN (:...estados)', { estados: ['programado', 'pendiente'] })
.andWhere('r.fecha_programada <= :today', { today });
if (fraccionamientoId) {
queryBuilder.andWhere('comision.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
}
return queryBuilder.orderBy('r.fecha_programada', 'ASC').getMany();
}
async getRecorridosProximos(ctx: ServiceContext, dias: number = 30): Promise<ComisionRecorrido[]> {
const today = new Date();
const fechaLimite = new Date();
fechaLimite.setDate(fechaLimite.getDate() + dias);
return this.recorridoRepository
.createQueryBuilder('r')
.innerJoinAndSelect('r.comision', 'comision')
.leftJoinAndSelect('comision.fraccionamiento', 'fraccionamiento')
.where('comision.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('r.estado = :estado', { estado: 'programado' })
.andWhere('r.fecha_programada >= :today', { today })
.andWhere('r.fecha_programada <= :fechaLimite', { fechaLimite })
.orderBy('r.fecha_programada', 'ASC')
.getMany();
}
// ========== Verificar Vigencia ==========
async verificarYActualizarVigencias(ctx: ServiceContext): Promise<number> {
const today = new Date();
const comisionesVencidas = await this.comisionRepository
.createQueryBuilder('c')
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('c.estado = :estado', { estado: 'activa' })
.andWhere('c.vigencia_fin < :today', { today })
.getMany();
for (const comision of comisionesVencidas) {
comision.estado = 'vencida';
await this.comisionRepository.save(comision);
}
return comisionesVencidas.length;
}
// ========== Estadísticas ==========
async getStats(ctx: ServiceContext, fraccionamientoId?: string): Promise<ComisionStats> {
const today = new Date();
const comisionQuery = this.comisionRepository
.createQueryBuilder('c')
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (fraccionamientoId) {
comisionQuery.andWhere('c.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
}
const totalComisiones = await comisionQuery.getCount();
const comisionesActivas = await this.comisionRepository
.createQueryBuilder('c')
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('c.estado = :estado', { estado: 'activa' })
.andWhere('c.vigencia_fin >= :today', { today })
.andWhere(fraccionamientoId ? 'c.fraccionamiento_id = :fraccionamientoId' : '1=1', { fraccionamientoId })
.getCount();
const comisionesVencidas = await this.comisionRepository
.createQueryBuilder('c')
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('c.estado = :estado', { estado: 'vencida' })
.andWhere(fraccionamientoId ? 'c.fraccionamiento_id = :fraccionamientoId' : '1=1', { fraccionamientoId })
.getCount();
const totalIntegrantes = await this.integranteRepository
.createQueryBuilder('i')
.innerJoin('i.comision', 'c')
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('i.activo = true')
.andWhere(fraccionamientoId ? 'c.fraccionamiento_id = :fraccionamientoId' : '1=1', { fraccionamientoId })
.getCount();
const recorridosQuery = this.recorridoRepository
.createQueryBuilder('r')
.innerJoin('r.comision', 'c')
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (fraccionamientoId) {
recorridosQuery.andWhere('c.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
}
const recorridosProgramados = await recorridosQuery
.clone()
.andWhere('r.estado = :estado', { estado: 'programado' })
.getCount();
const recorridosRealizados = await recorridosQuery
.clone()
.andWhere('r.estado = :estado', { estado: 'realizado' })
.getCount();
const recorridosPendientes = await recorridosQuery
.clone()
.andWhere('r.estado IN (:...estados)', { estados: ['programado', 'pendiente'] })
.andWhere('r.fecha_programada < :today', { today })
.getCount();
return {
totalComisiones,
comisionesActivas,
comisionesVencidas,
totalIntegrantes,
recorridosProgramados,
recorridosRealizados,
recorridosPendientes,
};
}
}

View File

@ -0,0 +1,298 @@
/**
* ConstanciaDc3Service - Servicio para gestión de constancias DC-3
*
* RF-MAA017-002: Control de Capacitaciones - Constancias DC-3 STPS
*
* @module HSE
*/
import { Repository, FindOptionsWhere, LessThan } from 'typeorm';
import { ConstanciaDc3 } from '../entities/constancia-dc3.entity';
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
export interface CreateConstanciaDc3Dto {
folio: string;
asistenteId: string;
employeeId: string;
capacitacionId: string;
fechaEmision: Date;
fechaVencimiento?: Date;
documentoUrl?: string;
}
export interface ConstanciaDc3Filters {
employeeId?: string;
capacitacionId?: string;
vigente?: boolean;
vencidaProximamente?: boolean;
diasProximoVencimiento?: number;
dateFrom?: Date;
dateTo?: Date;
search?: string;
}
export interface ConstanciaDc3Stats {
totalConstancias: number;
vigentes: number;
vencidas: number;
porVencer: number;
sinVencimiento: number;
}
export interface EmpleadoCapacitacionStatus {
employeeId: string;
employeeName: string;
capacitacionId: string;
capacitacionNombre: string;
constanciaId?: string;
folio?: string;
fechaEmision?: Date;
fechaVencimiento?: Date;
estado: 'vigente' | 'vencida' | 'por_vencer' | 'sin_constancia';
}
export class ConstanciaDc3Service {
constructor(private readonly repository: Repository<ConstanciaDc3>) {}
async findAll(
ctx: ServiceContext,
filters: ConstanciaDc3Filters = {},
page: number = 1,
limit: number = 20
): Promise<PaginatedResult<ConstanciaDc3>> {
const skip = (page - 1) * limit;
const today = new Date();
const queryBuilder = this.repository
.createQueryBuilder('constancia')
.leftJoinAndSelect('constancia.employee', 'employee')
.leftJoinAndSelect('constancia.capacitacion', 'capacitacion')
.leftJoinAndSelect('constancia.asistente', 'asistente')
.where('constancia.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (filters.employeeId) {
queryBuilder.andWhere('constancia.employee_id = :employeeId', {
employeeId: filters.employeeId,
});
}
if (filters.capacitacionId) {
queryBuilder.andWhere('constancia.capacitacion_id = :capacitacionId', {
capacitacionId: filters.capacitacionId,
});
}
if (filters.vigente === true) {
queryBuilder.andWhere(
'(constancia.fecha_vencimiento IS NULL OR constancia.fecha_vencimiento >= :today)',
{ today }
);
}
if (filters.vigente === false) {
queryBuilder.andWhere('constancia.fecha_vencimiento < :today', { today });
}
if (filters.vencidaProximamente) {
const diasLimite = filters.diasProximoVencimiento || 30;
const fechaLimite = new Date();
fechaLimite.setDate(fechaLimite.getDate() + diasLimite);
queryBuilder.andWhere(
'constancia.fecha_vencimiento >= :today AND constancia.fecha_vencimiento <= :fechaLimite',
{ today, fechaLimite }
);
}
if (filters.dateFrom) {
queryBuilder.andWhere('constancia.fecha_emision >= :dateFrom', { dateFrom: filters.dateFrom });
}
if (filters.dateTo) {
queryBuilder.andWhere('constancia.fecha_emision <= :dateTo', { dateTo: filters.dateTo });
}
if (filters.search) {
queryBuilder.andWhere(
'(constancia.folio ILIKE :search OR employee.nombres ILIKE :search OR employee.apellido_paterno ILIKE :search)',
{ search: `%${filters.search}%` }
);
}
queryBuilder
.orderBy('constancia.fecha_emision', '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<ConstanciaDc3 | null> {
return this.repository.findOne({
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<ConstanciaDc3>,
relations: ['employee', 'capacitacion', 'asistente'],
});
}
async findByFolio(ctx: ServiceContext, folio: string): Promise<ConstanciaDc3 | null> {
return this.repository.findOne({
where: { tenantId: ctx.tenantId, folio } as FindOptionsWhere<ConstanciaDc3>,
relations: ['employee', 'capacitacion'],
});
}
async create(ctx: ServiceContext, dto: CreateConstanciaDc3Dto): Promise<ConstanciaDc3> {
const existingFolio = await this.findByFolio(ctx, dto.folio);
if (existingFolio) {
throw new Error(`Ya existe una constancia DC-3 con el folio ${dto.folio}`);
}
const constancia = this.repository.create({
tenantId: ctx.tenantId,
folio: dto.folio,
asistenteId: dto.asistenteId,
employeeId: dto.employeeId,
capacitacionId: dto.capacitacionId,
fechaEmision: dto.fechaEmision,
fechaVencimiento: dto.fechaVencimiento,
documentoUrl: dto.documentoUrl,
});
return this.repository.save(constancia);
}
async updateDocumentoUrl(ctx: ServiceContext, id: string, documentoUrl: string): Promise<ConstanciaDc3 | null> {
const constancia = await this.findById(ctx, id);
if (!constancia) return null;
constancia.documentoUrl = documentoUrl;
return this.repository.save(constancia);
}
async findByEmployee(ctx: ServiceContext, employeeId: string): Promise<ConstanciaDc3[]> {
return this.repository.find({
where: { tenantId: ctx.tenantId, employeeId } as FindOptionsWhere<ConstanciaDc3>,
relations: ['capacitacion'],
order: { fechaEmision: 'DESC' },
});
}
async findVigenteByEmployeeAndCapacitacion(
ctx: ServiceContext,
employeeId: string,
capacitacionId: string
): Promise<ConstanciaDc3 | null> {
const today = new Date();
return this.repository
.createQueryBuilder('c')
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('c.employee_id = :employeeId', { employeeId })
.andWhere('c.capacitacion_id = :capacitacionId', { capacitacionId })
.andWhere('(c.fecha_vencimiento IS NULL OR c.fecha_vencimiento >= :today)', { today })
.orderBy('c.fecha_emision', 'DESC')
.getOne();
}
async getConstanciasVencidas(ctx: ServiceContext): Promise<ConstanciaDc3[]> {
const today = new Date();
return this.repository.find({
where: {
tenantId: ctx.tenantId,
fechaVencimiento: LessThan(today),
} as FindOptionsWhere<ConstanciaDc3>,
relations: ['employee', 'capacitacion'],
order: { fechaVencimiento: 'ASC' },
});
}
async getConstanciasPorVencer(ctx: ServiceContext, dias: number = 30): Promise<ConstanciaDc3[]> {
const today = new Date();
const fechaLimite = new Date();
fechaLimite.setDate(fechaLimite.getDate() + dias);
return this.repository
.createQueryBuilder('c')
.leftJoinAndSelect('c.employee', 'employee')
.leftJoinAndSelect('c.capacitacion', 'capacitacion')
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('c.fecha_vencimiento >= :today', { today })
.andWhere('c.fecha_vencimiento <= :fechaLimite', { fechaLimite })
.orderBy('c.fecha_vencimiento', 'ASC')
.getMany();
}
async getStats(ctx: ServiceContext): Promise<ConstanciaDc3Stats> {
const today = new Date();
const treintaDias = new Date();
treintaDias.setDate(treintaDias.getDate() + 30);
const totalConstancias = await this.repository.count({
where: { tenantId: ctx.tenantId } as FindOptionsWhere<ConstanciaDc3>,
});
const vigentes = await this.repository
.createQueryBuilder('c')
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('(c.fecha_vencimiento IS NULL OR c.fecha_vencimiento >= :today)', { today })
.getCount();
const vencidas = await this.repository.count({
where: {
tenantId: ctx.tenantId,
fechaVencimiento: LessThan(today),
} as FindOptionsWhere<ConstanciaDc3>,
});
const porVencer = await this.repository
.createQueryBuilder('c')
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('c.fecha_vencimiento >= :today', { today })
.andWhere('c.fecha_vencimiento <= :treintaDias', { treintaDias })
.getCount();
const sinVencimiento = await this.repository
.createQueryBuilder('c')
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('c.fecha_vencimiento IS NULL')
.getCount();
return {
totalConstancias,
vigentes,
vencidas,
porVencer,
sinVencimiento,
};
}
async generateFolio(ctx: ServiceContext, year?: number): Promise<string> {
const anio = year || new Date().getFullYear();
const prefix = `DC3-${anio}`;
const lastConstancia = await this.repository
.createQueryBuilder('c')
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('c.folio LIKE :prefix', { prefix: `${prefix}%` })
.orderBy('c.folio', 'DESC')
.getOne();
let nextNumber = 1;
if (lastConstancia) {
const parts = lastConstancia.folio.split('-');
if (parts.length >= 3) {
nextNumber = parseInt(parts[2], 10) + 1;
}
}
return `${prefix}-${nextNumber.toString().padStart(5, '0')}`;
}
}

View File

@ -0,0 +1,378 @@
/**
* CumplimientoService - Servicio para cumplimiento normativo por obra
*
* RF-MAA017-005: Cumplimiento STPS - Verificación de cumplimiento
*
* @module HSE
*/
import { Repository, FindOptionsWhere } from 'typeorm';
import { CumplimientoObra, EstadoCumplimiento } from '../entities/cumplimiento-obra.entity';
import { NormaStps } from '../entities/norma-stps.entity';
import { NormaRequisito } from '../entities/norma-requisito.entity';
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
export interface CreateCumplimientoObraDto {
fraccionamientoId: string;
normaId: string;
requisitoId?: string;
evaluadorId?: string;
fechaEvaluacion: Date;
estado?: EstadoCumplimiento;
evidenciaUrl?: string;
observaciones?: string;
fechaCompromiso?: Date;
}
export interface UpdateCumplimientoDto {
estado?: EstadoCumplimiento;
evidenciaUrl?: string;
observaciones?: string;
fechaCompromiso?: Date;
evaluadorId?: string;
}
export interface CumplimientoObraFilters {
fraccionamientoId?: string;
normaId?: string;
requisitoId?: string;
estado?: EstadoCumplimiento;
evaluadorId?: string;
fechaDesde?: Date;
fechaHasta?: Date;
}
export interface CumplimientoResumen {
normaId: string;
normaCodigo: string;
normaNombre: string;
totalRequisitos: number;
cumple: number;
parcial: number;
noCumple: number;
noAplica: number;
porcentajeCumplimiento: number;
}
export interface CumplimientoStats {
totalEvaluaciones: number;
cumple: number;
parcial: number;
noCumple: number;
noAplica: number;
porcentajeCumplimiento: number;
pendientesCompromiso: number;
}
export class CumplimientoService {
constructor(
private readonly cumplimientoRepository: Repository<CumplimientoObra>,
private readonly normaRepository: Repository<NormaStps>,
private readonly requisitoRepository: Repository<NormaRequisito>
) {}
// ========== Normas ==========
async findNormas(soloAplicaConstruccion: boolean = true): Promise<NormaStps[]> {
const where: FindOptionsWhere<NormaStps> = { activo: true };
if (soloAplicaConstruccion) {
where.aplicaConstruccion = true;
}
return this.normaRepository.find({
where,
order: { codigo: 'ASC' },
});
}
async findNormaById(id: string): Promise<NormaStps | null> {
return this.normaRepository.findOne({
where: { id } as FindOptionsWhere<NormaStps>,
relations: ['requisitos'],
});
}
async findRequisitosByNorma(normaId: string): Promise<NormaRequisito[]> {
return this.requisitoRepository.find({
where: { normaId } as FindOptionsWhere<NormaRequisito>,
order: { numero: 'ASC' },
});
}
// ========== Cumplimiento ==========
async findCumplimientos(
ctx: ServiceContext,
filters: CumplimientoObraFilters = {},
page: number = 1,
limit: number = 20
): Promise<PaginatedResult<CumplimientoObra>> {
const skip = (page - 1) * limit;
const queryBuilder = this.cumplimientoRepository
.createQueryBuilder('c')
.leftJoinAndSelect('c.fraccionamiento', 'fraccionamiento')
.leftJoinAndSelect('c.norma', 'norma')
.leftJoinAndSelect('c.requisito', 'requisito')
.leftJoinAndSelect('c.evaluador', 'evaluador')
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (filters.fraccionamientoId) {
queryBuilder.andWhere('c.fraccionamiento_id = :fraccionamientoId', {
fraccionamientoId: filters.fraccionamientoId,
});
}
if (filters.normaId) {
queryBuilder.andWhere('c.norma_id = :normaId', { normaId: filters.normaId });
}
if (filters.requisitoId) {
queryBuilder.andWhere('c.requisito_id = :requisitoId', { requisitoId: filters.requisitoId });
}
if (filters.estado) {
queryBuilder.andWhere('c.estado = :estado', { estado: filters.estado });
}
if (filters.evaluadorId) {
queryBuilder.andWhere('c.evaluador_id = :evaluadorId', { evaluadorId: filters.evaluadorId });
}
if (filters.fechaDesde) {
queryBuilder.andWhere('c.fecha_evaluacion >= :fechaDesde', { fechaDesde: filters.fechaDesde });
}
if (filters.fechaHasta) {
queryBuilder.andWhere('c.fecha_evaluacion <= :fechaHasta', { fechaHasta: filters.fechaHasta });
}
queryBuilder
.orderBy('norma.codigo', 'ASC')
.addOrderBy('requisito.numero', 'ASC')
.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<CumplimientoObra | null> {
return this.cumplimientoRepository.findOne({
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<CumplimientoObra>,
relations: ['fraccionamiento', 'norma', 'requisito', 'evaluador'],
});
}
async create(ctx: ServiceContext, dto: CreateCumplimientoObraDto): Promise<CumplimientoObra> {
const cumplimiento = this.cumplimientoRepository.create({
tenantId: ctx.tenantId,
fraccionamientoId: dto.fraccionamientoId,
normaId: dto.normaId,
requisitoId: dto.requisitoId,
evaluadorId: dto.evaluadorId,
fechaEvaluacion: dto.fechaEvaluacion,
estado: dto.estado || 'no_cumple',
evidenciaUrl: dto.evidenciaUrl,
observaciones: dto.observaciones,
fechaCompromiso: dto.fechaCompromiso,
});
return this.cumplimientoRepository.save(cumplimiento);
}
async update(ctx: ServiceContext, id: string, dto: UpdateCumplimientoDto): Promise<CumplimientoObra | null> {
const cumplimiento = await this.findById(ctx, id);
if (!cumplimiento) return null;
if (dto.estado) cumplimiento.estado = dto.estado;
if (dto.evidenciaUrl !== undefined) cumplimiento.evidenciaUrl = dto.evidenciaUrl;
if (dto.observaciones !== undefined) cumplimiento.observaciones = dto.observaciones;
if (dto.fechaCompromiso !== undefined) cumplimiento.fechaCompromiso = dto.fechaCompromiso;
if (dto.evaluadorId !== undefined) cumplimiento.evaluadorId = dto.evaluadorId;
cumplimiento.fechaEvaluacion = new Date();
return this.cumplimientoRepository.save(cumplimiento);
}
async updateEstado(
ctx: ServiceContext,
id: string,
estado: EstadoCumplimiento,
evidenciaUrl?: string,
observaciones?: string
): Promise<CumplimientoObra | null> {
const cumplimiento = await this.findById(ctx, id);
if (!cumplimiento) return null;
cumplimiento.estado = estado;
cumplimiento.fechaEvaluacion = new Date();
if (evidenciaUrl) cumplimiento.evidenciaUrl = evidenciaUrl;
if (observaciones) cumplimiento.observaciones = observaciones;
return this.cumplimientoRepository.save(cumplimiento);
}
// ========== Evaluación Masiva ==========
async evaluarNormaCompleta(
ctx: ServiceContext,
fraccionamientoId: string,
normaId: string,
evaluadorId: string
): Promise<CumplimientoObra[]> {
const requisitos = await this.findRequisitosByNorma(normaId);
const cumplimientos: CumplimientoObra[] = [];
for (const requisito of requisitos) {
const existing = await this.cumplimientoRepository.findOne({
where: {
tenantId: ctx.tenantId,
fraccionamientoId,
normaId,
requisitoId: requisito.id,
} as FindOptionsWhere<CumplimientoObra>,
});
if (!existing) {
const cumplimiento = await this.create(ctx, {
fraccionamientoId,
normaId,
requisitoId: requisito.id,
evaluadorId,
fechaEvaluacion: new Date(),
estado: 'no_cumple',
});
cumplimientos.push(cumplimiento);
}
}
return cumplimientos;
}
// ========== Resumen por Norma ==========
async getResumenPorNorma(ctx: ServiceContext, fraccionamientoId: string): Promise<CumplimientoResumen[]> {
const normas = await this.findNormas(true);
const resumenes: CumplimientoResumen[] = [];
for (const norma of normas) {
const cumplimientos = await this.cumplimientoRepository.find({
where: {
tenantId: ctx.tenantId,
fraccionamientoId,
normaId: norma.id,
} as FindOptionsWhere<CumplimientoObra>,
});
const cumple = cumplimientos.filter((c) => c.estado === 'cumple').length;
const parcial = cumplimientos.filter((c) => c.estado === 'parcial').length;
const noCumple = cumplimientos.filter((c) => c.estado === 'no_cumple').length;
const noAplica = cumplimientos.filter((c) => c.estado === 'no_aplica').length;
const totalRequisitos = cumplimientos.length;
const evaluables = totalRequisitos - noAplica;
const porcentajeCumplimiento = evaluables > 0
? Math.round(((cumple + parcial * 0.5) / evaluables) * 100)
: 100;
resumenes.push({
normaId: norma.id,
normaCodigo: norma.codigo,
normaNombre: norma.nombre,
totalRequisitos,
cumple,
parcial,
noCumple,
noAplica,
porcentajeCumplimiento,
});
}
return resumenes;
}
// ========== Estadísticas ==========
async getStats(ctx: ServiceContext, fraccionamientoId?: string): Promise<CumplimientoStats> {
const queryBuilder = this.cumplimientoRepository
.createQueryBuilder('c')
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (fraccionamientoId) {
queryBuilder.andWhere('c.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
}
const cumplimientos = await queryBuilder.getMany();
const cumple = cumplimientos.filter((c) => c.estado === 'cumple').length;
const parcial = cumplimientos.filter((c) => c.estado === 'parcial').length;
const noCumple = cumplimientos.filter((c) => c.estado === 'no_cumple').length;
const noAplica = cumplimientos.filter((c) => c.estado === 'no_aplica').length;
const totalEvaluaciones = cumplimientos.length;
const evaluables = totalEvaluaciones - noAplica;
const porcentajeCumplimiento = evaluables > 0
? Math.round(((cumple + parcial * 0.5) / evaluables) * 100)
: 100;
const today = new Date();
const pendientesCompromiso = cumplimientos.filter(
(c) => c.estado !== 'cumple' && c.fechaCompromiso && c.fechaCompromiso < today
).length;
return {
totalEvaluaciones,
cumple,
parcial,
noCumple,
noAplica,
porcentajeCumplimiento,
pendientesCompromiso,
};
}
// ========== Compromisos Pendientes ==========
async getCompromisosPendientes(ctx: ServiceContext, fraccionamientoId?: string): Promise<CumplimientoObra[]> {
const today = new Date();
const queryBuilder = this.cumplimientoRepository
.createQueryBuilder('c')
.leftJoinAndSelect('c.fraccionamiento', 'fraccionamiento')
.leftJoinAndSelect('c.norma', 'norma')
.leftJoinAndSelect('c.requisito', 'requisito')
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('c.estado IN (:...estados)', { estados: ['no_cumple', 'parcial'] })
.andWhere('c.fecha_compromiso IS NOT NULL')
.andWhere('c.fecha_compromiso < :today', { today });
if (fraccionamientoId) {
queryBuilder.andWhere('c.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
}
return queryBuilder.orderBy('c.fecha_compromiso', 'ASC').getMany();
}
// ========== No Cumplimientos ==========
async getNoCumplimientos(ctx: ServiceContext, fraccionamientoId?: string): Promise<CumplimientoObra[]> {
const queryBuilder = this.cumplimientoRepository
.createQueryBuilder('c')
.leftJoinAndSelect('c.fraccionamiento', 'fraccionamiento')
.leftJoinAndSelect('c.norma', 'norma')
.leftJoinAndSelect('c.requisito', 'requisito')
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('c.estado = :estado', { estado: 'no_cumple' });
if (fraccionamientoId) {
queryBuilder.andWhere('c.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
}
return queryBuilder
.orderBy('norma.codigo', 'ASC')
.addOrderBy('requisito.numero', 'ASC')
.getMany();
}
}

View File

@ -0,0 +1,276 @@
/**
* DiasSinAccidenteService - Servicio para contador de dias sin accidente
*
* RF-MAA017-008: Gestion de indicadores de dias sin accidente por obra
*
* @module HSE
*/
import { Repository, FindOptionsWhere } from 'typeorm';
import { DiasSinAccidente } from '../entities/dias-sin-accidente.entity';
import { Incidente, TipoIncidente } from '../entities/incidente.entity';
import { ServiceContext } from '../../../shared/services/base.service';
export interface CreateDiasSinAccidenteDto {
fraccionamientoId: string;
fechaInicioConteo: Date;
}
export interface DiasSinAccidenteStats {
fraccionamientoId: string;
diasAcumulados: number;
recordHistorico: number;
fechaInicioConteo: Date;
ultimoAccidenteFecha?: Date;
}
export interface DiasSinAccidenteResumen {
totalObras: number;
obraConMasDias: { fraccionamientoId: string; dias: number } | null;
obraConMenosDias: { fraccionamientoId: string; dias: number } | null;
promedioDias: number;
}
export class DiasSinAccidenteService {
constructor(
private readonly diasSinAccidenteRepository: Repository<DiasSinAccidente>,
private readonly incidenteRepository: Repository<Incidente>
) {}
/**
* Obtiene el contador de dias sin accidente para una obra
*/
async findByFraccionamiento(
ctx: ServiceContext,
fraccionamientoId: string
): Promise<DiasSinAccidente | null> {
return this.diasSinAccidenteRepository.findOne({
where: {
tenantId: ctx.tenantId,
fraccionamientoId,
} as FindOptionsWhere<DiasSinAccidente>,
relations: ['fraccionamiento', 'ultimoIncidente'],
});
}
/**
* Lista todos los contadores de dias sin accidente del tenant
*/
async findAll(ctx: ServiceContext): Promise<DiasSinAccidente[]> {
return this.diasSinAccidenteRepository.find({
where: { tenantId: ctx.tenantId } as FindOptionsWhere<DiasSinAccidente>,
relations: ['fraccionamiento'],
order: { diasAcumulados: 'DESC' },
});
}
/**
* Inicializa el contador para una obra
*/
async initialize(
ctx: ServiceContext,
dto: CreateDiasSinAccidenteDto
): Promise<DiasSinAccidente> {
// Verificar si ya existe
const existing = await this.findByFraccionamiento(ctx, dto.fraccionamientoId);
if (existing) {
throw new Error('El contador ya existe para esta obra');
}
const contador = this.diasSinAccidenteRepository.create({
tenantId: ctx.tenantId,
fraccionamientoId: dto.fraccionamientoId,
fechaInicioConteo: dto.fechaInicioConteo,
diasAcumulados: this.calculateDays(dto.fechaInicioConteo),
recordHistorico: 0,
});
return this.diasSinAccidenteRepository.save(contador);
}
/**
* Reinicia el contador por un nuevo accidente
*/
async resetByAccident(
ctx: ServiceContext,
fraccionamientoId: string,
incidenteId: string
): Promise<DiasSinAccidente | null> {
const contador = await this.findByFraccionamiento(ctx, fraccionamientoId);
if (!contador) {
return null;
}
// Actualizar record si es necesario
const diasActuales = this.calculateDays(contador.fechaInicioConteo);
if (diasActuales > contador.recordHistorico) {
contador.recordHistorico = diasActuales;
}
// Reiniciar conteo
contador.fechaInicioConteo = new Date();
contador.diasAcumulados = 0;
contador.ultimoIncidenteId = incidenteId;
return this.diasSinAccidenteRepository.save(contador);
}
/**
* Actualiza los dias acumulados (llamado por cron o manualmente)
*/
async updateDiasAcumulados(
ctx: ServiceContext,
fraccionamientoId: string
): Promise<DiasSinAccidente | null> {
const contador = await this.findByFraccionamiento(ctx, fraccionamientoId);
if (!contador) {
return null;
}
contador.diasAcumulados = this.calculateDays(contador.fechaInicioConteo);
// Actualizar record si supera el historico
if (contador.diasAcumulados > contador.recordHistorico) {
contador.recordHistorico = contador.diasAcumulados;
}
return this.diasSinAccidenteRepository.save(contador);
}
/**
* Actualiza todos los contadores del tenant
*/
async updateAllContadores(ctx: ServiceContext): Promise<number> {
const contadores = await this.findAll(ctx);
let updated = 0;
for (const contador of contadores) {
const dias = this.calculateDays(contador.fechaInicioConteo);
if (dias !== contador.diasAcumulados) {
contador.diasAcumulados = dias;
if (dias > contador.recordHistorico) {
contador.recordHistorico = dias;
}
await this.diasSinAccidenteRepository.save(contador);
updated++;
}
}
return updated;
}
/**
* Obtiene estadisticas de una obra
*/
async getStats(
ctx: ServiceContext,
fraccionamientoId: string
): Promise<DiasSinAccidenteStats | null> {
const contador = await this.findByFraccionamiento(ctx, fraccionamientoId);
if (!contador) {
return null;
}
// Obtener ultimo accidente
const ultimoAccidente = await this.incidenteRepository.findOne({
where: {
tenantId: ctx.tenantId,
fraccionamientoId,
tipo: 'accidente' as TipoIncidente,
} as FindOptionsWhere<Incidente>,
order: { fechaHora: 'DESC' },
});
return {
fraccionamientoId,
diasAcumulados: this.calculateDays(contador.fechaInicioConteo),
recordHistorico: contador.recordHistorico,
fechaInicioConteo: contador.fechaInicioConteo,
ultimoAccidenteFecha: ultimoAccidente?.fechaHora,
};
}
/**
* Obtiene resumen de dias sin accidente del tenant
*/
async getResumen(ctx: ServiceContext): Promise<DiasSinAccidenteResumen> {
const contadores = await this.findAll(ctx);
if (contadores.length === 0) {
return {
totalObras: 0,
obraConMasDias: null,
obraConMenosDias: null,
promedioDias: 0,
};
}
// Actualizar dias acumulados
const contadoresActualizados = contadores.map((c) => ({
...c,
diasAcumulados: this.calculateDays(c.fechaInicioConteo),
}));
const sorted = contadoresActualizados.sort((a, b) => b.diasAcumulados - a.diasAcumulados);
const totalDias = sorted.reduce((sum, c) => sum + c.diasAcumulados, 0);
return {
totalObras: contadores.length,
obraConMasDias: {
fraccionamientoId: sorted[0].fraccionamientoId,
dias: sorted[0].diasAcumulados,
},
obraConMenosDias: {
fraccionamientoId: sorted[sorted.length - 1].fraccionamientoId,
dias: sorted[sorted.length - 1].diasAcumulados,
},
promedioDias: Math.round(totalDias / contadores.length),
};
}
/**
* Verifica si se debe reiniciar el contador basado en incidentes recientes
*/
async checkAndResetIfNeeded(
ctx: ServiceContext,
fraccionamientoId: string
): Promise<{ reset: boolean; incidenteId?: string }> {
const contador = await this.findByFraccionamiento(ctx, fraccionamientoId);
if (!contador) {
return { reset: false };
}
// Buscar accidentes despues del inicio del conteo
const accidenteReciente = await this.incidenteRepository.findOne({
where: {
tenantId: ctx.tenantId,
fraccionamientoId,
tipo: 'accidente' as TipoIncidente,
} as FindOptionsWhere<Incidente>,
order: { fechaHora: 'DESC' },
});
if (
accidenteReciente &&
new Date(accidenteReciente.fechaHora) > new Date(contador.fechaInicioConteo) &&
accidenteReciente.id !== contador.ultimoIncidenteId
) {
await this.resetByAccident(ctx, fraccionamientoId, accidenteReciente.id);
return { reset: true, incidenteId: accidenteReciente.id };
}
return { reset: false };
}
/**
* Calcula dias desde una fecha hasta hoy
*/
private calculateDays(fechaInicio: Date): number {
const inicio = new Date(fechaInicio);
inicio.setHours(0, 0, 0, 0);
const hoy = new Date();
hoy.setHours(0, 0, 0, 0);
const diff = hoy.getTime() - inicio.getTime();
return Math.max(0, Math.floor(diff / (1000 * 60 * 60 * 24)));
}
}

View File

@ -0,0 +1,518 @@
/**
* HallazgoService - Servicio para gestion de hallazgos de seguridad
*
* RF-MAA017-003: Gestion de hallazgos, acciones correctivas y seguimiento
*
* @module HSE
*/
import { Repository, FindOptionsWhere } from 'typeorm';
import { Hallazgo, GravedadHallazgo, EstadoHallazgo } from '../entities/hallazgo.entity';
import { HallazgoEvidencia, TipoEvidencia } from '../entities/hallazgo-evidencia.entity';
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
export interface HallazgoServiceFilters {
fraccionamientoId?: string;
inspeccionId?: string;
gravedad?: GravedadHallazgo;
estado?: EstadoHallazgo;
responsableId?: string;
vencidos?: boolean;
tipo?: 'acto_inseguro' | 'condicion_insegura';
dateFrom?: Date;
dateTo?: Date;
}
export interface CreateHallazgoServiceDto {
inspeccionId: string;
evaluacionId?: string;
gravedad: GravedadHallazgo;
tipo: 'acto_inseguro' | 'condicion_insegura';
descripcion: string;
ubicacionDescripcion?: string;
responsableCorreccionId?: string;
fechaLimite: Date;
}
export interface UpdateHallazgoDto {
gravedad?: GravedadHallazgo;
descripcion?: string;
ubicacionDescripcion?: string;
responsableCorreccionId?: string;
fechaLimite?: Date;
}
export interface AddEvidenciaDto {
tipo: TipoEvidencia;
archivoUrl: string;
descripcion?: string;
}
export interface RegistrarCorreccionDto {
descripcionCorreccion: string;
evidenciaUrl?: string;
}
export interface HallazgoStats {
total: number;
porGravedad: { gravedad: string; count: number }[];
porEstado: { estado: string; count: number }[];
porTipo: { tipo: string; count: number }[];
vencidos: number;
proximosAVencer: number;
tiempoPromedioCorreccion: number;
}
export class HallazgoService {
constructor(
private readonly hallazgoRepository: Repository<Hallazgo>,
private readonly evidenciaRepository: Repository<HallazgoEvidencia>
) {}
private generateFolio(): string {
const now = new Date();
const year = now.getFullYear().toString().slice(-2);
const month = (now.getMonth() + 1).toString().padStart(2, '0');
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `HAL-${year}${month}-${random}`;
}
/**
* Lista hallazgos con filtros y paginacion
*/
async findWithFilters(
ctx: ServiceContext,
filters: HallazgoServiceFilters = {},
page: number = 1,
limit: number = 20
): Promise<PaginatedResult<Hallazgo>> {
const skip = (page - 1) * limit;
const queryBuilder = this.hallazgoRepository
.createQueryBuilder('hallazgo')
.leftJoinAndSelect('hallazgo.inspeccion', 'inspeccion')
.leftJoinAndSelect('inspeccion.fraccionamiento', 'fraccionamiento')
.leftJoinAndSelect('hallazgo.responsableCorreccion', 'responsable')
.leftJoinAndSelect('hallazgo.verificador', 'verificador')
.where('hallazgo.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (filters.fraccionamientoId) {
queryBuilder.andWhere('inspeccion.fraccionamiento_id = :fraccionamientoId', {
fraccionamientoId: filters.fraccionamientoId,
});
}
if (filters.inspeccionId) {
queryBuilder.andWhere('hallazgo.inspeccion_id = :inspeccionId', {
inspeccionId: filters.inspeccionId,
});
}
if (filters.gravedad) {
queryBuilder.andWhere('hallazgo.gravedad = :gravedad', { gravedad: filters.gravedad });
}
if (filters.estado) {
queryBuilder.andWhere('hallazgo.estado = :estado', { estado: filters.estado });
}
if (filters.responsableId) {
queryBuilder.andWhere('hallazgo.responsable_correccion_id = :responsableId', {
responsableId: filters.responsableId,
});
}
if (filters.tipo) {
queryBuilder.andWhere('hallazgo.tipo = :tipo', { tipo: filters.tipo });
}
if (filters.vencidos) {
queryBuilder.andWhere('hallazgo.fecha_limite < :today', { today: new Date() });
queryBuilder.andWhere('hallazgo.estado NOT IN (:...cerrados)', { cerrados: ['cerrado'] });
}
if (filters.dateFrom) {
queryBuilder.andWhere('hallazgo.created_at >= :dateFrom', { dateFrom: filters.dateFrom });
}
if (filters.dateTo) {
queryBuilder.andWhere('hallazgo.created_at <= :dateTo', { dateTo: filters.dateTo });
}
queryBuilder
.orderBy('hallazgo.fecha_limite', 'ASC')
.addOrderBy('hallazgo.gravedad', 'ASC')
.skip(skip)
.take(limit);
const [data, total] = await queryBuilder.getManyAndCount();
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
/**
* Obtiene hallazgo por ID
*/
async findById(ctx: ServiceContext, id: string): Promise<Hallazgo | null> {
return this.hallazgoRepository.findOne({
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<Hallazgo>,
relations: ['inspeccion', 'responsableCorreccion', 'verificador'],
});
}
/**
* Obtiene hallazgo con todas las relaciones
*/
async findWithDetails(ctx: ServiceContext, id: string): Promise<Hallazgo | null> {
return this.hallazgoRepository.findOne({
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<Hallazgo>,
relations: [
'inspeccion',
'inspeccion.fraccionamiento',
'inspeccion.tipoInspeccion',
'evaluacion',
'responsableCorreccion',
'verificador',
'evidencias',
'evidencias.createdBy',
],
});
}
/**
* Crea un nuevo hallazgo
*/
async create(ctx: ServiceContext, dto: CreateHallazgoServiceDto): Promise<Hallazgo> {
const hallazgo = this.hallazgoRepository.create({
tenantId: ctx.tenantId,
folio: this.generateFolio(),
inspeccionId: dto.inspeccionId,
evaluacionId: dto.evaluacionId,
gravedad: dto.gravedad,
tipo: dto.tipo,
descripcion: dto.descripcion,
ubicacionDescripcion: dto.ubicacionDescripcion,
responsableCorreccionId: dto.responsableCorreccionId,
fechaLimite: dto.fechaLimite,
estado: 'abierto',
});
return this.hallazgoRepository.save(hallazgo);
}
/**
* Actualiza un hallazgo
*/
async update(ctx: ServiceContext, id: string, dto: UpdateHallazgoDto): Promise<Hallazgo | null> {
const existing = await this.findById(ctx, id);
if (!existing) {
return null;
}
if (existing.estado === 'cerrado') {
throw new Error('No se puede modificar un hallazgo cerrado');
}
const updated = this.hallazgoRepository.merge(existing, dto);
return this.hallazgoRepository.save(updated);
}
/**
* Inicia la correccion de un hallazgo
*/
async iniciarCorreccion(ctx: ServiceContext, id: string): Promise<Hallazgo | null> {
const hallazgo = await this.findById(ctx, id);
if (!hallazgo) {
return null;
}
if (hallazgo.estado !== 'abierto' && hallazgo.estado !== 'reabierto') {
throw new Error('El hallazgo no esta en estado abierto o reabierto');
}
hallazgo.estado = 'en_correccion';
return this.hallazgoRepository.save(hallazgo);
}
/**
* Registra la correccion de un hallazgo
*/
async registrarCorreccion(
ctx: ServiceContext,
id: string,
dto: RegistrarCorreccionDto
): Promise<Hallazgo | null> {
const hallazgo = await this.findById(ctx, id);
if (!hallazgo) {
return null;
}
if (hallazgo.estado === 'cerrado') {
throw new Error('El hallazgo ya esta cerrado');
}
hallazgo.estado = 'verificando';
hallazgo.descripcionCorreccion = dto.descripcionCorreccion;
hallazgo.fechaCorreccion = new Date();
const saved = await this.hallazgoRepository.save(hallazgo);
// Agregar evidencia de correccion si se proporciona
if (dto.evidenciaUrl) {
await this.addEvidencia(ctx, id, {
tipo: 'correccion',
archivoUrl: dto.evidenciaUrl,
descripcion: 'Evidencia de correccion',
});
}
return saved;
}
/**
* Verifica un hallazgo (aprueba o rechaza la correccion)
*/
async verificar(
ctx: ServiceContext,
id: string,
aprobado: boolean,
observaciones?: string
): Promise<Hallazgo | null> {
const hallazgo = await this.findById(ctx, id);
if (!hallazgo) {
return null;
}
if (hallazgo.estado !== 'verificando') {
throw new Error('El hallazgo no esta en estado de verificacion');
}
hallazgo.verificadorId = ctx.userId || '';
hallazgo.fechaVerificacion = new Date();
hallazgo.estado = aprobado ? 'cerrado' : 'reabierto';
if (observaciones && !aprobado) {
hallazgo.descripcionCorreccion = `${hallazgo.descripcionCorreccion}\n\n[RECHAZADO]: ${observaciones}`;
}
return this.hallazgoRepository.save(hallazgo);
}
/**
* Reabre un hallazgo cerrado
*/
async reabrir(ctx: ServiceContext, id: string, motivo: string): Promise<Hallazgo | null> {
const hallazgo = await this.findById(ctx, id);
if (!hallazgo) {
return null;
}
if (hallazgo.estado !== 'cerrado') {
throw new Error('Solo se pueden reabrir hallazgos cerrados');
}
hallazgo.estado = 'reabierto';
hallazgo.descripcionCorreccion = `${hallazgo.descripcionCorreccion}\n\n[REABIERTO]: ${motivo}`;
hallazgo.fechaCorreccion = undefined as unknown as Date;
hallazgo.fechaVerificacion = undefined as unknown as Date;
return this.hallazgoRepository.save(hallazgo);
}
/**
* Agrega evidencia a un hallazgo
*/
async addEvidencia(
ctx: ServiceContext,
hallazgoId: string,
dto: AddEvidenciaDto
): Promise<HallazgoEvidencia> {
const hallazgo = await this.findById(ctx, hallazgoId);
if (!hallazgo) {
throw new Error('Hallazgo no encontrado');
}
const evidencia = this.evidenciaRepository.create({
hallazgoId,
tipo: dto.tipo,
archivoUrl: dto.archivoUrl,
descripcion: dto.descripcion,
createdById: ctx.userId,
});
return this.evidenciaRepository.save(evidencia);
}
/**
* Obtiene evidencias de un hallazgo
*/
async getEvidencias(ctx: ServiceContext, hallazgoId: string): Promise<HallazgoEvidencia[]> {
const hallazgo = await this.findById(ctx, hallazgoId);
if (!hallazgo) {
return [];
}
return this.evidenciaRepository.find({
where: { hallazgoId } as FindOptionsWhere<HallazgoEvidencia>,
relations: ['createdBy'],
order: { createdAt: 'DESC' },
});
}
/**
* Elimina evidencia
*/
async removeEvidencia(ctx: ServiceContext, hallazgoId: string, evidenciaId: string): Promise<boolean> {
const hallazgo = await this.findById(ctx, hallazgoId);
if (!hallazgo) {
return false;
}
const result = await this.evidenciaRepository.delete({
id: evidenciaId,
hallazgoId,
});
return (result.affected ?? 0) > 0;
}
/**
* Obtiene hallazgos vencidos
*/
async findVencidos(ctx: ServiceContext, fraccionamientoId?: string): Promise<Hallazgo[]> {
const queryBuilder = this.hallazgoRepository
.createQueryBuilder('hallazgo')
.leftJoinAndSelect('hallazgo.inspeccion', 'inspeccion')
.leftJoinAndSelect('hallazgo.responsableCorreccion', 'responsable')
.where('hallazgo.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('hallazgo.fecha_limite < :today', { today: new Date() })
.andWhere('hallazgo.estado NOT IN (:...cerrados)', { cerrados: ['cerrado'] });
if (fraccionamientoId) {
queryBuilder.andWhere('inspeccion.fraccionamiento_id = :fraccionamientoId', {
fraccionamientoId,
});
}
return queryBuilder
.orderBy('hallazgo.fecha_limite', 'ASC')
.getMany();
}
/**
* Obtiene hallazgos proximos a vencer (7 dias)
*/
async findProximosAVencer(
ctx: ServiceContext,
dias: number = 7,
fraccionamientoId?: string
): Promise<Hallazgo[]> {
const fechaLimite = new Date();
fechaLimite.setDate(fechaLimite.getDate() + dias);
const queryBuilder = this.hallazgoRepository
.createQueryBuilder('hallazgo')
.leftJoinAndSelect('hallazgo.inspeccion', 'inspeccion')
.leftJoinAndSelect('hallazgo.responsableCorreccion', 'responsable')
.where('hallazgo.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('hallazgo.fecha_limite >= :today', { today: new Date() })
.andWhere('hallazgo.fecha_limite <= :fechaLimite', { fechaLimite })
.andWhere('hallazgo.estado NOT IN (:...cerrados)', { cerrados: ['cerrado'] });
if (fraccionamientoId) {
queryBuilder.andWhere('inspeccion.fraccionamiento_id = :fraccionamientoId', {
fraccionamientoId,
});
}
return queryBuilder
.orderBy('hallazgo.fecha_limite', 'ASC')
.getMany();
}
/**
* Obtiene estadisticas de hallazgos
*/
async getStats(ctx: ServiceContext, fraccionamientoId?: string): Promise<HallazgoStats> {
const baseQuery = this.hallazgoRepository
.createQueryBuilder('h')
.leftJoin('h.inspeccion', 'i')
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (fraccionamientoId) {
baseQuery.andWhere('i.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
}
const total = await baseQuery.getCount();
// Por gravedad
const porGravedad = await this.hallazgoRepository
.createQueryBuilder('h')
.select('h.gravedad', 'gravedad')
.addSelect('COUNT(*)', 'count')
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.groupBy('h.gravedad')
.getRawMany();
// Por estado
const porEstado = await this.hallazgoRepository
.createQueryBuilder('h')
.select('h.estado', 'estado')
.addSelect('COUNT(*)', 'count')
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.groupBy('h.estado')
.getRawMany();
// Por tipo
const porTipo = await this.hallazgoRepository
.createQueryBuilder('h')
.select('h.tipo', 'tipo')
.addSelect('COUNT(*)', 'count')
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.groupBy('h.tipo')
.getRawMany();
// Vencidos
const vencidos = await this.hallazgoRepository
.createQueryBuilder('h')
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('h.fecha_limite < :today', { today: new Date() })
.andWhere('h.estado NOT IN (:...cerrados)', { cerrados: ['cerrado'] })
.getCount();
// Proximos a vencer (7 dias)
const fechaLimite = new Date();
fechaLimite.setDate(fechaLimite.getDate() + 7);
const proximosAVencer = await this.hallazgoRepository
.createQueryBuilder('h')
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('h.fecha_limite >= :today', { today: new Date() })
.andWhere('h.fecha_limite <= :fechaLimite', { fechaLimite })
.andWhere('h.estado NOT IN (:...cerrados)', { cerrados: ['cerrado'] })
.getCount();
// Tiempo promedio de correccion
const tiemposCorreccion = await this.hallazgoRepository
.createQueryBuilder('h')
.select('AVG(EXTRACT(EPOCH FROM (h.fecha_correccion - h.created_at)) / 86400)', 'avgDias')
.where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('h.fecha_correccion IS NOT NULL')
.getRawOne();
return {
total,
porGravedad: porGravedad.map((p) => ({ gravedad: p.gravedad, count: parseInt(p.count, 10) })),
porEstado: porEstado.map((p) => ({ estado: p.estado, count: parseInt(p.count, 10) })),
porTipo: porTipo.map((p) => ({ tipo: p.tipo, count: parseInt(p.count, 10) })),
vencidos,
proximosAVencer,
tiempoPromedioCorreccion: tiemposCorreccion?.avgDias ? parseFloat(tiemposCorreccion.avgDias) : 0,
};
}
}

View File

@ -0,0 +1,386 @@
/**
* 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;
}
}

View File

@ -4,7 +4,7 @@
*
* Services for Health, Safety & Environment module
* Based on RF-MAA017-001 to RF-MAA017-008
* Updated: 2025-12-18
* Updated: 2026-01-30
*/
// RF-MAA017-001: Gestión de Incidentes
@ -12,21 +12,45 @@ export * from './incidente.service';
// RF-MAA017-002: Control de Capacitaciones
export * from './capacitacion.service';
export * from './instructor.service';
export * from './sesion-capacitacion.service';
export * from './constancia-dc3.service';
// RF-MAA017-003: Inspecciones de Seguridad
export * from './inspeccion.service';
// RF-MAA017-003: Gestión de Hallazgos (standalone)
export * from './hallazgo.service';
// RF-MAA017-004: Control de EPP
export * from './epp.service';
// RF-MAA017-005: Cumplimiento STPS
export * from './stps.service';
export * from './cumplimiento.service';
// RF-MAA017-005: Comisiones de Seguridad (NOM-019)
export * from './comision-seguridad.service';
// RF-MAA017-005: Programas de Seguridad
export * from './programa-seguridad.service';
// RF-MAA017-006: Gestión Ambiental
export * from './ambiental.service';
export * from './residuo-peligroso.service';
export * from './almacen-temporal.service';
export * from './proveedor-ambiental.service';
export * from './auditoria-ambiental.service';
// RF-MAA017-007: Permisos de Trabajo
export * from './permiso-trabajo.service';
// RF-MAA017-008: Indicadores HSE
export * from './indicador.service';
export * from './horas-trabajadas.service';
// RF-MAA017-008: Días Sin Accidente
export * from './dias-sin-accidente.service';
// RF-MAA017-008: Reportes Programados
export * from './reporte-programado.service';

View File

@ -0,0 +1,189 @@
/**
* InstructorService - Servicio para gestión de instructores HSE
*
* RF-MAA017-002: Control de Capacitaciones - Instructores
*
* @module HSE
*/
import { Repository, FindOptionsWhere } from 'typeorm';
import { Instructor } from '../entities/instructor.entity';
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
export interface CreateInstructorDto {
nombre: string;
registroStps?: string;
especialidades?: string;
esInterno?: boolean;
employeeId?: string;
contactoTelefono?: string;
contactoEmail?: string;
}
export interface UpdateInstructorDto {
nombre?: string;
registroStps?: string;
especialidades?: string;
esInterno?: boolean;
employeeId?: string;
contactoTelefono?: string;
contactoEmail?: string;
activo?: boolean;
}
export interface InstructorFilters {
esInterno?: boolean;
activo?: boolean;
search?: string;
}
export class InstructorService {
constructor(private readonly repository: Repository<Instructor>) {}
async findAll(
ctx: ServiceContext,
filters: InstructorFilters = {},
page: number = 1,
limit: number = 20
): Promise<PaginatedResult<Instructor>> {
const skip = (page - 1) * limit;
const queryBuilder = this.repository
.createQueryBuilder('instructor')
.leftJoinAndSelect('instructor.employee', 'employee')
.where('instructor.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (filters.esInterno !== undefined) {
queryBuilder.andWhere('instructor.es_interno = :esInterno', { esInterno: filters.esInterno });
}
if (filters.activo !== undefined) {
queryBuilder.andWhere('instructor.activo = :activo', { activo: filters.activo });
}
if (filters.search) {
queryBuilder.andWhere(
'(instructor.nombre ILIKE :search OR instructor.registro_stps ILIKE :search OR instructor.especialidades ILIKE :search)',
{ search: `%${filters.search}%` }
);
}
queryBuilder
.orderBy('instructor.nombre', 'ASC')
.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<Instructor | null> {
return this.repository.findOne({
where: {
id,
tenantId: ctx.tenantId,
} as FindOptionsWhere<Instructor>,
relations: ['employee'],
});
}
async findByRegistroStps(ctx: ServiceContext, registroStps: string): Promise<Instructor | null> {
return this.repository.findOne({
where: {
registroStps,
tenantId: ctx.tenantId,
} as FindOptionsWhere<Instructor>,
});
}
async create(ctx: ServiceContext, dto: CreateInstructorDto): Promise<Instructor> {
if (dto.registroStps) {
const existing = await this.findByRegistroStps(ctx, dto.registroStps);
if (existing) {
throw new Error(`Instructor with registro STPS ${dto.registroStps} already exists`);
}
}
const instructor = this.repository.create({
tenantId: ctx.tenantId,
nombre: dto.nombre,
registroStps: dto.registroStps,
especialidades: dto.especialidades,
esInterno: dto.esInterno || false,
employeeId: dto.employeeId,
contactoTelefono: dto.contactoTelefono,
contactoEmail: dto.contactoEmail,
activo: true,
});
return this.repository.save(instructor);
}
async update(ctx: ServiceContext, id: string, dto: UpdateInstructorDto): Promise<Instructor | null> {
const existing = await this.findById(ctx, id);
if (!existing) {
return null;
}
if (dto.registroStps && dto.registroStps !== existing.registroStps) {
const duplicate = await this.findByRegistroStps(ctx, dto.registroStps);
if (duplicate) {
throw new Error(`Instructor with registro STPS ${dto.registroStps} already exists`);
}
}
const updated = this.repository.merge(existing, dto);
return this.repository.save(updated);
}
async toggleActive(ctx: ServiceContext, id: string): Promise<Instructor | null> {
const existing = await this.findById(ctx, id);
if (!existing) {
return null;
}
existing.activo = !existing.activo;
return this.repository.save(existing);
}
async getInstructoresActivos(ctx: ServiceContext): Promise<Instructor[]> {
return this.repository.find({
where: {
tenantId: ctx.tenantId,
activo: true,
} as FindOptionsWhere<Instructor>,
relations: ['employee'],
order: { nombre: 'ASC' },
});
}
async getInstructoresInternos(ctx: ServiceContext): Promise<Instructor[]> {
return this.repository.find({
where: {
tenantId: ctx.tenantId,
esInterno: true,
activo: true,
} as FindOptionsWhere<Instructor>,
relations: ['employee'],
order: { nombre: 'ASC' },
});
}
async getInstructoresExternos(ctx: ServiceContext): Promise<Instructor[]> {
return this.repository.find({
where: {
tenantId: ctx.tenantId,
esInterno: false,
activo: true,
} as FindOptionsWhere<Instructor>,
order: { nombre: 'ASC' },
});
}
}

View File

@ -0,0 +1,661 @@
/**
* 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,
};
}
}

View File

@ -0,0 +1,525 @@
/**
* ProveedorAmbientalService - Servicio para gestión de proveedores ambientales
*
* RF-MAA017-006: Gestión de proveedores autorizados para transporte,
* reciclaje y confinamiento de residuos.
*
* @module HSE
*/
import { Repository, FindOptionsWhere } from 'typeorm';
import { ProveedorAmbiental, TipoProveedorAmbiental } from '../entities/proveedor-ambiental.entity';
import { ManifiestoResiduos } from '../entities/manifiesto-residuos.entity';
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
export interface ProveedorFilters {
tipo?: TipoProveedorAmbiental;
activo?: boolean;
vigente?: boolean;
search?: string;
}
export interface CreateProveedorDto {
razonSocial: string;
rfc?: string;
tipo: TipoProveedorAmbiental;
numeroAutorizacion?: string;
entidadAutorizadora?: string;
fechaAutorizacion?: Date;
fechaVencimiento?: Date;
servicios?: string;
contactoNombre?: string;
contactoTelefono?: string;
}
export interface UpdateProveedorDto {
razonSocial?: string;
rfc?: string;
tipo?: TipoProveedorAmbiental;
numeroAutorizacion?: string;
entidadAutorizadora?: string;
fechaAutorizacion?: Date;
fechaVencimiento?: Date;
servicios?: string;
contactoNombre?: string;
contactoTelefono?: string;
activo?: boolean;
}
export interface ProveedorConHistorial extends ProveedorAmbiental {
totalManifiestos: number;
ultimoServicio: Date | null;
cantidadTotalKg: number;
}
export interface ProveedorStats {
totalProveedores: number;
proveedoresActivos: number;
proveedoresVencidos: number;
proximosVencer: number;
porTipo: { tipo: string; count: number }[];
topProveedores: { id: string; nombre: string; manifiestos: number }[];
}
export interface AlertaProveedor {
proveedorId: string;
razonSocial: string;
tipo: 'vencimiento' | 'inactivo' | 'sin_autorizacion';
mensaje: string;
diasRestantes?: number;
nivel: 'advertencia' | 'critico';
}
export class ProveedorAmbientalService {
constructor(
private readonly proveedorRepository: Repository<ProveedorAmbiental>,
private readonly manifiestoRepository: Repository<ManifiestoResiduos>
) {}
// ========== CRUD de Proveedores ==========
async findAll(
ctx: ServiceContext,
filters: ProveedorFilters = {},
page: number = 1,
limit: number = 20
): Promise<PaginatedResult<ProveedorAmbiental>> {
const skip = (page - 1) * limit;
const queryBuilder = this.proveedorRepository
.createQueryBuilder('p')
.where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (filters.tipo) {
queryBuilder.andWhere('p.tipo = :tipo', { tipo: filters.tipo });
}
if (filters.activo !== undefined) {
queryBuilder.andWhere('p.activo = :activo', { activo: filters.activo });
}
if (filters.vigente === true) {
const today = new Date();
queryBuilder.andWhere(
'(p.fecha_vencimiento IS NULL OR p.fecha_vencimiento >= :today)',
{ today }
);
} else if (filters.vigente === false) {
const today = new Date();
queryBuilder.andWhere('p.fecha_vencimiento < :today', { today });
}
if (filters.search) {
queryBuilder.andWhere(
'(p.razon_social ILIKE :search OR p.rfc ILIKE :search OR p.numero_autorizacion ILIKE :search)',
{ search: `%${filters.search}%` }
);
}
queryBuilder
.orderBy('p.razon_social', 'ASC')
.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<ProveedorAmbiental | null> {
return this.proveedorRepository.findOne({
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<ProveedorAmbiental>,
});
}
async findByTipo(ctx: ServiceContext, tipo: TipoProveedorAmbiental): Promise<ProveedorAmbiental[]> {
return this.proveedorRepository.find({
where: {
tenantId: ctx.tenantId,
tipo,
activo: true,
} as FindOptionsWhere<ProveedorAmbiental>,
order: { razonSocial: 'ASC' },
});
}
async findTransportistas(ctx: ServiceContext): Promise<ProveedorAmbiental[]> {
return this.findByTipo(ctx, 'transportista');
}
async findRecicladores(ctx: ServiceContext): Promise<ProveedorAmbiental[]> {
return this.findByTipo(ctx, 'reciclador');
}
async findConfinamientos(ctx: ServiceContext): Promise<ProveedorAmbiental[]> {
return this.findByTipo(ctx, 'confinamiento');
}
async findVigentes(ctx: ServiceContext, tipo?: TipoProveedorAmbiental): Promise<ProveedorAmbiental[]> {
const today = new Date();
const queryBuilder = this.proveedorRepository
.createQueryBuilder('p')
.where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('p.activo = true')
.andWhere('(p.fecha_vencimiento IS NULL OR p.fecha_vencimiento >= :today)', { today });
if (tipo) {
queryBuilder.andWhere('p.tipo = :tipo', { tipo });
}
return queryBuilder.orderBy('p.razon_social', 'ASC').getMany();
}
async create(ctx: ServiceContext, dto: CreateProveedorDto): Promise<ProveedorAmbiental> {
// Validate RFC uniqueness within tenant
if (dto.rfc) {
const existing = await this.proveedorRepository.findOne({
where: { tenantId: ctx.tenantId, rfc: dto.rfc } as FindOptionsWhere<ProveedorAmbiental>,
});
if (existing) {
throw new Error('Ya existe un proveedor con este RFC');
}
}
const proveedor = this.proveedorRepository.create({
tenantId: ctx.tenantId,
razonSocial: dto.razonSocial,
rfc: dto.rfc,
tipo: dto.tipo,
numeroAutorizacion: dto.numeroAutorizacion,
entidadAutorizadora: dto.entidadAutorizadora,
fechaAutorizacion: dto.fechaAutorizacion,
fechaVencimiento: dto.fechaVencimiento,
servicios: dto.servicios,
contactoNombre: dto.contactoNombre,
contactoTelefono: dto.contactoTelefono,
activo: true,
});
return this.proveedorRepository.save(proveedor);
}
async update(
ctx: ServiceContext,
id: string,
dto: UpdateProveedorDto
): Promise<ProveedorAmbiental | null> {
const proveedor = await this.findById(ctx, id);
if (!proveedor) return null;
// Validate RFC uniqueness if changing
if (dto.rfc && dto.rfc !== proveedor.rfc) {
const existing = await this.proveedorRepository.findOne({
where: { tenantId: ctx.tenantId, rfc: dto.rfc } as FindOptionsWhere<ProveedorAmbiental>,
});
if (existing && existing.id !== id) {
throw new Error('Ya existe un proveedor con este RFC');
}
}
if (dto.razonSocial !== undefined) proveedor.razonSocial = dto.razonSocial;
if (dto.rfc !== undefined) proveedor.rfc = dto.rfc;
if (dto.tipo !== undefined) proveedor.tipo = dto.tipo;
if (dto.numeroAutorizacion !== undefined) proveedor.numeroAutorizacion = dto.numeroAutorizacion;
if (dto.entidadAutorizadora !== undefined) proveedor.entidadAutorizadora = dto.entidadAutorizadora;
if (dto.fechaAutorizacion !== undefined) proveedor.fechaAutorizacion = dto.fechaAutorizacion;
if (dto.fechaVencimiento !== undefined) proveedor.fechaVencimiento = dto.fechaVencimiento;
if (dto.servicios !== undefined) proveedor.servicios = dto.servicios;
if (dto.contactoNombre !== undefined) proveedor.contactoNombre = dto.contactoNombre;
if (dto.contactoTelefono !== undefined) proveedor.contactoTelefono = dto.contactoTelefono;
if (dto.activo !== undefined) proveedor.activo = dto.activo;
return this.proveedorRepository.save(proveedor);
}
async activate(ctx: ServiceContext, id: string): Promise<ProveedorAmbiental | null> {
return this.update(ctx, id, { activo: true });
}
async deactivate(ctx: ServiceContext, id: string): Promise<ProveedorAmbiental | null> {
return this.update(ctx, id, { activo: false });
}
async delete(ctx: ServiceContext, id: string): Promise<boolean> {
// Check if proveedor has manifiestos
const manifiestoCount = await this.manifiestoRepository.count({
where: [
{ tenantId: ctx.tenantId, transportistaId: id },
{ tenantId: ctx.tenantId, destinoId: id },
] as FindOptionsWhere<ManifiestoResiduos>[],
});
if (manifiestoCount > 0) {
throw new Error('No se puede eliminar un proveedor con manifiestos asociados. Desactívelo en su lugar.');
}
const result = await this.proveedorRepository.delete({
id,
tenantId: ctx.tenantId,
} as FindOptionsWhere<ProveedorAmbiental>);
return (result.affected ?? 0) > 0;
}
// ========== Renovación de Autorización ==========
async renovarAutorizacion(
ctx: ServiceContext,
id: string,
nuevaFechaVencimiento: Date,
nuevoNumeroAutorizacion?: string
): Promise<ProveedorAmbiental | null> {
const proveedor = await this.findById(ctx, id);
if (!proveedor) return null;
proveedor.fechaVencimiento = nuevaFechaVencimiento;
if (nuevoNumeroAutorizacion) {
proveedor.numeroAutorizacion = nuevoNumeroAutorizacion;
}
proveedor.fechaAutorizacion = new Date();
return this.proveedorRepository.save(proveedor);
}
// ========== Historial de Servicios ==========
async getProveedorConHistorial(
ctx: ServiceContext,
id: string
): Promise<ProveedorConHistorial | null> {
const proveedor = await this.findById(ctx, id);
if (!proveedor) return null;
// Count manifiestos where this proveedor is transportista or destino
const totalManifiestos = await this.manifiestoRepository.count({
where: [
{ tenantId: ctx.tenantId, transportistaId: id },
{ tenantId: ctx.tenantId, destinoId: id },
] as FindOptionsWhere<ManifiestoResiduos>[],
});
// Get last service date
const ultimoManifiesto = await this.manifiestoRepository.findOne({
where: [
{ tenantId: ctx.tenantId, transportistaId: id },
{ tenantId: ctx.tenantId, destinoId: id },
] as FindOptionsWhere<ManifiestoResiduos>[],
order: { fechaRecoleccion: 'DESC' },
});
// Calculate total kg handled (approximate)
const cantidadResult = await this.manifiestoRepository
.createQueryBuilder('m')
.innerJoin('m.detalles', 'd')
.where('m.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('(m.transportista_id = :proveedorId OR m.destino_id = :proveedorId)', { proveedorId: id })
.andWhere("d.unidad = 'kg'")
.select('SUM(d.cantidad)', 'total')
.getRawOne();
return {
...proveedor,
totalManifiestos,
ultimoServicio: ultimoManifiesto?.fechaRecoleccion || null,
cantidadTotalKg: parseFloat(cantidadResult?.total || '0'),
};
}
async getManifiestosProveedor(
ctx: ServiceContext,
proveedorId: string,
page: number = 1,
limit: number = 20
): Promise<PaginatedResult<ManifiestoResiduos>> {
const skip = (page - 1) * limit;
const queryBuilder = this.manifiestoRepository
.createQueryBuilder('m')
.leftJoinAndSelect('m.fraccionamiento', 'fraccionamiento')
.leftJoinAndSelect('m.transportista', 'transportista')
.leftJoinAndSelect('m.destino', 'destino')
.where('m.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('(m.transportista_id = :proveedorId OR m.destino_id = :proveedorId)', { proveedorId })
.orderBy('m.fecha_recoleccion', 'DESC')
.skip(skip)
.take(limit);
const [data, total] = await queryBuilder.getManyAndCount();
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
// ========== Alertas ==========
async getAlertas(ctx: ServiceContext, diasProximoVencer: number = 30): Promise<AlertaProveedor[]> {
const alertas: AlertaProveedor[] = [];
const today = new Date();
const proximaFecha = new Date();
proximaFecha.setDate(proximaFecha.getDate() + diasProximoVencer);
// Proveedores activos
const proveedores = await this.proveedorRepository.find({
where: { tenantId: ctx.tenantId, activo: true } as FindOptionsWhere<ProveedorAmbiental>,
});
for (const proveedor of proveedores) {
// Sin autorización
if (!proveedor.numeroAutorizacion) {
alertas.push({
proveedorId: proveedor.id,
razonSocial: proveedor.razonSocial,
tipo: 'sin_autorizacion',
mensaje: 'Proveedor sin numero de autorizacion registrado',
nivel: 'critico',
});
}
// Vencidos
if (proveedor.fechaVencimiento && proveedor.fechaVencimiento < today) {
const diasVencido = Math.floor(
(today.getTime() - proveedor.fechaVencimiento.getTime()) / (1000 * 60 * 60 * 24)
);
alertas.push({
proveedorId: proveedor.id,
razonSocial: proveedor.razonSocial,
tipo: 'vencimiento',
mensaje: `Autorizacion vencida hace ${diasVencido} dias`,
diasRestantes: -diasVencido,
nivel: 'critico',
});
}
// Próximos a vencer
else if (proveedor.fechaVencimiento && proveedor.fechaVencimiento <= proximaFecha) {
const diasRestantes = Math.floor(
(proveedor.fechaVencimiento.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
);
alertas.push({
proveedorId: proveedor.id,
razonSocial: proveedor.razonSocial,
tipo: 'vencimiento',
mensaje: `Autorizacion vence en ${diasRestantes} dias`,
diasRestantes,
nivel: 'advertencia',
});
}
}
// Sort by urgency
alertas.sort((a, b) => {
if (a.nivel === 'critico' && b.nivel !== 'critico') return -1;
if (a.nivel !== 'critico' && b.nivel === 'critico') return 1;
return (a.diasRestantes ?? 999) - (b.diasRestantes ?? 999);
});
return alertas;
}
// ========== Estadísticas ==========
async getStats(ctx: ServiceContext): Promise<ProveedorStats> {
const today = new Date();
const proximaFecha = new Date();
proximaFecha.setDate(proximaFecha.getDate() + 30);
const totalProveedores = await this.proveedorRepository.count({
where: { tenantId: ctx.tenantId } as FindOptionsWhere<ProveedorAmbiental>,
});
const proveedoresActivos = await this.proveedorRepository.count({
where: { tenantId: ctx.tenantId, activo: true } as FindOptionsWhere<ProveedorAmbiental>,
});
const proveedoresVencidos = await this.proveedorRepository
.createQueryBuilder('p')
.where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('p.activo = true')
.andWhere('p.fecha_vencimiento < :today', { today })
.getCount();
const proximosVencer = await this.proveedorRepository
.createQueryBuilder('p')
.where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('p.activo = true')
.andWhere('p.fecha_vencimiento >= :today', { today })
.andWhere('p.fecha_vencimiento <= :proximaFecha', { proximaFecha })
.getCount();
const porTipoRaw = await this.proveedorRepository
.createQueryBuilder('p')
.select('p.tipo', 'tipo')
.addSelect('COUNT(*)', 'count')
.where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('p.activo = true')
.groupBy('p.tipo')
.getRawMany();
const porTipo = porTipoRaw.map((t) => ({
tipo: t.tipo,
count: parseInt(t.count, 10),
}));
// Top proveedores by manifiestos
const topProveedoresRaw = await this.manifiestoRepository
.createQueryBuilder('m')
.innerJoin('m.transportista', 't')
.select('t.id', 'id')
.addSelect('t.razon_social', 'nombre')
.addSelect('COUNT(*)', 'manifiestos')
.where('m.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.groupBy('t.id')
.addGroupBy('t.razon_social')
.orderBy('COUNT(*)', 'DESC')
.limit(5)
.getRawMany();
const topProveedores = topProveedoresRaw.map((p) => ({
id: p.id,
nombre: p.nombre,
manifiestos: parseInt(p.manifiestos, 10),
}));
return {
totalProveedores,
proveedoresActivos,
proveedoresVencidos,
proximosVencer,
porTipo,
topProveedores,
};
}
// ========== Validación para Manifiestos ==========
async validarParaManifiesto(ctx: ServiceContext, proveedorId: string): Promise<{ valido: boolean; errores: string[] }> {
const errores: string[] = [];
const proveedor = await this.findById(ctx, proveedorId);
if (!proveedor) {
return { valido: false, errores: ['Proveedor no encontrado'] };
}
if (!proveedor.activo) {
errores.push('Proveedor inactivo');
}
if (!proveedor.numeroAutorizacion) {
errores.push('Proveedor sin numero de autorizacion');
}
const today = new Date();
if (proveedor.fechaVencimiento && proveedor.fechaVencimiento < today) {
errores.push('Autorizacion del proveedor vencida');
}
return {
valido: errores.length === 0,
errores,
};
}
}

View File

@ -0,0 +1,557 @@
/**
* ReporteProgramadoService - Servicio para gestión de reportes HSE programados
*
* RF-MAA017-008: Gestión de reportes programados para envío automático
* con soporte para múltiples frecuencias y formatos.
*
* @module HSE
*/
import { Repository, FindOptionsWhere } from 'typeorm';
import { ReporteProgramado, TipoReporteHse, FormatoReporte } from '../entities/reporte-programado.entity';
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
export interface ReporteProgramadoFilters {
tipoReporte?: TipoReporteHse;
formato?: FormatoReporte;
activo?: boolean;
search?: string;
}
export interface CreateReporteProgramadoDto {
nombre: string;
tipoReporte: TipoReporteHse;
indicadores?: string[];
fraccionamientos?: string[];
destinatarios?: string[];
diaEnvio?: number;
horaEnvio?: string;
formato?: FormatoReporte;
}
export interface UpdateReporteProgramadoDto {
nombre?: string;
tipoReporte?: TipoReporteHse;
indicadores?: string[];
fraccionamientos?: string[];
destinatarios?: string[];
diaEnvio?: number;
horaEnvio?: string;
formato?: FormatoReporte;
activo?: boolean;
}
export interface ReporteConProximoEnvio extends ReporteProgramado {
proximoEnvio: Date | null;
diasParaEnvio: number | null;
}
export interface ReporteProgramadoStats {
totalReportes: number;
reportesActivos: number;
porTipo: { tipo: string; count: number }[];
porFormato: { formato: string; count: number }[];
proximosEnvios: { reporteId: string; nombre: string; proximoEnvio: Date }[];
enviosUltimaSemana: number;
}
export interface HistorialEnvio {
reporteId: string;
reporteNombre: string;
fechaEnvio: Date;
exitoso: boolean;
destinatarios: string[];
error?: string;
}
export class ReporteProgramadoService {
constructor(
private readonly reporteRepository: Repository<ReporteProgramado>
) {}
// ========== CRUD de Reportes Programados ==========
async findAll(
ctx: ServiceContext,
filters: ReporteProgramadoFilters = {},
page: number = 1,
limit: number = 20
): Promise<PaginatedResult<ReporteProgramado>> {
const skip = (page - 1) * limit;
const queryBuilder = this.reporteRepository
.createQueryBuilder('r')
.where('r.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (filters.tipoReporte) {
queryBuilder.andWhere('r.tipo_reporte = :tipoReporte', { tipoReporte: filters.tipoReporte });
}
if (filters.formato) {
queryBuilder.andWhere('r.formato = :formato', { formato: filters.formato });
}
if (filters.activo !== undefined) {
queryBuilder.andWhere('r.activo = :activo', { activo: filters.activo });
}
if (filters.search) {
queryBuilder.andWhere('r.nombre ILIKE :search', { search: `%${filters.search}%` });
}
queryBuilder
.orderBy('r.nombre', 'ASC')
.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<ReporteProgramado | null> {
return this.reporteRepository.findOne({
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<ReporteProgramado>,
});
}
async findActivos(ctx: ServiceContext): Promise<ReporteProgramado[]> {
return this.reporteRepository.find({
where: { tenantId: ctx.tenantId, activo: true } as FindOptionsWhere<ReporteProgramado>,
order: { nombre: 'ASC' },
});
}
async findByTipo(ctx: ServiceContext, tipoReporte: TipoReporteHse): Promise<ReporteProgramado[]> {
return this.reporteRepository.find({
where: {
tenantId: ctx.tenantId,
tipoReporte,
activo: true,
} as FindOptionsWhere<ReporteProgramado>,
order: { nombre: 'ASC' },
});
}
async create(ctx: ServiceContext, dto: CreateReporteProgramadoDto): Promise<ReporteProgramado> {
// Validate destinatarios are email addresses
if (dto.destinatarios) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
for (const email of dto.destinatarios) {
if (!emailRegex.test(email)) {
throw new Error(`Destinatario invalido: ${email}`);
}
}
}
// Set default diaEnvio based on tipo
let diaEnvio = dto.diaEnvio;
if (diaEnvio === undefined) {
switch (dto.tipoReporte) {
case 'semanal':
diaEnvio = 1; // Monday
break;
case 'mensual':
diaEnvio = 1; // First day
break;
case 'trimestral':
diaEnvio = 1; // First day
break;
case 'anual':
diaEnvio = 15; // Mid-January
break;
}
}
const reporte = this.reporteRepository.create({
tenantId: ctx.tenantId,
nombre: dto.nombre,
tipoReporte: dto.tipoReporte,
indicadores: dto.indicadores,
fraccionamientos: dto.fraccionamientos,
destinatarios: dto.destinatarios,
diaEnvio,
horaEnvio: dto.horaEnvio || '08:00',
formato: dto.formato || 'pdf',
activo: true,
});
return this.reporteRepository.save(reporte);
}
async update(
ctx: ServiceContext,
id: string,
dto: UpdateReporteProgramadoDto
): Promise<ReporteProgramado | null> {
const reporte = await this.findById(ctx, id);
if (!reporte) return null;
// Validate destinatarios if provided
if (dto.destinatarios) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
for (const email of dto.destinatarios) {
if (!emailRegex.test(email)) {
throw new Error(`Destinatario invalido: ${email}`);
}
}
}
if (dto.nombre !== undefined) reporte.nombre = dto.nombre;
if (dto.tipoReporte !== undefined) reporte.tipoReporte = dto.tipoReporte;
if (dto.indicadores !== undefined) reporte.indicadores = dto.indicadores;
if (dto.fraccionamientos !== undefined) reporte.fraccionamientos = dto.fraccionamientos;
if (dto.destinatarios !== undefined) reporte.destinatarios = dto.destinatarios;
if (dto.diaEnvio !== undefined) reporte.diaEnvio = dto.diaEnvio;
if (dto.horaEnvio !== undefined) reporte.horaEnvio = dto.horaEnvio;
if (dto.formato !== undefined) reporte.formato = dto.formato;
if (dto.activo !== undefined) reporte.activo = dto.activo;
return this.reporteRepository.save(reporte);
}
async activate(ctx: ServiceContext, id: string): Promise<ReporteProgramado | null> {
return this.update(ctx, id, { activo: true });
}
async deactivate(ctx: ServiceContext, id: string): Promise<ReporteProgramado | null> {
return this.update(ctx, id, { activo: false });
}
async delete(ctx: ServiceContext, id: string): Promise<boolean> {
const result = await this.reporteRepository.delete({
id,
tenantId: ctx.tenantId,
} as FindOptionsWhere<ReporteProgramado>);
return (result.affected ?? 0) > 0;
}
// ========== Cálculo de Próximo Envío ==========
private calcularProximoEnvio(reporte: ReporteProgramado): Date | null {
if (!reporte.activo) return null;
const now = new Date();
const proximoEnvio = new Date();
// Parse hora
const [hora, minuto] = (reporte.horaEnvio || '08:00').split(':').map(Number);
proximoEnvio.setHours(hora, minuto, 0, 0);
switch (reporte.tipoReporte) {
case 'semanal': {
// diaEnvio: 0 = Sunday, 1 = Monday, etc.
const dia = reporte.diaEnvio || 1;
const diasHastaEnvio = (dia - now.getDay() + 7) % 7 || 7;
proximoEnvio.setDate(now.getDate() + diasHastaEnvio);
if (proximoEnvio <= now) {
proximoEnvio.setDate(proximoEnvio.getDate() + 7);
}
break;
}
case 'mensual': {
// diaEnvio: day of month (1-28)
const dia = Math.min(reporte.diaEnvio || 1, 28);
proximoEnvio.setDate(dia);
if (proximoEnvio <= now) {
proximoEnvio.setMonth(proximoEnvio.getMonth() + 1);
}
break;
}
case 'trimestral': {
// diaEnvio: day of month
const dia = Math.min(reporte.diaEnvio || 1, 28);
const currentQuarter = Math.floor(now.getMonth() / 3);
const nextQuarterMonth = (currentQuarter + 1) * 3;
proximoEnvio.setMonth(nextQuarterMonth % 12);
proximoEnvio.setDate(dia);
if (nextQuarterMonth >= 12) {
proximoEnvio.setFullYear(proximoEnvio.getFullYear() + 1);
}
if (proximoEnvio <= now) {
proximoEnvio.setMonth(proximoEnvio.getMonth() + 3);
}
break;
}
case 'anual': {
// diaEnvio: day of January
const dia = Math.min(reporte.diaEnvio || 15, 28);
proximoEnvio.setMonth(0, dia); // January
if (proximoEnvio <= now) {
proximoEnvio.setFullYear(proximoEnvio.getFullYear() + 1);
}
break;
}
}
return proximoEnvio;
}
async findWithProximoEnvio(
ctx: ServiceContext,
filters: ReporteProgramadoFilters = {}
): Promise<ReporteConProximoEnvio[]> {
const result = await this.findAll(ctx, filters, 1, 1000);
const now = new Date();
return result.data.map((reporte) => {
const proximoEnvio = this.calcularProximoEnvio(reporte);
const diasParaEnvio = proximoEnvio
? Math.ceil((proximoEnvio.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
: null;
return {
...reporte,
proximoEnvio,
diasParaEnvio,
};
});
}
// ========== Reportes Pendientes de Envío ==========
async findPendientesEnvio(ctx: ServiceContext): Promise<ReporteProgramado[]> {
const reportes = await this.findActivos(ctx);
const pendientes: ReporteProgramado[] = [];
const now = new Date();
for (const reporte of reportes) {
const proximoEnvio = this.calcularProximoEnvio(reporte);
if (proximoEnvio && proximoEnvio <= now) {
pendientes.push(reporte);
}
}
return pendientes;
}
// ========== Marcar Envío ==========
async marcarEnviado(ctx: ServiceContext, id: string): Promise<ReporteProgramado | null> {
const reporte = await this.findById(ctx, id);
if (!reporte) return null;
reporte.ultimoEnvio = new Date();
return this.reporteRepository.save(reporte);
}
// ========== Gestión de Destinatarios ==========
async addDestinatario(ctx: ServiceContext, id: string, email: string): Promise<ReporteProgramado | null> {
const reporte = await this.findById(ctx, id);
if (!reporte) return null;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new Error('Email invalido');
}
const destinatarios = reporte.destinatarios || [];
if (!destinatarios.includes(email)) {
destinatarios.push(email);
reporte.destinatarios = destinatarios;
return this.reporteRepository.save(reporte);
}
return reporte;
}
async removeDestinatario(ctx: ServiceContext, id: string, email: string): Promise<ReporteProgramado | null> {
const reporte = await this.findById(ctx, id);
if (!reporte) return null;
const destinatarios = reporte.destinatarios || [];
const index = destinatarios.indexOf(email);
if (index > -1) {
destinatarios.splice(index, 1);
reporte.destinatarios = destinatarios;
return this.reporteRepository.save(reporte);
}
return reporte;
}
// ========== Gestión de Indicadores ==========
async addIndicador(ctx: ServiceContext, id: string, indicadorId: string): Promise<ReporteProgramado | null> {
const reporte = await this.findById(ctx, id);
if (!reporte) return null;
const indicadores = reporte.indicadores || [];
if (!indicadores.includes(indicadorId)) {
indicadores.push(indicadorId);
reporte.indicadores = indicadores;
return this.reporteRepository.save(reporte);
}
return reporte;
}
async removeIndicador(ctx: ServiceContext, id: string, indicadorId: string): Promise<ReporteProgramado | null> {
const reporte = await this.findById(ctx, id);
if (!reporte) return null;
const indicadores = reporte.indicadores || [];
const index = indicadores.indexOf(indicadorId);
if (index > -1) {
indicadores.splice(index, 1);
reporte.indicadores = indicadores;
return this.reporteRepository.save(reporte);
}
return reporte;
}
// ========== Gestión de Fraccionamientos ==========
async addFraccionamiento(ctx: ServiceContext, id: string, fraccionamientoId: string): Promise<ReporteProgramado | null> {
const reporte = await this.findById(ctx, id);
if (!reporte) return null;
const fraccionamientos = reporte.fraccionamientos || [];
if (!fraccionamientos.includes(fraccionamientoId)) {
fraccionamientos.push(fraccionamientoId);
reporte.fraccionamientos = fraccionamientos;
return this.reporteRepository.save(reporte);
}
return reporte;
}
async removeFraccionamiento(ctx: ServiceContext, id: string, fraccionamientoId: string): Promise<ReporteProgramado | null> {
const reporte = await this.findById(ctx, id);
if (!reporte) return null;
const fraccionamientos = reporte.fraccionamientos || [];
const index = fraccionamientos.indexOf(fraccionamientoId);
if (index > -1) {
fraccionamientos.splice(index, 1);
reporte.fraccionamientos = fraccionamientos;
return this.reporteRepository.save(reporte);
}
return reporte;
}
// ========== Duplicar Reporte ==========
async duplicar(ctx: ServiceContext, id: string, nuevoNombre: string): Promise<ReporteProgramado | null> {
const original = await this.findById(ctx, id);
if (!original) return null;
const duplicado = this.reporteRepository.create({
tenantId: ctx.tenantId,
nombre: nuevoNombre,
tipoReporte: original.tipoReporte,
indicadores: original.indicadores ? [...original.indicadores] : undefined,
fraccionamientos: original.fraccionamientos ? [...original.fraccionamientos] : undefined,
destinatarios: original.destinatarios ? [...original.destinatarios] : undefined,
diaEnvio: original.diaEnvio,
horaEnvio: original.horaEnvio,
formato: original.formato,
activo: false, // Start inactive
});
return this.reporteRepository.save(duplicado);
}
// ========== Estadísticas ==========
async getStats(ctx: ServiceContext): Promise<ReporteProgramadoStats> {
const totalReportes = await this.reporteRepository.count({
where: { tenantId: ctx.tenantId } as FindOptionsWhere<ReporteProgramado>,
});
const reportesActivos = await this.reporteRepository.count({
where: { tenantId: ctx.tenantId, activo: true } as FindOptionsWhere<ReporteProgramado>,
});
// Por tipo
const porTipoRaw = await this.reporteRepository
.createQueryBuilder('r')
.select('r.tipo_reporte', 'tipo')
.addSelect('COUNT(*)', 'count')
.where('r.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.groupBy('r.tipo_reporte')
.getRawMany();
const porTipo = porTipoRaw.map((t) => ({
tipo: t.tipo,
count: parseInt(t.count, 10),
}));
// Por formato
const porFormatoRaw = await this.reporteRepository
.createQueryBuilder('r')
.select('r.formato', 'formato')
.addSelect('COUNT(*)', 'count')
.where('r.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.groupBy('r.formato')
.getRawMany();
const porFormato = porFormatoRaw.map((f) => ({
formato: f.formato,
count: parseInt(f.count, 10),
}));
// Próximos envíos
const reportesConProximo = await this.findWithProximoEnvio(ctx, { activo: true });
const proximosEnvios = reportesConProximo
.filter((r) => r.proximoEnvio !== null)
.sort((a, b) => (a.proximoEnvio!.getTime() - b.proximoEnvio!.getTime()))
.slice(0, 5)
.map((r) => ({
reporteId: r.id,
nombre: r.nombre,
proximoEnvio: r.proximoEnvio!,
}));
// Envíos última semana
const semanaAtras = new Date();
semanaAtras.setDate(semanaAtras.getDate() - 7);
const enviosUltimaSemana = await this.reporteRepository
.createQueryBuilder('r')
.where('r.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('r.ultimo_envio >= :semanaAtras', { semanaAtras })
.getCount();
return {
totalReportes,
reportesActivos,
porTipo,
porFormato,
proximosEnvios,
enviosUltimaSemana,
};
}
// ========== Validación de Configuración ==========
async validarConfiguracion(ctx: ServiceContext, id: string): Promise<{ valido: boolean; errores: string[] }> {
const errores: string[] = [];
const reporte = await this.findById(ctx, id);
if (!reporte) {
return { valido: false, errores: ['Reporte no encontrado'] };
}
if (!reporte.destinatarios || reporte.destinatarios.length === 0) {
errores.push('El reporte no tiene destinatarios configurados');
}
if (!reporte.indicadores || reporte.indicadores.length === 0) {
errores.push('El reporte no tiene indicadores seleccionados');
}
if (!reporte.fraccionamientos || reporte.fraccionamientos.length === 0) {
errores.push('El reporte no tiene fraccionamientos seleccionados');
}
if (!reporte.diaEnvio) {
errores.push('El reporte no tiene dia de envio configurado');
}
return {
valido: errores.length === 0,
errores,
};
}
}

View File

@ -0,0 +1,499 @@
/**
* ResiduoPeligrosoService - Servicio para gestión de residuos peligrosos
*
* RF-MAA017-006: Gestión especializada de residuos peligrosos con seguimiento
* completo desde generación hasta disposición final.
*
* @module HSE
*/
import { Repository, FindOptionsWhere } from 'typeorm';
import { ResiduoCatalogo, CategoriaResiduo } from '../entities/residuo-catalogo.entity';
import { ResiduoGeneracion, UnidadResiduo, EstadoResiduo } from '../entities/residuo-generacion.entity';
import { AlmacenTemporal } from '../entities/almacen-temporal.entity';
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
export interface ResiduoPeligrosoFilters {
fraccionamientoId?: string;
residuoId?: string;
estado?: EstadoResiduo;
categoria?: CategoriaResiduo;
areaGeneracion?: string;
contenedorId?: string;
dateFrom?: Date;
dateTo?: Date;
vencidos?: boolean;
}
export interface CreateResiduoPeligrosoDto {
fraccionamientoId: string;
residuoId: string;
fechaGeneracion: Date;
cantidad: number;
unidad: UnidadResiduo;
areaGeneracion?: string;
fuente?: string;
contenedorId?: string;
fotoUrl?: string;
ubicacionGeo?: { lat: number; lng: number };
}
export interface UpdateResiduoPeligrosoDto {
cantidad?: number;
unidad?: UnidadResiduo;
areaGeneracion?: string;
fuente?: string;
contenedorId?: string;
fotoUrl?: string;
}
export interface TransferirResiduoDto {
contenedorDestinoId: string;
cantidad: number;
observaciones?: string;
}
export interface ResiduoPeligrosoStats {
totalGenerado: number;
totalAlmacenado: number;
totalDispuesto: number;
residuosVencidos: number;
proximosVencer: number;
porCategoria: { categoria: string; cantidad: number; unidad: string }[];
porArea: { area: string; cantidad: number }[];
tendenciaMensual: { mes: string; cantidad: number }[];
}
export interface AlertaVencimiento {
residuoGeneracionId: string;
residuoNombre: string;
fechaGeneracion: Date;
diasAlmacenado: number;
tiempoMaximo: number;
diasRestantes: number;
estado: 'normal' | 'proximo' | 'vencido';
}
export class ResiduoPeligrosoService {
constructor(
private readonly residuoCatalogoRepository: Repository<ResiduoCatalogo>,
private readonly generacionRepository: Repository<ResiduoGeneracion>,
private readonly almacenRepository: Repository<AlmacenTemporal>
) {}
// ========== Catálogo de Residuos Peligrosos ==========
async findResiduosPeligrosos(
search?: string,
activo: boolean = true
): Promise<ResiduoCatalogo[]> {
const queryBuilder = this.residuoCatalogoRepository
.createQueryBuilder('r')
.where('r.categoria = :categoria', { categoria: 'peligroso' })
.andWhere('r.activo = :activo', { activo });
if (search) {
queryBuilder.andWhere(
'(r.codigo ILIKE :search OR r.nombre ILIKE :search)',
{ search: `%${search}%` }
);
}
return queryBuilder.orderBy('r.nombre', 'ASC').getMany();
}
async findResiduoById(id: string): Promise<ResiduoCatalogo | null> {
return this.residuoCatalogoRepository.findOne({
where: { id, categoria: 'peligroso' } as FindOptionsWhere<ResiduoCatalogo>,
});
}
async findResiduoByCodigo(codigo: string): Promise<ResiduoCatalogo | null> {
return this.residuoCatalogoRepository.findOne({
where: { codigo } as FindOptionsWhere<ResiduoCatalogo>,
});
}
// ========== Generación de Residuos Peligrosos ==========
async findGeneraciones(
ctx: ServiceContext,
filters: ResiduoPeligrosoFilters = {},
page: number = 1,
limit: number = 20
): Promise<PaginatedResult<ResiduoGeneracion>> {
const skip = (page - 1) * limit;
const queryBuilder = this.generacionRepository
.createQueryBuilder('g')
.innerJoinAndSelect('g.residuo', 'residuo')
.leftJoinAndSelect('g.fraccionamiento', 'fraccionamiento')
.leftJoinAndSelect('g.createdBy', 'createdBy')
.where('g.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('residuo.categoria = :categoria', { categoria: 'peligroso' });
if (filters.fraccionamientoId) {
queryBuilder.andWhere('g.fraccionamiento_id = :fraccionamientoId', {
fraccionamientoId: filters.fraccionamientoId,
});
}
if (filters.residuoId) {
queryBuilder.andWhere('g.residuo_id = :residuoId', {
residuoId: filters.residuoId,
});
}
if (filters.estado) {
queryBuilder.andWhere('g.estado = :estado', { estado: filters.estado });
}
if (filters.areaGeneracion) {
queryBuilder.andWhere('g.area_generacion ILIKE :area', {
area: `%${filters.areaGeneracion}%`,
});
}
if (filters.contenedorId) {
queryBuilder.andWhere('g.contenedor_id = :contenedorId', {
contenedorId: filters.contenedorId,
});
}
if (filters.dateFrom) {
queryBuilder.andWhere('g.fecha_generacion >= :dateFrom', {
dateFrom: filters.dateFrom,
});
}
if (filters.dateTo) {
queryBuilder.andWhere('g.fecha_generacion <= :dateTo', {
dateTo: filters.dateTo,
});
}
if (filters.vencidos) {
// Residuos que han excedido su tiempo máximo de almacenamiento
queryBuilder.andWhere('g.estado = :estadoAlmacenado', { estadoAlmacenado: 'almacenado' });
queryBuilder.andWhere(
"g.fecha_generacion + (residuo.tiempo_max_almacen_dias || ' days')::interval < NOW()"
);
}
queryBuilder
.orderBy('g.fecha_generacion', 'DESC')
.skip(skip)
.take(limit);
const [data, total] = await queryBuilder.getManyAndCount();
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findGeneracionById(ctx: ServiceContext, id: string): Promise<ResiduoGeneracion | null> {
return this.generacionRepository.findOne({
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<ResiduoGeneracion>,
relations: ['residuo', 'fraccionamiento', 'createdBy'],
});
}
async createGeneracion(ctx: ServiceContext, dto: CreateResiduoPeligrosoDto): Promise<ResiduoGeneracion> {
// Validate that the residuo is peligroso
const residuo = await this.residuoCatalogoRepository.findOne({
where: { id: dto.residuoId } as FindOptionsWhere<ResiduoCatalogo>,
});
if (!residuo) {
throw new Error('Residuo no encontrado en el catálogo');
}
if (residuo.categoria !== 'peligroso') {
throw new Error('Este servicio solo gestiona residuos peligrosos');
}
const generacion = this.generacionRepository.create({
tenantId: ctx.tenantId,
fraccionamientoId: dto.fraccionamientoId,
residuoId: dto.residuoId,
fechaGeneracion: dto.fechaGeneracion,
cantidad: dto.cantidad,
unidad: dto.unidad,
areaGeneracion: dto.areaGeneracion,
fuente: dto.fuente,
contenedorId: dto.contenedorId,
fotoUrl: dto.fotoUrl,
estado: 'almacenado',
createdById: ctx.userId,
});
if (dto.ubicacionGeo) {
generacion.ubicacionGeo = `POINT(${dto.ubicacionGeo.lng} ${dto.ubicacionGeo.lat})`;
}
return this.generacionRepository.save(generacion);
}
async updateGeneracion(
ctx: ServiceContext,
id: string,
dto: UpdateResiduoPeligrosoDto
): Promise<ResiduoGeneracion | null> {
const generacion = await this.generacionRepository.findOne({
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<ResiduoGeneracion>,
});
if (!generacion) return null;
if (generacion.estado !== 'almacenado') {
throw new Error('Solo se pueden modificar residuos en estado almacenado');
}
if (dto.cantidad !== undefined) generacion.cantidad = dto.cantidad;
if (dto.unidad !== undefined) generacion.unidad = dto.unidad;
if (dto.areaGeneracion !== undefined) generacion.areaGeneracion = dto.areaGeneracion;
if (dto.fuente !== undefined) generacion.fuente = dto.fuente;
if (dto.contenedorId !== undefined) generacion.contenedorId = dto.contenedorId;
if (dto.fotoUrl !== undefined) generacion.fotoUrl = dto.fotoUrl;
return this.generacionRepository.save(generacion);
}
async updateEstado(
ctx: ServiceContext,
id: string,
estado: EstadoResiduo
): Promise<ResiduoGeneracion | null> {
const generacion = await this.generacionRepository.findOne({
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<ResiduoGeneracion>,
});
if (!generacion) return null;
generacion.estado = estado;
return this.generacionRepository.save(generacion);
}
async transferirContenedor(
ctx: ServiceContext,
id: string,
dto: TransferirResiduoDto
): Promise<ResiduoGeneracion | null> {
const generacion = await this.generacionRepository.findOne({
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<ResiduoGeneracion>,
});
if (!generacion) return null;
if (generacion.estado !== 'almacenado') {
throw new Error('Solo se pueden transferir residuos en estado almacenado');
}
// Verify destination container exists
const contenedorDestino = await this.almacenRepository.findOne({
where: { id: dto.contenedorDestinoId, tenantId: ctx.tenantId } as FindOptionsWhere<AlmacenTemporal>,
});
if (!contenedorDestino) {
throw new Error('Contenedor destino no encontrado');
}
if (contenedorDestino.estado === 'lleno') {
throw new Error('El contenedor destino está lleno');
}
generacion.contenedorId = dto.contenedorDestinoId;
return this.generacionRepository.save(generacion);
}
// ========== Alertas de Vencimiento ==========
async getAlertasVencimiento(
ctx: ServiceContext,
fraccionamientoId?: string,
diasProximoVencer: number = 7
): Promise<AlertaVencimiento[]> {
const queryBuilder = this.generacionRepository
.createQueryBuilder('g')
.innerJoinAndSelect('g.residuo', 'r')
.where('g.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('g.estado = :estado', { estado: 'almacenado' })
.andWhere('r.categoria = :categoria', { categoria: 'peligroso' })
.andWhere('r.tiempo_max_almacen_dias IS NOT NULL');
if (fraccionamientoId) {
queryBuilder.andWhere('g.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
}
const generaciones = await queryBuilder.getMany();
const alertas: AlertaVencimiento[] = [];
const today = new Date();
for (const gen of generaciones) {
const fechaGen = new Date(gen.fechaGeneracion);
const diasAlmacenado = Math.floor((today.getTime() - fechaGen.getTime()) / (1000 * 60 * 60 * 24));
const tiempoMax = gen.residuo.tiempoMaxAlmacenDias || 180; // Default 180 days
const diasRestantes = tiempoMax - diasAlmacenado;
let estado: 'normal' | 'proximo' | 'vencido' = 'normal';
if (diasRestantes <= 0) {
estado = 'vencido';
} else if (diasRestantes <= diasProximoVencer) {
estado = 'proximo';
}
if (estado !== 'normal') {
alertas.push({
residuoGeneracionId: gen.id,
residuoNombre: gen.residuo.nombre,
fechaGeneracion: gen.fechaGeneracion,
diasAlmacenado,
tiempoMaximo: tiempoMax,
diasRestantes: Math.max(0, diasRestantes),
estado,
});
}
}
// Sort by dias restantes (most urgent first)
alertas.sort((a, b) => a.diasRestantes - b.diasRestantes);
return alertas;
}
// ========== Estadísticas ==========
async getStats(ctx: ServiceContext, fraccionamientoId?: string): Promise<ResiduoPeligrosoStats> {
const baseQuery = () => {
const qb = this.generacionRepository
.createQueryBuilder('g')
.innerJoin('g.residuo', 'r')
.where('g.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('r.categoria = :categoria', { categoria: 'peligroso' });
if (fraccionamientoId) {
qb.andWhere('g.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
}
return qb;
};
// Total generado
const totalGeneradoResult = await baseQuery()
.select('SUM(g.cantidad)', 'total')
.getRawOne();
const totalGenerado = parseFloat(totalGeneradoResult?.total || '0');
// Total almacenado
const totalAlmacenadoResult = await baseQuery()
.andWhere('g.estado = :estado', { estado: 'almacenado' })
.select('SUM(g.cantidad)', 'total')
.getRawOne();
const totalAlmacenado = parseFloat(totalAlmacenadoResult?.total || '0');
// Total dispuesto
const totalDispuestoResult = await baseQuery()
.andWhere('g.estado = :estado', { estado: 'dispuesto' })
.select('SUM(g.cantidad)', 'total')
.getRawOne();
const totalDispuesto = parseFloat(totalDispuestoResult?.total || '0');
// Residuos vencidos
const vencidosResult = await baseQuery()
.andWhere('g.estado = :estado', { estado: 'almacenado' })
.andWhere("g.fecha_generacion + (r.tiempo_max_almacen_dias || ' days')::interval < NOW()")
.getCount();
// Próximos a vencer (7 días)
const proximosResult = await baseQuery()
.andWhere('g.estado = :estado', { estado: 'almacenado' })
.andWhere("g.fecha_generacion + (r.tiempo_max_almacen_dias || ' days')::interval >= NOW()")
.andWhere("g.fecha_generacion + ((r.tiempo_max_almacen_dias - 7) || ' days')::interval < NOW()")
.getCount();
// Por categoría (subcategoría CRETIB)
const porCategoriaRaw = await baseQuery()
.select('r.caracteristicas_cretib', 'categoria')
.addSelect('g.unidad', 'unidad')
.addSelect('SUM(g.cantidad)', 'cantidad')
.groupBy('r.caracteristicas_cretib')
.addGroupBy('g.unidad')
.getRawMany();
const porCategoria = porCategoriaRaw.map((c) => ({
categoria: c.categoria || 'Sin clasificar',
cantidad: parseFloat(c.cantidad || '0'),
unidad: c.unidad,
}));
// Por área de generación
const porAreaRaw = await baseQuery()
.select('g.area_generacion', 'area')
.addSelect('SUM(g.cantidad)', 'cantidad')
.groupBy('g.area_generacion')
.getRawMany();
const porArea = porAreaRaw
.filter((a) => a.area)
.map((a) => ({
area: a.area,
cantidad: parseFloat(a.cantidad || '0'),
}));
// Tendencia mensual (últimos 6 meses)
const sixMonthsAgo = new Date();
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
const tendenciaRaw = await baseQuery()
.andWhere('g.fecha_generacion >= :sixMonthsAgo', { sixMonthsAgo })
.select("TO_CHAR(g.fecha_generacion, 'YYYY-MM')", 'mes')
.addSelect('SUM(g.cantidad)', 'cantidad')
.groupBy("TO_CHAR(g.fecha_generacion, 'YYYY-MM')")
.orderBy("TO_CHAR(g.fecha_generacion, 'YYYY-MM')", 'ASC')
.getRawMany();
const tendenciaMensual = tendenciaRaw.map((t) => ({
mes: t.mes,
cantidad: parseFloat(t.cantidad || '0'),
}));
return {
totalGenerado,
totalAlmacenado,
totalDispuesto,
residuosVencidos: vencidosResult,
proximosVencer: proximosResult,
porCategoria,
porArea,
tendenciaMensual,
};
}
// ========== Trazabilidad ==========
async getHistorialResiduo(ctx: ServiceContext, residuoId: string): Promise<ResiduoGeneracion[]> {
return this.generacionRepository.find({
where: {
tenantId: ctx.tenantId,
residuoId,
} as FindOptionsWhere<ResiduoGeneracion>,
relations: ['residuo', 'fraccionamiento', 'createdBy'],
order: { fechaGeneracion: 'DESC' },
});
}
async getGeneracionesPorContenedor(
ctx: ServiceContext,
contenedorId: string
): Promise<ResiduoGeneracion[]> {
return this.generacionRepository.find({
where: {
tenantId: ctx.tenantId,
contenedorId,
estado: 'almacenado',
} as FindOptionsWhere<ResiduoGeneracion>,
relations: ['residuo'],
order: { fechaGeneracion: 'ASC' },
});
}
}

View File

@ -0,0 +1,419 @@
/**
* SesionCapacitacionService - Servicio para sesiones y asistencia de capacitación
*
* RF-MAA017-002: Control de Capacitaciones - Sesiones y Asistencia
*
* @module HSE
*/
import { Repository, FindOptionsWhere } from 'typeorm';
import { CapacitacionSesion, EstadoSesion } from '../entities/capacitacion-sesion.entity';
import { CapacitacionAsistente } from '../entities/capacitacion-asistente.entity';
import { CapacitacionMatriz } from '../entities/capacitacion-matriz.entity';
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
export interface CreateSesionDto {
capacitacionId: string;
fraccionamientoId?: string;
instructorId?: string;
fechaProgramada: Date;
horaInicio: string;
horaFin: string;
lugar?: string;
cupoMaximo?: number;
observaciones?: string;
}
export interface UpdateSesionDto {
instructorId?: string;
fechaProgramada?: Date;
horaInicio?: string;
horaFin?: string;
lugar?: string;
cupoMaximo?: number;
observaciones?: string;
}
export interface SesionFilters {
capacitacionId?: string;
fraccionamientoId?: string;
instructorId?: string;
estado?: EstadoSesion;
dateFrom?: Date;
dateTo?: Date;
}
export interface RegistrarAsistenciaDto {
sesionId: string;
employeeId: string;
asistio: boolean;
horaEntrada?: string;
horaSalida?: string;
observaciones?: string;
}
export interface EvaluarAsistenteDto {
calificacion: number;
aprobado: boolean;
observaciones?: string;
}
export interface SesionStats {
totalSesiones: number;
sesionesProgramadas: number;
sesionesCompletadas: number;
sesionesCanceladas: number;
totalAsistentes: number;
asistentesAprobados: number;
}
export class SesionCapacitacionService {
constructor(
private readonly sesionRepository: Repository<CapacitacionSesion>,
private readonly asistenteRepository: Repository<CapacitacionAsistente>,
private readonly matrizRepository: Repository<CapacitacionMatriz>
) {}
// ========== Sesiones ==========
async findSesiones(
ctx: ServiceContext,
filters: SesionFilters = {},
page: number = 1,
limit: number = 20
): Promise<PaginatedResult<CapacitacionSesion>> {
const skip = (page - 1) * limit;
const queryBuilder = this.sesionRepository
.createQueryBuilder('sesion')
.leftJoinAndSelect('sesion.capacitacion', 'capacitacion')
.leftJoinAndSelect('sesion.fraccionamiento', 'fraccionamiento')
.leftJoinAndSelect('sesion.instructor', 'instructor')
.where('sesion.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (filters.capacitacionId) {
queryBuilder.andWhere('sesion.capacitacion_id = :capacitacionId', {
capacitacionId: filters.capacitacionId,
});
}
if (filters.fraccionamientoId) {
queryBuilder.andWhere('sesion.fraccionamiento_id = :fraccionamientoId', {
fraccionamientoId: filters.fraccionamientoId,
});
}
if (filters.instructorId) {
queryBuilder.andWhere('sesion.instructor_id = :instructorId', {
instructorId: filters.instructorId,
});
}
if (filters.estado) {
queryBuilder.andWhere('sesion.estado = :estado', { estado: filters.estado });
}
if (filters.dateFrom) {
queryBuilder.andWhere('sesion.fecha_programada >= :dateFrom', { dateFrom: filters.dateFrom });
}
if (filters.dateTo) {
queryBuilder.andWhere('sesion.fecha_programada <= :dateTo', { dateTo: filters.dateTo });
}
queryBuilder
.orderBy('sesion.fecha_programada', 'DESC')
.addOrderBy('sesion.hora_inicio', 'ASC')
.skip(skip)
.take(limit);
const [data, total] = await queryBuilder.getManyAndCount();
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findSesionById(ctx: ServiceContext, id: string): Promise<CapacitacionSesion | null> {
return this.sesionRepository.findOne({
where: { id, tenantId: ctx.tenantId } as FindOptionsWhere<CapacitacionSesion>,
relations: ['capacitacion', 'fraccionamiento', 'instructor', 'asistentes', 'asistentes.employee'],
});
}
async createSesion(ctx: ServiceContext, dto: CreateSesionDto): Promise<CapacitacionSesion> {
const sesion = this.sesionRepository.create({
tenantId: ctx.tenantId,
capacitacionId: dto.capacitacionId,
fraccionamientoId: dto.fraccionamientoId,
instructorId: dto.instructorId,
fechaProgramada: dto.fechaProgramada,
horaInicio: dto.horaInicio,
horaFin: dto.horaFin,
lugar: dto.lugar,
cupoMaximo: dto.cupoMaximo,
observaciones: dto.observaciones,
estado: 'programada',
});
return this.sesionRepository.save(sesion);
}
async updateSesion(ctx: ServiceContext, id: string, dto: UpdateSesionDto): Promise<CapacitacionSesion | null> {
const sesion = await this.findSesionById(ctx, id);
if (!sesion) return null;
if (sesion.estado !== 'programada') {
throw new Error('Solo se pueden modificar sesiones programadas');
}
Object.assign(sesion, dto);
return this.sesionRepository.save(sesion);
}
async iniciarSesion(ctx: ServiceContext, id: string): Promise<CapacitacionSesion | null> {
const sesion = await this.findSesionById(ctx, id);
if (!sesion) return null;
if (sesion.estado !== 'programada') {
throw new Error('Solo se pueden iniciar sesiones programadas');
}
sesion.estado = 'en_curso';
return this.sesionRepository.save(sesion);
}
async completarSesion(ctx: ServiceContext, id: string): Promise<CapacitacionSesion | null> {
const sesion = await this.findSesionById(ctx, id);
if (!sesion) return null;
if (sesion.estado !== 'en_curso') {
throw new Error('Solo se pueden completar sesiones en curso');
}
sesion.estado = 'completada';
return this.sesionRepository.save(sesion);
}
async cancelarSesion(ctx: ServiceContext, id: string, observaciones?: string): Promise<CapacitacionSesion | null> {
const sesion = await this.findSesionById(ctx, id);
if (!sesion) return null;
if (sesion.estado === 'completada') {
throw new Error('No se pueden cancelar sesiones completadas');
}
sesion.estado = 'cancelada';
if (observaciones) {
sesion.observaciones = sesion.observaciones
? `${sesion.observaciones}\nCancelación: ${observaciones}`
: `Cancelación: ${observaciones}`;
}
return this.sesionRepository.save(sesion);
}
// ========== Asistentes ==========
async findAsistentesBySesion(sesionId: string): Promise<CapacitacionAsistente[]> {
return this.asistenteRepository.find({
where: { sesionId } as FindOptionsWhere<CapacitacionAsistente>,
relations: ['employee'],
order: { createdAt: 'ASC' },
});
}
async inscribirAsistente(sesionId: string, employeeId: string): Promise<CapacitacionAsistente> {
const existing = await this.asistenteRepository.findOne({
where: { sesionId, employeeId } as FindOptionsWhere<CapacitacionAsistente>,
});
if (existing) {
throw new Error('El empleado ya está inscrito en esta sesión');
}
const asistente = this.asistenteRepository.create({
sesionId,
employeeId,
asistio: false,
});
return this.asistenteRepository.save(asistente);
}
async registrarAsistencia(dto: RegistrarAsistenciaDto): Promise<CapacitacionAsistente> {
let asistente = await this.asistenteRepository.findOne({
where: {
sesionId: dto.sesionId,
employeeId: dto.employeeId,
} as FindOptionsWhere<CapacitacionAsistente>,
});
if (!asistente) {
asistente = this.asistenteRepository.create({
sesionId: dto.sesionId,
employeeId: dto.employeeId,
});
}
asistente.asistio = dto.asistio;
if (dto.horaEntrada) asistente.horaEntrada = dto.horaEntrada;
if (dto.horaSalida) asistente.horaSalida = dto.horaSalida;
if (dto.observaciones) asistente.observaciones = dto.observaciones;
return this.asistenteRepository.save(asistente);
}
async evaluarAsistente(asistenteId: string, dto: EvaluarAsistenteDto): Promise<CapacitacionAsistente | null> {
const asistente = await this.asistenteRepository.findOne({
where: { id: asistenteId } as FindOptionsWhere<CapacitacionAsistente>,
});
if (!asistente) return null;
if (!asistente.asistio) {
throw new Error('No se puede evaluar a un asistente que no asistió');
}
asistente.calificacion = dto.calificacion;
asistente.aprobado = dto.aprobado;
if (dto.observaciones) {
asistente.observaciones = dto.observaciones;
}
return this.asistenteRepository.save(asistente);
}
async eliminarAsistente(asistenteId: string): Promise<boolean> {
const result = await this.asistenteRepository.delete(asistenteId);
return (result.affected || 0) > 0;
}
// ========== Matriz de Capacitación ==========
async findMatrizByPuesto(ctx: ServiceContext, puestoId: string): Promise<CapacitacionMatriz[]> {
return this.matrizRepository.find({
where: { tenantId: ctx.tenantId, puestoId } as FindOptionsWhere<CapacitacionMatriz>,
relations: ['capacitacion'],
order: { esObligatoria: 'DESC' },
});
}
async addCapacitacionToMatriz(
ctx: ServiceContext,
puestoId: string,
capacitacionId: string,
esObligatoria: boolean = true,
plazoDias: number = 30
): Promise<CapacitacionMatriz> {
const existing = await this.matrizRepository.findOne({
where: { tenantId: ctx.tenantId, puestoId, capacitacionId } as FindOptionsWhere<CapacitacionMatriz>,
});
if (existing) {
throw new Error('La capacitación ya está en la matriz para este puesto');
}
const matriz = this.matrizRepository.create({
tenantId: ctx.tenantId,
puestoId,
capacitacionId,
esObligatoria,
plazoDias,
});
return this.matrizRepository.save(matriz);
}
async removeCapacitacionFromMatriz(ctx: ServiceContext, matrizId: string): Promise<boolean> {
const matriz = await this.matrizRepository.findOne({
where: { id: matrizId, tenantId: ctx.tenantId } as FindOptionsWhere<CapacitacionMatriz>,
});
if (!matriz) return false;
await this.matrizRepository.remove(matriz);
return true;
}
// ========== Estadísticas ==========
async getStats(ctx: ServiceContext, fraccionamientoId?: string): Promise<SesionStats> {
const baseQuery = this.sesionRepository
.createQueryBuilder('s')
.where('s.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (fraccionamientoId) {
baseQuery.andWhere('s.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
}
const totalSesiones = await baseQuery.getCount();
const sesionesProgramadas = await this.sesionRepository
.createQueryBuilder('s')
.where('s.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('s.estado = :estado', { estado: 'programada' })
.andWhere(fraccionamientoId ? 's.fraccionamiento_id = :fraccionamientoId' : '1=1', { fraccionamientoId })
.getCount();
const sesionesCompletadas = await this.sesionRepository
.createQueryBuilder('s')
.where('s.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('s.estado = :estado', { estado: 'completada' })
.andWhere(fraccionamientoId ? 's.fraccionamiento_id = :fraccionamientoId' : '1=1', { fraccionamientoId })
.getCount();
const sesionesCanceladas = await this.sesionRepository
.createQueryBuilder('s')
.where('s.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('s.estado = :estado', { estado: 'cancelada' })
.andWhere(fraccionamientoId ? 's.fraccionamiento_id = :fraccionamientoId' : '1=1', { fraccionamientoId })
.getCount();
const asistentesQuery = this.asistenteRepository
.createQueryBuilder('a')
.innerJoin('a.sesion', 's')
.where('s.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (fraccionamientoId) {
asistentesQuery.andWhere('s.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
}
const totalAsistentes = await asistentesQuery.andWhere('a.asistio = true').getCount();
const asistentesAprobados = await this.asistenteRepository
.createQueryBuilder('a')
.innerJoin('a.sesion', 's')
.where('s.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('a.aprobado = true')
.andWhere(fraccionamientoId ? 's.fraccionamiento_id = :fraccionamientoId' : '1=1', { fraccionamientoId })
.getCount();
return {
totalSesiones,
sesionesProgramadas,
sesionesCompletadas,
sesionesCanceladas,
totalAsistentes,
asistentesAprobados,
};
}
// ========== Sesiones Próximas ==========
async getSesionesProximas(ctx: ServiceContext, dias: number = 7): Promise<CapacitacionSesion[]> {
const fechaLimite = new Date();
fechaLimite.setDate(fechaLimite.getDate() + dias);
return this.sesionRepository
.createQueryBuilder('s')
.leftJoinAndSelect('s.capacitacion', 'capacitacion')
.leftJoinAndSelect('s.fraccionamiento', 'fraccionamiento')
.leftJoinAndSelect('s.instructor', 'instructor')
.where('s.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('s.estado = :estado', { estado: 'programada' })
.andWhere('s.fecha_programada >= :hoy', { hoy: new Date() })
.andWhere('s.fecha_programada <= :fechaLimite', { fechaLimite })
.orderBy('s.fecha_programada', 'ASC')
.addOrderBy('s.hora_inicio', 'ASC')
.getMany();
}
}