# ET-SEG-001: Especificaciones Técnicas Backend - Seguridad Industrial ## 1. Información General **Módulo:** MAI-007 - Seguridad Industrial **Componente:** Backend API **Stack Tecnológico:** NestJS 10+, TypeORM **Responsable:** Equipo Backend **Fecha:** 2025-12-06 **Versión:** 1.0.0 ## 2. Arquitectura de Módulos ### 2.1. Estructura de Módulos NestJS ```typescript src/ ├── modules/ │ ├── epp/ # Equipos de Protección Personal │ │ ├── epp.module.ts │ │ ├── entities/ │ │ ├── dto/ │ │ ├── services/ │ │ └── controllers/ │ ├── safety-inspections/ # Inspecciones de Seguridad │ │ ├── safety-inspections.module.ts │ │ ├── entities/ │ │ ├── dto/ │ │ ├── services/ │ │ └── controllers/ │ ├── incidents/ # Gestión de Incidentes │ │ ├── incidents.module.ts │ │ ├── entities/ │ │ ├── dto/ │ │ ├── services/ │ │ ├── controllers/ │ │ └── workflows/ │ └── safety-trainings/ # Capacitaciones de Seguridad │ ├── safety-trainings.module.ts │ ├── entities/ │ ├── dto/ │ ├── services/ │ └── controllers/ ├── shared/ │ ├── notifications/ │ └── reporting/ └── config/ ``` ### 2.2. EPPModule - Equipos de Protección Personal ```typescript // epp.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { EPPItem, EPPAssignment, EPPInventory, EPPDelivery, EPPCategory } from './entities'; import { EPPService } from './services/epp.service'; import { EPPAssignmentService } from './services/epp-assignment.service'; import { EPPInventoryService } from './services/epp-inventory.service'; import { EPPController } from './controllers/epp.controller'; @Module({ imports: [ TypeOrmModule.forFeature([ EPPItem, EPPAssignment, EPPInventory, EPPDelivery, EPPCategory ]) ], controllers: [EPPController], providers: [ EPPService, EPPAssignmentService, EPPInventoryService ], exports: [EPPService, EPPAssignmentService] }) export class EPPModule {} ``` ### 2.3. SafetyInspectionsModule - Inspecciones de Seguridad ```typescript // safety-inspections.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { SafetyInspection, InspectionTemplate, InspectionItem, InspectionResponse, InspectionEvidence } from './entities'; import { SafetyInspectionsService } from './services/safety-inspections.service'; import { InspectionTemplatesService } from './services/inspection-templates.service'; import { SafetyInspectionsController } from './controllers/safety-inspections.controller'; @Module({ imports: [ TypeOrmModule.forFeature([ SafetyInspection, InspectionTemplate, InspectionItem, InspectionResponse, InspectionEvidence ]) ], controllers: [SafetyInspectionsController], providers: [ SafetyInspectionsService, InspectionTemplatesService ], exports: [SafetyInspectionsService] }) export class SafetyInspectionsModule {} ``` ### 2.4. IncidentsModule - Gestión de Incidentes ```typescript // incidents.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Incident, IncidentInvestigation, IncidentWitness, IncidentEvidence, CorrectiveAction, RootCauseAnalysis } from './entities'; import { IncidentsService } from './services/incidents.service'; import { IncidentInvestigationService } from './services/incident-investigation.service'; import { CorrectiveActionsService } from './services/corrective-actions.service'; import { IncidentWorkflowService } from './workflows/incident-workflow.service'; import { IncidentsController } from './controllers/incidents.controller'; @Module({ imports: [ TypeOrmModule.forFeature([ Incident, IncidentInvestigation, IncidentWitness, IncidentEvidence, CorrectiveAction, RootCauseAnalysis ]) ], controllers: [IncidentsController], providers: [ IncidentsService, IncidentInvestigationService, CorrectiveActionsService, IncidentWorkflowService ], exports: [IncidentsService, IncidentWorkflowService] }) export class IncidentsModule {} ``` ### 2.5. SafetyTrainingsModule - Capacitaciones ```typescript // safety-trainings.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { SafetyTraining, TrainingCourse, TrainingAttendance, TrainingCertification, TrainingRequirement } from './entities'; import { SafetyTrainingsService } from './services/safety-trainings.service'; import { TrainingCertificationsService } from './services/training-certifications.service'; import { SafetyTrainingsController } from './controllers/safety-trainings.controller'; @Module({ imports: [ TypeOrmModule.forFeature([ SafetyTraining, TrainingCourse, TrainingAttendance, TrainingCertification, TrainingRequirement ]) ], controllers: [SafetyTrainingsController], providers: [ SafetyTrainingsService, TrainingCertificationsService ], exports: [SafetyTrainingsService] }) export class SafetyTrainingsModule {} ``` ## 3. Entities y Modelos de Datos ### 3.1. EPP - Equipos de Protección Personal ```typescript // entities/epp-item.entity.ts import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, CreateDateColumn, UpdateDateColumn } from 'typeorm'; @Entity('epp_items') export class EPPItem { @PrimaryGeneratedColumn('uuid') id: string; @Column() code: string; @Column() name: string; @Column('text', { nullable: true }) description: string; @ManyToOne(() => EPPCategory) category: EPPCategory; @Column('uuid') categoryId: string; @Column() brand: string; @Column({ nullable: true }) model: string; @Column('jsonb', { nullable: true }) specifications: { size?: string; color?: string; material?: string; certifications?: string[]; }; @Column('decimal', { precision: 10, scale: 2 }) unitCost: number; @Column('int') usefulLifeDays: number; // Vida útil en días @Column('boolean', { default: true }) requiresCertification: boolean; @Column('jsonb', { nullable: true }) nomCompliance: { norm: string; requirement: string; validUntil?: Date; }[]; @Column('boolean', { default: true }) isActive: true; @OneToMany(() => EPPInventory, inventory => inventory.eppItem) inventory: EPPInventory[]; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; } // entities/epp-category.entity.ts @Entity('epp_categories') export class EPPCategory { @PrimaryGeneratedColumn('uuid') id: string; @Column() name: string; @Column('text', { nullable: true }) description: string; @Column({ nullable: true }) icon: string; @Column('simple-array', { nullable: true }) requiredByRoles: string[]; // Roles que requieren este EPP @Column('boolean', { default: true }) isMandatory: boolean; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; } // entities/epp-assignment.entity.ts @Entity('epp_assignments') export class EPPAssignment { @PrimaryGeneratedColumn('uuid') id: string; @Column('uuid') workerId: string; @ManyToOne(() => EPPItem) eppItem: EPPItem; @Column('uuid') eppItemId: string; @Column('uuid') projectId: string; @Column('date') assignmentDate: Date; @Column({ nullable: true }) serialNumber: string; // Para EPP individualizado @Column() quantity: number; @Column('date') expectedReturnDate: Date; @Column('date', { nullable: true }) actualReturnDate: Date; @Column({ type: 'enum', enum: ['assigned', 'in_use', 'returned', 'damaged', 'lost'], default: 'assigned' }) status: string; @Column('text', { nullable: true }) condition: string; @Column('jsonb', { nullable: true }) maintenanceHistory: { date: Date; type: string; notes: string; }[]; @Column('uuid') assignedBy: string; @Column('uuid', { nullable: true }) receivedBy: string; @Column('text', { nullable: true }) notes: string; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; } // entities/epp-inventory.entity.ts @Entity('epp_inventory') export class EPPInventory { @PrimaryGeneratedColumn('uuid') id: string; @ManyToOne(() => EPPItem, item => item.inventory) eppItem: EPPItem; @Column('uuid') eppItemId: string; @Column('uuid') warehouseId: string; @Column('uuid', { nullable: true }) projectId: string; // null = almacén central @Column('int') quantity: number; @Column('int') minimumStock: number; @Column('int') maximumStock: number; @Column('int', { default: 0 }) reserved: number; // Cantidad reservada pero no asignada @Column('date', { nullable: true }) lastRestockDate: Date; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; } // entities/epp-delivery.entity.ts @Entity('epp_deliveries') export class EPPDelivery { @PrimaryGeneratedColumn('uuid') id: string; @Column() deliveryNumber: string; @Column('uuid') workerId: string; @Column('uuid') projectId: string; @Column('jsonb') items: { eppItemId: string; quantity: number; serialNumbers?: string[]; }[]; @Column('date') deliveryDate: Date; @Column('uuid') deliveredBy: string; @Column({ nullable: true }) signatureUrl: string; // Firma digital del trabajador @Column('text', { nullable: true }) notes: string; @CreateDateColumn() createdAt: Date; } ``` ### 3.2. Safety Inspections - Inspecciones ```typescript // entities/inspection-template.entity.ts @Entity('inspection_templates') export class InspectionTemplate { @PrimaryGeneratedColumn('uuid') id: string; @Column() code: string; @Column() name: string; @Column('text', { nullable: true }) description: string; @Column({ type: 'enum', enum: ['epp', 'workplace', 'equipment', 'vehicle', 'scaffolding', 'electrical', 'general'] }) inspectionType: string; @Column('jsonb') sections: { id: string; name: string; order: number; items: { id: string; question: string; type: 'yes_no' | 'scale' | 'text' | 'numeric' | 'checklist'; required: boolean; options?: string[]; criticalItem?: boolean; // Si falla, requiere acción inmediata }[]; }[]; @Column('int') frequency: number; // Frecuencia en días @Column('simple-array', { nullable: true }) applicableRoles: string[]; @Column('boolean', { default: true }) requiresEvidence: boolean; @Column('boolean', { default: true }) isActive: boolean; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; } // entities/safety-inspection.entity.ts @Entity('safety_inspections') export class SafetyInspection { @PrimaryGeneratedColumn('uuid') id: string; @Column() inspectionNumber: string; @ManyToOne(() => InspectionTemplate) template: InspectionTemplate; @Column('uuid') templateId: string; @Column('uuid') projectId: string; @Column('uuid', { nullable: true }) inspectedAreaId: string; // Área/zona inspeccionada @Column('uuid') inspectorId: string; @Column('timestamp') inspectionDate: Date; @Column('timestamp', { nullable: true }) scheduledDate: Date; @Column({ type: 'enum', enum: ['scheduled', 'in_progress', 'completed', 'cancelled'], default: 'scheduled' }) status: string; @OneToMany(() => InspectionResponse, response => response.inspection, { cascade: true }) responses: InspectionResponse[]; @Column('decimal', { precision: 5, scale: 2, nullable: true }) overallScore: number; // Puntuación general @Column('int', { default: 0 }) criticalFindings: number; @Column('int', { default: 0 }) majorFindings: number; @Column('int', { default: 0 }) minorFindings: number; @Column('jsonb', { nullable: true }) geolocation: { latitude: number; longitude: number; accuracy: number; }; @Column('text', { nullable: true }) generalObservations: string; @Column('simple-array', { nullable: true }) attachments: string[]; // URLs de fotos/documentos @Column({ nullable: true }) signature: string; // Firma digital del inspector @Column('timestamp', { nullable: true }) submittedAt: Date; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; } // entities/inspection-response.entity.ts @Entity('inspection_responses') export class InspectionResponse { @PrimaryGeneratedColumn('uuid') id: string; @ManyToOne(() => SafetyInspection, inspection => inspection.responses) inspection: SafetyInspection; @Column('uuid') inspectionId: string; @Column() sectionId: string; @Column() itemId: string; @Column('text') response: string; // Respuesta según el tipo de pregunta @Column('boolean', { default: false }) isNonCompliant: boolean; @Column({ type: 'enum', enum: ['critical', 'major', 'minor', 'observation'], nullable: true }) severity: string; @Column('text', { nullable: true }) notes: string; @Column('simple-array', { nullable: true }) evidenceUrls: string[]; @Column('boolean', { default: false }) requiresCorrectiveAction: boolean; @CreateDateColumn() createdAt: Date; } ``` ### 3.3. Incidents - Gestión de Incidentes ```typescript // entities/incident.entity.ts @Entity('incidents') export class Incident { @PrimaryGeneratedColumn('uuid') id: string; @Column() incidentNumber: string; @Column('uuid') projectId: string; @Column({ type: 'enum', enum: ['accident', 'near_miss', 'unsafe_condition', 'property_damage', 'environmental'] }) type: string; @Column({ type: 'enum', enum: ['fatal', 'serious', 'moderate', 'minor', 'first_aid'] }) severity: string; @Column('timestamp') incidentDate: Date; @Column('time') incidentTime: string; @Column() location: string; @Column('uuid', { nullable: true }) areaId: string; @Column('text') description: string; @Column('text', { nullable: true }) immediateActions: string; @Column('jsonb', { nullable: true }) affectedPersons: { workerId: string; name: string; role: string; injuryType?: string; injuryLocation?: string; hospitalizedRequired?: boolean; }[]; @Column('uuid') reportedBy: string; @Column('timestamp') reportedAt: Date; @Column({ type: 'enum', enum: ['reported', 'investigating', 'analysis', 'corrective_actions', 'closed'], default: 'reported' }) status: string; @Column('int', { default: 0 }) workDaysLost: number; @Column('boolean', { default: false }) requiresAuthority: boolean; // Requiere notificación a autoridades @Column('boolean', { default: false }) notifiedToSTPS: boolean; // Notificado a STPS @Column('timestamp', { nullable: true }) stpsNotificationDate: Date; @Column('simple-array', { nullable: true }) evidenceUrls: string[]; @Column('jsonb', { nullable: true }) geolocation: { latitude: number; longitude: number; }; @OneToMany(() => IncidentInvestigation, investigation => investigation.incident) investigations: IncidentInvestigation[]; @OneToMany(() => IncidentWitness, witness => witness.incident) witnesses: IncidentWitness[]; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; } // entities/incident-investigation.entity.ts @Entity('incident_investigations') export class IncidentInvestigation { @PrimaryGeneratedColumn('uuid') id: string; @ManyToOne(() => Incident, incident => incident.investigations) incident: Incident; @Column('uuid') incidentId: string; @Column('uuid') investigatorId: string; @Column('date') investigationDate: Date; @Column({ type: 'enum', enum: ['initiated', 'in_progress', 'completed'], default: 'initiated' }) status: string; @Column('text', { nullable: true }) findings: string; @Column('text', { nullable: true }) contributingFactors: string; @Column('jsonb', { nullable: true }) rootCauseAnalysis: { method: '5why' | 'fishbone' | 'fta'; // Five Whys, Fishbone, Fault Tree Analysis analysis: any; rootCauses: string[]; }; @Column('text', { nullable: true }) recommendations: string; @Column('simple-array', { nullable: true }) investigationDocuments: string[]; @OneToMany(() => CorrectiveAction, action => action.investigation) correctiveActions: CorrectiveAction[]; @Column('timestamp', { nullable: true }) completedAt: Date; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; } // entities/incident-witness.entity.ts @Entity('incident_witnesses') export class IncidentWitness { @PrimaryGeneratedColumn('uuid') id: string; @ManyToOne(() => Incident, incident => incident.witnesses) incident: Incident; @Column('uuid') incidentId: string; @Column('uuid', { nullable: true }) workerId: string; // null si es testigo externo @Column() name: string; @Column() role: string; @Column({ nullable: true }) company: string; // Para subcontratistas @Column('text') statement: string; @Column('timestamp') statementDate: Date; @Column({ nullable: true }) signatureUrl: string; @Column() contactPhone: string; @CreateDateColumn() createdAt: Date; } // entities/corrective-action.entity.ts @Entity('corrective_actions') export class CorrectiveAction { @PrimaryGeneratedColumn('uuid') id: string; @ManyToOne(() => IncidentInvestigation, investigation => investigation.correctiveActions) investigation: IncidentInvestigation; @Column('uuid') investigationId: string; @Column() actionNumber: string; @Column('text') description: string; @Column({ type: 'enum', enum: ['immediate', 'corrective', 'preventive'] }) actionType: string; @Column({ type: 'enum', enum: ['critical', 'high', 'medium', 'low'] }) priority: string; @Column('uuid') responsibleId: string; @Column('date') dueDate: Date; @Column({ type: 'enum', enum: ['pending', 'in_progress', 'completed', 'verified', 'overdue'], default: 'pending' }) status: string; @Column('text', { nullable: true }) implementationNotes: string; @Column('date', { nullable: true }) completionDate: Date; @Column('uuid', { nullable: true }) verifiedBy: string; @Column('date', { nullable: true }) verificationDate: Date; @Column('text', { nullable: true }) verificationNotes: string; @Column('simple-array', { nullable: true }) evidenceUrls: string[]; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; } ``` ### 3.4. Safety Trainings - Capacitaciones ```typescript // entities/training-course.entity.ts @Entity('training_courses') export class TrainingCourse { @PrimaryGeneratedColumn('uuid') id: string; @Column() code: string; @Column() name: string; @Column('text') description: string; @Column({ type: 'enum', enum: ['safety_induction', 'epp_usage', 'emergency_response', 'first_aid', 'heights', 'confined_spaces', 'hazmat', 'electrical', 'machinery', 'nom_compliance'] }) category: string; @Column('int') durationHours: number; @Column('boolean', { default: false }) isMandatory: boolean; @Column('simple-array', { nullable: true }) requiredByRoles: string[]; @Column('int', { nullable: true }) validityMonths: number; // Meses de validez del certificado @Column('int', { nullable: true }) passingScore: number; // Calificación mínima aprobatoria @Column('jsonb', { nullable: true }) curriculum: { topics: string[]; objectives: string[]; evaluation: string; }; @Column('jsonb', { nullable: true }) nomCompliance: { norm: string; requirement: string; }[]; @Column({ nullable: true }) certificateTemplateId: string; @Column('boolean', { default: true }) isActive: boolean; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; } // entities/safety-training.entity.ts @Entity('safety_trainings') export class SafetyTraining { @PrimaryGeneratedColumn('uuid') id: string; @Column() trainingNumber: string; @ManyToOne(() => TrainingCourse) course: TrainingCourse; @Column('uuid') courseId: string; @Column('uuid', { nullable: true }) projectId: string; // null = corporativo @Column('date') trainingDate: Date; @Column('time') startTime: string; @Column('time') endTime: string; @Column() location: string; @Column('uuid') instructorId: string; @Column({ nullable: true }) externalInstructor: string; // Para instructores externos @Column('int') capacity: number; @Column({ type: 'enum', enum: ['scheduled', 'in_progress', 'completed', 'cancelled'], default: 'scheduled' }) status: string; @OneToMany(() => TrainingAttendance, attendance => attendance.training) attendances: TrainingAttendance[]; @Column('text', { nullable: true }) materials: string; // Material didáctico utilizado @Column('simple-array', { nullable: true }) attachments: string[]; @Column('text', { nullable: true }) notes: string; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; } // entities/training-attendance.entity.ts @Entity('training_attendances') export class TrainingAttendance { @PrimaryGeneratedColumn('uuid') id: string; @ManyToOne(() => SafetyTraining, training => training.attendances) training: SafetyTraining; @Column('uuid') trainingId: string; @Column('uuid') workerId: string; @Column('boolean', { default: false }) attended: boolean; @Column('int', { nullable: true }) score: number; // Calificación de evaluación @Column('boolean', { default: false }) passed: boolean; @Column({ nullable: true }) signatureUrl: string; @Column('timestamp', { nullable: true }) signedAt: Date; @Column('text', { nullable: true }) notes: string; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; } // entities/training-certification.entity.ts @Entity('training_certifications') export class TrainingCertification { @PrimaryGeneratedColumn('uuid') id: string; @Column() certificateNumber: string; @Column('uuid') workerId: string; @ManyToOne(() => TrainingCourse) course: TrainingCourse; @Column('uuid') courseId: string; @ManyToOne(() => SafetyTraining) training: SafetyTraining; @Column('uuid') trainingId: string; @Column('date') issueDate: Date; @Column('date') expirationDate: Date; @Column({ type: 'enum', enum: ['active', 'expired', 'revoked'], default: 'active' }) status: string; @Column({ nullable: true }) certificateUrl: string; // PDF del certificado @Column('uuid') issuedBy: string; @Column('text', { nullable: true }) notes: string; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; } ``` ## 4. DTOs (Data Transfer Objects) ### 4.1. EPP DTOs ```typescript // dto/create-epp-assignment.dto.ts import { IsUUID, IsDate, IsNumber, IsString, IsOptional, IsEnum, Min } from 'class-validator'; import { Type } from 'class-transformer'; export class CreateEPPAssignmentDto { @IsUUID() workerId: string; @IsUUID() eppItemId: string; @IsUUID() projectId: string; @Type(() => Date) @IsDate() assignmentDate: Date; @IsNumber() @Min(1) quantity: number; @IsOptional() @IsString() serialNumber?: string; @Type(() => Date) @IsDate() expectedReturnDate: Date; @IsOptional() @IsString() notes?: string; } // dto/epp-delivery.dto.ts export class CreateEPPDeliveryDto { @IsUUID() workerId: string; @IsUUID() projectId: string; @IsArray() @ValidateNested({ each: true }) @Type(() => EPPDeliveryItemDto) items: EPPDeliveryItemDto[]; @Type(() => Date) @IsDate() deliveryDate: Date; @IsOptional() @IsString() signatureUrl?: string; @IsOptional() @IsString() notes?: string; } class EPPDeliveryItemDto { @IsUUID() eppItemId: string; @IsNumber() @Min(1) quantity: number; @IsOptional() @IsArray() @IsString({ each: true }) serialNumbers?: string[]; } ``` ### 4.2. Inspections DTOs ```typescript // dto/create-inspection.dto.ts export class CreateSafetyInspectionDto { @IsUUID() templateId: string; @IsUUID() projectId: string; @IsOptional() @IsUUID() inspectedAreaId?: string; @Type(() => Date) @IsDate() inspectionDate: Date; @IsOptional() @Type(() => Date) @IsDate() scheduledDate?: Date; @IsOptional() @ValidateNested() @Type(() => GeolocationDto) geolocation?: GeolocationDto; } // dto/submit-inspection.dto.ts export class SubmitInspectionDto { @IsArray() @ValidateNested({ each: true }) @Type(() => InspectionResponseDto) responses: InspectionResponseDto[]; @IsOptional() @IsString() generalObservations?: string; @IsOptional() @IsArray() @IsString({ each: true }) attachments?: string[]; @IsOptional() @IsString() signature?: string; } class InspectionResponseDto { @IsString() sectionId: string; @IsString() itemId: string; @IsString() response: string; @IsOptional() @IsBoolean() isNonCompliant?: boolean; @IsOptional() @IsEnum(['critical', 'major', 'minor', 'observation']) severity?: string; @IsOptional() @IsString() notes?: string; @IsOptional() @IsArray() @IsString({ each: true }) evidenceUrls?: string[]; } ``` ### 4.3. Incidents DTOs ```typescript // dto/create-incident.dto.ts export class CreateIncidentDto { @IsUUID() projectId: string; @IsEnum(['accident', 'near_miss', 'unsafe_condition', 'property_damage', 'environmental']) type: string; @IsEnum(['fatal', 'serious', 'moderate', 'minor', 'first_aid']) severity: string; @Type(() => Date) @IsDate() incidentDate: Date; @IsString() incidentTime: string; @IsString() location: string; @IsOptional() @IsUUID() areaId?: string; @IsString() description: string; @IsOptional() @IsString() immediateActions?: string; @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => AffectedPersonDto) affectedPersons?: AffectedPersonDto[]; @IsOptional() @IsArray() @IsString({ each: true }) evidenceUrls?: string[]; @IsOptional() @ValidateNested() @Type(() => GeolocationDto) geolocation?: GeolocationDto; } class AffectedPersonDto { @IsUUID() workerId: string; @IsString() name: string; @IsString() role: string; @IsOptional() @IsString() injuryType?: string; @IsOptional() @IsString() injuryLocation?: string; @IsOptional() @IsBoolean() hospitalizedRequired?: boolean; } // dto/create-investigation.dto.ts export class CreateInvestigationDto { @IsUUID() incidentId: string; @IsUUID() investigatorId: string; @Type(() => Date) @IsDate() investigationDate: Date; @IsOptional() @IsString() findings?: string; @IsOptional() @IsString() contributingFactors?: string; } // dto/complete-investigation.dto.ts export class CompleteInvestigationDto { @IsString() findings: string; @IsString() contributingFactors: string; @ValidateNested() @Type(() => RootCauseAnalysisDto) rootCauseAnalysis: RootCauseAnalysisDto; @IsString() recommendations: string; @IsArray() @ValidateNested({ each: true }) @Type(() => CreateCorrectiveActionDto) correctiveActions: CreateCorrectiveActionDto[]; } class RootCauseAnalysisDto { @IsEnum(['5why', 'fishbone', 'fta']) method: string; @IsObject() analysis: any; @IsArray() @IsString({ each: true }) rootCauses: string[]; } // dto/create-corrective-action.dto.ts export class CreateCorrectiveActionDto { @IsString() description: string; @IsEnum(['immediate', 'corrective', 'preventive']) actionType: string; @IsEnum(['critical', 'high', 'medium', 'low']) priority: string; @IsUUID() responsibleId: string; @Type(() => Date) @IsDate() dueDate: Date; } ``` ### 4.4. Trainings DTOs ```typescript // dto/create-training.dto.ts export class CreateSafetyTrainingDto { @IsUUID() courseId: string; @IsOptional() @IsUUID() projectId?: string; @Type(() => Date) @IsDate() trainingDate: Date; @IsString() startTime: string; @IsString() endTime: string; @IsString() location: string; @IsUUID() instructorId: string; @IsOptional() @IsString() externalInstructor?: string; @IsNumber() @Min(1) capacity: number; @IsOptional() @IsString() materials?: string; @IsOptional() @IsString() notes?: string; } // dto/register-attendance.dto.ts export class RegisterAttendanceDto { @IsUUID() trainingId: string; @IsArray() @IsUUID({}, { each: true }) workerIds: string[]; } // dto/complete-attendance.dto.ts export class CompleteAttendanceDto { @IsBoolean() attended: boolean; @IsOptional() @IsNumber() @Min(0) @Max(100) score?: number; @IsOptional() @IsString() signatureUrl?: string; @IsOptional() @IsString() notes?: string; } ``` ## 5. Services - Lógica de Negocio ### 5.1. EPP Service ```typescript // services/epp.service.ts import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { EPPItem, EPPAssignment, EPPInventory } from '../entities'; @Injectable() export class EPPService { constructor( @InjectRepository(EPPItem) private eppItemRepository: Repository, @InjectRepository(EPPInventory) private inventoryRepository: Repository ) {} async findAll(filters?: any): Promise { const query = this.eppItemRepository.createQueryBuilder('epp') .leftJoinAndSelect('epp.category', 'category') .where('epp.isActive = :isActive', { isActive: true }); if (filters?.categoryId) { query.andWhere('epp.categoryId = :categoryId', { categoryId: filters.categoryId }); } if (filters?.search) { query.andWhere('(epp.name ILIKE :search OR epp.code ILIKE :search)', { search: `%${filters.search}%` }); } return query.getMany(); } async findOne(id: string): Promise { const item = await this.eppItemRepository.findOne({ where: { id }, relations: ['category'] }); if (!item) { throw new NotFoundException(`EPP item ${id} not found`); } return item; } async checkAvailability(eppItemId: string, projectId: string, quantity: number): Promise { const inventory = await this.inventoryRepository.findOne({ where: { eppItemId, projectId } }); if (!inventory) { return false; } const available = inventory.quantity - inventory.reserved; return available >= quantity; } async getInventoryByProject(projectId: string): Promise { return this.inventoryRepository.find({ where: { projectId }, relations: ['eppItem', 'eppItem.category'] }); } async getLowStockItems(projectId?: string): Promise { const query = this.inventoryRepository.createQueryBuilder('inv') .leftJoinAndSelect('inv.eppItem', 'epp') .where('inv.quantity <= inv.minimumStock'); if (projectId) { query.andWhere('inv.projectId = :projectId', { projectId }); } return query.getMany(); } } // services/epp-assignment.service.ts @Injectable() export class EPPAssignmentService { constructor( @InjectRepository(EPPAssignment) private assignmentRepository: Repository, @InjectRepository(EPPInventory) private inventoryRepository: Repository, private eppService: EPPService, private notificationsService: NotificationsService ) {} async assignEPP(dto: CreateEPPAssignmentDto, userId: string): Promise { // Verificar disponibilidad const isAvailable = await this.eppService.checkAvailability( dto.eppItemId, dto.projectId, dto.quantity ); if (!isAvailable) { throw new BadRequestException('Insufficient EPP inventory'); } // Crear asignación const assignment = this.assignmentRepository.create({ ...dto, assignedBy: userId, status: 'assigned' }); // Reducir inventario await this.inventoryRepository.decrement( { eppItemId: dto.eppItemId, projectId: dto.projectId }, 'quantity', dto.quantity ); const saved = await this.assignmentRepository.save(assignment); // Notificar al trabajador await this.notificationsService.send({ userId: dto.workerId, type: 'epp_assigned', title: 'EPP Asignado', message: `Se te ha asignado equipo de protección personal`, data: { assignmentId: saved.id } }); return saved; } async returnEPP(assignmentId: string, condition: string, userId: string): Promise { const assignment = await this.assignmentRepository.findOne({ where: { id: assignmentId } }); if (!assignment) { throw new NotFoundException('Assignment not found'); } assignment.actualReturnDate = new Date(); assignment.condition = condition; assignment.receivedBy = userId; assignment.status = condition === 'good' ? 'returned' : 'damaged'; // Si está en buenas condiciones, regresar al inventario if (condition === 'good') { await this.inventoryRepository.increment( { eppItemId: assignment.eppItemId, projectId: assignment.projectId }, 'quantity', assignment.quantity ); } return this.assignmentRepository.save(assignment); } async getWorkerEPP(workerId: string, projectId: string): Promise { return this.assignmentRepository.find({ where: { workerId, projectId, status: 'assigned' }, relations: ['eppItem', 'eppItem.category'] }); } async getExpiringEPP(days: number = 30): Promise { const futureDate = new Date(); futureDate.setDate(futureDate.getDate() + days); return this.assignmentRepository.createQueryBuilder('assignment') .leftJoinAndSelect('assignment.eppItem', 'epp') .where('assignment.status = :status', { status: 'assigned' }) .andWhere('assignment.expectedReturnDate <= :futureDate', { futureDate }) .getMany(); } } ``` ### 5.2. Incidents Workflow Service ```typescript // workflows/incident-workflow.service.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Incident, IncidentInvestigation, CorrectiveAction } from '../entities'; @Injectable() export class IncidentWorkflowService { constructor( @InjectRepository(Incident) private incidentRepository: Repository, @InjectRepository(IncidentInvestigation) private investigationRepository: Repository, @InjectRepository(CorrectiveAction) private actionRepository: Repository, private notificationsService: NotificationsService, private alertsService: AlertsService ) {} /** * Workflow Step 1: Reporte inicial del incidente */ async reportIncident(dto: CreateIncidentDto, reportedBy: string): Promise { const incidentNumber = await this.generateIncidentNumber(); const incident = this.incidentRepository.create({ ...dto, incidentNumber, reportedBy, reportedAt: new Date(), status: 'reported' }); const saved = await this.incidentRepository.save(incident); // Determinar si requiere notificación a autoridades (STPS) if (this.requiresAuthorityNotification(saved)) { saved.requiresAuthority = true; await this.incidentRepository.save(saved); // Alerta inmediata await this.alertsService.sendCriticalAlert({ type: 'incident_requires_authority', incidentId: saved.id, severity: saved.severity }); } // Notificar a responsables de seguridad await this.notifySecurityTeam(saved); // Iniciar investigación automáticamente para incidentes graves if (['fatal', 'serious'].includes(saved.severity)) { await this.initiateInvestigation(saved.id); } return saved; } /** * Workflow Step 2: Iniciar investigación */ async initiateInvestigation(incidentId: string, investigatorId?: string): Promise { const incident = await this.incidentRepository.findOne({ where: { id: incidentId } }); if (!incident) { throw new NotFoundException('Incident not found'); } // Asignar investigador automáticamente si no se especifica if (!investigatorId) { investigatorId = await this.assignInvestigator(incident); } const investigation = this.investigationRepository.create({ incidentId, investigatorId, investigationDate: new Date(), status: 'initiated' }); const saved = await this.investigationRepository.save(investigation); // Actualizar estado del incidente incident.status = 'investigating'; await this.incidentRepository.save(incident); // Notificar al investigador await this.notificationsService.send({ userId: investigatorId, type: 'investigation_assigned', title: 'Investigación de Incidente Asignada', message: `Se te ha asignado la investigación del incidente ${incident.incidentNumber}`, data: { incidentId, investigationId: saved.id }, priority: 'high' }); return saved; } /** * Workflow Step 3: Completar investigación con análisis de causa raíz */ async completeInvestigation( investigationId: string, dto: CompleteInvestigationDto ): Promise { const investigation = await this.investigationRepository.findOne({ where: { id: investigationId }, relations: ['incident'] }); if (!investigation) { throw new NotFoundException('Investigation not found'); } // Actualizar investigación Object.assign(investigation, { findings: dto.findings, contributingFactors: dto.contributingFactors, rootCauseAnalysis: dto.rootCauseAnalysis, recommendations: dto.recommendations, status: 'completed', completedAt: new Date() }); await this.investigationRepository.save(investigation); // Crear acciones correctivas for (const actionDto of dto.correctiveActions) { await this.createCorrectiveAction(investigationId, actionDto); } // Actualizar estado del incidente investigation.incident.status = 'corrective_actions'; await this.incidentRepository.save(investigation.incident); return investigation; } /** * Workflow Step 4: Crear y asignar acciones correctivas */ async createCorrectiveAction( investigationId: string, dto: CreateCorrectiveActionDto ): Promise { const actionNumber = await this.generateActionNumber(); const action = this.actionRepository.create({ ...dto, investigationId, actionNumber, status: 'pending' }); const saved = await this.actionRepository.save(action); // Notificar al responsable await this.notificationsService.send({ userId: dto.responsibleId, type: 'corrective_action_assigned', title: 'Acción Correctiva Asignada', message: `Se te ha asignado una acción correctiva: ${dto.description}`, data: { actionId: saved.id, dueDate: dto.dueDate, priority: dto.priority }, priority: dto.priority === 'critical' ? 'high' : 'normal' }); return saved; } /** * Workflow Step 5: Implementar acción correctiva */ async implementAction( actionId: string, implementationNotes: string, evidenceUrls: string[] ): Promise { const action = await this.actionRepository.findOne({ where: { id: actionId } }); if (!action) { throw new NotFoundException('Corrective action not found'); } action.status = 'completed'; action.completionDate = new Date(); action.implementationNotes = implementationNotes; action.evidenceUrls = evidenceUrls; return this.actionRepository.save(action); } /** * Workflow Step 6: Verificar acción correctiva */ async verifyAction( actionId: string, verifiedBy: string, verificationNotes: string, approved: boolean ): Promise { const action = await this.actionRepository.findOne({ where: { id: actionId }, relations: ['investigation', 'investigation.incident'] }); if (!action) { throw new NotFoundException('Corrective action not found'); } action.verifiedBy = verifiedBy; action.verificationDate = new Date(); action.verificationNotes = verificationNotes; action.status = approved ? 'verified' : 'in_progress'; await this.actionRepository.save(action); // Si todas las acciones están verificadas, cerrar el incidente const allActions = await this.actionRepository.find({ where: { investigationId: action.investigationId } }); const allVerified = allActions.every(a => a.status === 'verified'); if (allVerified) { action.investigation.incident.status = 'closed'; await this.incidentRepository.save(action.investigation.incident); } return action; } /** * Verifica si el incidente requiere notificación a autoridades según NOM-STPS */ private requiresAuthorityNotification(incident: Incident): boolean { // NOM-019-STPS-2011: Notificar incidentes fatales o con días perdidos if (incident.severity === 'fatal') { return true; } if (incident.severity === 'serious' && incident.workDaysLost > 0) { return true; } return false; } /** * Asigna investigador basado en severidad y disponibilidad */ private async assignInvestigator(incident: Incident): Promise { // Lógica para asignar investigador // Por ahora retorna un ID dummy - implementar según estructura organizacional return 'safety-manager-id'; } /** * Notifica al equipo de seguridad sobre nuevo incidente */ private async notifySecurityTeam(incident: Incident): Promise { await this.alertsService.sendToRole({ role: 'safety_manager', type: 'new_incident', title: `Nuevo Incidente: ${incident.type}`, message: `Severidad: ${incident.severity} - ${incident.location}`, data: { incidentId: incident.id }, priority: incident.severity === 'fatal' ? 'critical' : 'high' }); } private async generateIncidentNumber(): Promise { const year = new Date().getFullYear(); const count = await this.incidentRepository.count({ where: { incidentNumber: Like(`INC-${year}%`) } }); return `INC-${year}-${String(count + 1).padStart(4, '0')}`; } private async generateActionNumber(): Promise { const year = new Date().getFullYear(); const count = await this.actionRepository.count({ where: { actionNumber: Like(`AC-${year}%`) } }); return `AC-${year}-${String(count + 1).padStart(4, '0')}`; } } ``` ### 5.3. Safety Inspections Service ```typescript // services/safety-inspections.service.ts @Injectable() export class SafetyInspectionsService { constructor( @InjectRepository(SafetyInspection) private inspectionRepository: Repository, @InjectRepository(InspectionResponse) private responseRepository: Repository, private notificationsService: NotificationsService ) {} async createInspection(dto: CreateSafetyInspectionDto, inspectorId: string): Promise { const inspectionNumber = await this.generateInspectionNumber(); const inspection = this.inspectionRepository.create({ ...dto, inspectionNumber, inspectorId, status: 'in_progress' }); return this.inspectionRepository.save(inspection); } async submitInspection(inspectionId: string, dto: SubmitInspectionDto): Promise { const inspection = await this.inspectionRepository.findOne({ where: { id: inspectionId }, relations: ['template'] }); if (!inspection) { throw new NotFoundException('Inspection not found'); } // Guardar respuestas const responses = dto.responses.map(r => this.responseRepository.create({ ...r, inspectionId }) ); await this.responseRepository.save(responses); // Calcular métricas const { score, findings } = this.calculateInspectionScore(responses, inspection.template); // Actualizar inspección inspection.status = 'completed'; inspection.submittedAt = new Date(); inspection.overallScore = score; inspection.criticalFindings = findings.critical; inspection.majorFindings = findings.major; inspection.minorFindings = findings.minor; inspection.generalObservations = dto.generalObservations; inspection.attachments = dto.attachments; inspection.signature = dto.signature; const saved = await this.inspectionRepository.save(inspection); // Generar alertas para hallazgos críticos if (findings.critical > 0) { await this.notificationsService.send({ userId: 'safety-manager-id', type: 'critical_inspection_findings', title: 'Hallazgos Críticos en Inspección', message: `${findings.critical} hallazgos críticos en ${inspection.inspectionNumber}`, data: { inspectionId: saved.id }, priority: 'high' }); } return saved; } private calculateInspectionScore( responses: InspectionResponse[], template: InspectionTemplate ): { score: number; findings: { critical: number; major: number; minor: number } } { let totalPoints = 0; let earnedPoints = 0; const findings = { critical: 0, major: 0, minor: 0 }; for (const response of responses) { totalPoints += 1; if (!response.isNonCompliant) { earnedPoints += 1; } else { if (response.severity === 'critical') findings.critical++; else if (response.severity === 'major') findings.major++; else if (response.severity === 'minor') findings.minor++; } } const score = totalPoints > 0 ? (earnedPoints / totalPoints) * 100 : 0; return { score, findings }; } private async generateInspectionNumber(): Promise { const year = new Date().getFullYear(); const month = String(new Date().getMonth() + 1).padStart(2, '0'); const count = await this.inspectionRepository.count({ where: { inspectionNumber: Like(`INS-${year}${month}%`) } }); return `INS-${year}${month}-${String(count + 1).padStart(4, '0')}`; } } ``` ## 6. Controllers - Endpoints API ### 6.1. EPP Controller ```typescript // controllers/epp.controller.ts import { Controller, Get, Post, Put, Body, Param, Query, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard } from '@/guards/jwt-auth.guard'; import { RolesGuard } from '@/guards/roles.guard'; import { Roles } from '@/decorators/roles.decorator'; import { CurrentUser } from '@/decorators/current-user.decorator'; @ApiTags('EPP - Equipos de Protección Personal') @ApiBearerAuth() @UseGuards(JwtAuthGuard, RolesGuard) @Controller('safety/epp') export class EPPController { constructor( private readonly eppService: EPPService, private readonly assignmentService: EPPAssignmentService, private readonly inventoryService: EPPInventoryService ) {} @Get() @ApiOperation({ summary: 'Listar equipos EPP' }) @Roles('safety_manager', 'warehouse_manager', 'supervisor') async findAll(@Query() filters: any) { return this.eppService.findAll(filters); } @Get('inventory/:projectId') @ApiOperation({ summary: 'Obtener inventario EPP por proyecto' }) @Roles('safety_manager', 'warehouse_manager', 'supervisor') async getInventory(@Param('projectId') projectId: string) { return this.eppService.getInventoryByProject(projectId); } @Get('inventory/low-stock') @ApiOperation({ summary: 'Items con stock bajo' }) @Roles('safety_manager', 'warehouse_manager') async getLowStock(@Query('projectId') projectId?: string) { return this.eppService.getLowStockItems(projectId); } @Post('assignments') @ApiOperation({ summary: 'Asignar EPP a trabajador' }) @Roles('safety_manager', 'warehouse_manager', 'supervisor') async assignEPP( @Body() dto: CreateEPPAssignmentDto, @CurrentUser() user: any ) { return this.assignmentService.assignEPP(dto, user.id); } @Put('assignments/:id/return') @ApiOperation({ summary: 'Devolver EPP asignado' }) @Roles('safety_manager', 'warehouse_manager') async returnEPP( @Param('id') id: string, @Body() body: { condition: string }, @CurrentUser() user: any ) { return this.assignmentService.returnEPP(id, body.condition, user.id); } @Get('workers/:workerId/assignments') @ApiOperation({ summary: 'EPP asignado a trabajador - Para apps móviles' }) @Roles('worker', 'supervisor', 'safety_manager') async getWorkerEPP( @Param('workerId') workerId: string, @Query('projectId') projectId: string ) { return this.assignmentService.getWorkerEPP(workerId, projectId); } @Get('assignments/expiring') @ApiOperation({ summary: 'EPP próximo a vencer' }) @Roles('safety_manager', 'supervisor') async getExpiringEPP(@Query('days') days: number = 30) { return this.assignmentService.getExpiringEPP(days); } @Post('deliveries') @ApiOperation({ summary: 'Registrar entrega de EPP - Para MOB-003' }) @Roles('warehouse_manager', 'safety_manager') async createDelivery( @Body() dto: CreateEPPDeliveryDto, @CurrentUser() user: any ) { return this.assignmentService.createDelivery(dto, user.id); } } ``` ### 6.2. Safety Inspections Controller ```typescript // controllers/safety-inspections.controller.ts @ApiTags('Inspecciones de Seguridad') @ApiBearerAuth() @UseGuards(JwtAuthGuard, RolesGuard) @Controller('safety/inspections') export class SafetyInspectionsController { constructor( private readonly inspectionsService: SafetyInspectionsService, private readonly templatesService: InspectionTemplatesService ) {} @Get('templates') @ApiOperation({ summary: 'Listar plantillas de inspección' }) @Roles('safety_manager', 'supervisor', 'inspector') async getTemplates(@Query('type') type?: string) { return this.templatesService.findAll({ type }); } @Get('templates/:id') @ApiOperation({ summary: 'Obtener plantilla - Para apps móviles MOB-003' }) @Roles('safety_manager', 'supervisor', 'inspector') async getTemplate(@Param('id') id: string) { return this.templatesService.findOne(id); } @Post() @ApiOperation({ summary: 'Crear inspección - Desde app móvil MOB-003' }) @Roles('safety_manager', 'supervisor', 'inspector') async create( @Body() dto: CreateSafetyInspectionDto, @CurrentUser() user: any ) { return this.inspectionsService.createInspection(dto, user.id); } @Put(':id/submit') @ApiOperation({ summary: 'Enviar inspección completada - Desde app móvil' }) @Roles('safety_manager', 'supervisor', 'inspector') async submit( @Param('id') id: string, @Body() dto: SubmitInspectionDto ) { return this.inspectionsService.submitInspection(id, dto); } @Get('project/:projectId') @ApiOperation({ summary: 'Inspecciones por proyecto' }) @Roles('safety_manager', 'supervisor', 'inspector') async getByProject( @Param('projectId') projectId: string, @Query() filters: any ) { return this.inspectionsService.findByProject(projectId, filters); } @Get(':id') @ApiOperation({ summary: 'Detalle de inspección' }) @Roles('safety_manager', 'supervisor', 'inspector') async findOne(@Param('id') id: string) { return this.inspectionsService.findOne(id); } @Get('scheduled/pending') @ApiOperation({ summary: 'Inspecciones pendientes - Para MOB-003' }) @Roles('safety_manager', 'supervisor', 'inspector') async getPending(@Query('projectId') projectId: string) { return this.inspectionsService.findPending(projectId); } } ``` ### 6.3. Incidents Controller ```typescript // controllers/incidents.controller.ts @ApiTags('Incidentes y Accidentes') @ApiBearerAuth() @UseGuards(JwtAuthGuard, RolesGuard) @Controller('safety/incidents') export class IncidentsController { constructor( private readonly incidentsService: IncidentsService, private readonly workflowService: IncidentWorkflowService, private readonly investigationService: IncidentInvestigationService ) {} @Post() @ApiOperation({ summary: 'Reportar incidente - Desde app móvil MOB-004' }) @Roles('worker', 'supervisor', 'safety_manager') async report( @Body() dto: CreateIncidentDto, @CurrentUser() user: any ) { return this.workflowService.reportIncident(dto, user.id); } @Get() @ApiOperation({ summary: 'Listar incidentes' }) @Roles('safety_manager', 'supervisor') async findAll(@Query() filters: any) { return this.incidentsService.findAll(filters); } @Get(':id') @ApiOperation({ summary: 'Detalle de incidente' }) @Roles('safety_manager', 'supervisor', 'worker') async findOne(@Param('id') id: string) { return this.incidentsService.findOne(id); } @Post(':id/investigation') @ApiOperation({ summary: 'Iniciar investigación' }) @Roles('safety_manager') async initiateInvestigation( @Param('id') id: string, @Body() body: { investigatorId?: string } ) { return this.workflowService.initiateInvestigation(id, body.investigatorId); } @Put('investigations/:id/complete') @ApiOperation({ summary: 'Completar investigación con análisis' }) @Roles('safety_manager', 'investigator') async completeInvestigation( @Param('id') id: string, @Body() dto: CompleteInvestigationDto ) { return this.workflowService.completeInvestigation(id, dto); } @Post('investigations/:id/corrective-actions') @ApiOperation({ summary: 'Agregar acción correctiva' }) @Roles('safety_manager') async addCorrectiveAction( @Param('id') investigationId: string, @Body() dto: CreateCorrectiveActionDto ) { return this.workflowService.createCorrectiveAction(investigationId, dto); } @Put('corrective-actions/:id/implement') @ApiOperation({ summary: 'Implementar acción correctiva' }) @Roles('supervisor', 'safety_manager') async implementAction( @Param('id') id: string, @Body() body: { implementationNotes: string; evidenceUrls: string[] } ) { return this.workflowService.implementAction( id, body.implementationNotes, body.evidenceUrls ); } @Put('corrective-actions/:id/verify') @ApiOperation({ summary: 'Verificar acción correctiva' }) @Roles('safety_manager') async verifyAction( @Param('id') id: string, @Body() body: { verificationNotes: string; approved: boolean }, @CurrentUser() user: any ) { return this.workflowService.verifyAction( id, user.id, body.verificationNotes, body.approved ); } @Get('project/:projectId/statistics') @ApiOperation({ summary: 'Estadísticas de incidentes por proyecto' }) @Roles('safety_manager', 'supervisor') async getStatistics(@Param('projectId') projectId: string) { return this.incidentsService.getStatistics(projectId); } @Post(':id/witnesses') @ApiOperation({ summary: 'Agregar testigo a incidente' }) @Roles('safety_manager', 'investigator') async addWitness( @Param('id') id: string, @Body() dto: CreateWitnessDto ) { return this.incidentsService.addWitness(id, dto); } } ``` ### 6.4. Safety Trainings Controller ```typescript // controllers/safety-trainings.controller.ts @ApiTags('Capacitaciones de Seguridad') @ApiBearerAuth() @UseGuards(JwtAuthGuard, RolesGuard) @Controller('safety/trainings') export class SafetyTrainingsController { constructor( private readonly trainingsService: SafetyTrainingsService, private readonly certificationsService: TrainingCertificationsService ) {} @Get('courses') @ApiOperation({ summary: 'Listar cursos de capacitación' }) async getCourses(@Query() filters: any) { return this.trainingsService.getCourses(filters); } @Post() @ApiOperation({ summary: 'Programar capacitación' }) @Roles('safety_manager', 'hr_manager') async schedule(@Body() dto: CreateSafetyTrainingDto) { return this.trainingsService.scheduleTraining(dto); } @Get() @ApiOperation({ summary: 'Listar capacitaciones' }) @Roles('safety_manager', 'supervisor') async findAll(@Query() filters: any) { return this.trainingsService.findAll(filters); } @Get(':id') @ApiOperation({ summary: 'Detalle de capacitación' }) async findOne(@Param('id') id: string) { return this.trainingsService.findOne(id); } @Post(':id/attendances') @ApiOperation({ summary: 'Registrar asistentes' }) @Roles('safety_manager', 'instructor') async registerAttendees( @Param('id') id: string, @Body() dto: RegisterAttendanceDto ) { return this.trainingsService.registerAttendances(id, dto); } @Put('attendances/:id') @ApiOperation({ summary: 'Completar asistencia y calificación' }) @Roles('safety_manager', 'instructor') async completeAttendance( @Param('id') id: string, @Body() dto: CompleteAttendanceDto ) { return this.trainingsService.completeAttendance(id, dto); } @Post(':id/complete') @ApiOperation({ summary: 'Finalizar capacitación y generar certificados' }) @Roles('safety_manager') async completeTraining(@Param('id') id: string) { return this.trainingsService.completeTraining(id); } @Get('workers/:workerId/certifications') @ApiOperation({ summary: 'Certificaciones de trabajador - Para MOB-003/MOB-004' }) async getWorkerCertifications(@Param('workerId') workerId: string) { return this.certificationsService.getWorkerCertifications(workerId); } @Get('workers/:workerId/expired') @ApiOperation({ summary: 'Certificaciones vencidas o próximas a vencer' }) @Roles('safety_manager', 'hr_manager') async getExpiredCertifications( @Param('workerId') workerId: string, @Query('days') days: number = 30 ) { return this.certificationsService.getExpiring(workerId, days); } @Get('project/:projectId/compliance') @ApiOperation({ summary: 'Cumplimiento de capacitaciones por proyecto' }) @Roles('safety_manager', 'supervisor') async getComplianceReport(@Param('projectId') projectId: string) { return this.trainingsService.getComplianceReport(projectId); } } ``` ## 7. Sistema de Alertas y Notificaciones ### 7.1. Alerts Service ```typescript // shared/alerts/alerts.service.ts import { Injectable } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; @Injectable() export class AlertsService { constructor( private eventEmitter: EventEmitter2, private notificationsService: NotificationsService, private emailService: EmailService, private smsService: SMSService ) {} /** * Envía alerta crítica (incidentes fatales, condiciones peligrosas) */ async sendCriticalAlert(data: { type: string; incidentId?: string; inspectionId?: string; severity: string; description?: string; }): Promise { // Notificación push await this.notificationsService.sendToRole({ role: 'safety_manager', type: data.type, title: 'ALERTA CRÍTICA DE SEGURIDAD', message: this.buildAlertMessage(data), data, priority: 'critical' }); // Email await this.emailService.sendToRole({ role: 'safety_manager', subject: 'ALERTA CRÍTICA DE SEGURIDAD', template: 'critical-safety-alert', data }); // SMS para incidentes fatales if (data.severity === 'fatal') { await this.smsService.sendToRole({ role: 'safety_manager', message: this.buildSMSAlert(data) }); } // Emitir evento para integraciones this.eventEmitter.emit('safety.critical-alert', data); } /** * Alertas de EPP */ async alertLowStock(inventory: EPPInventory): Promise { await this.notificationsService.sendToRole({ role: 'warehouse_manager', type: 'epp_low_stock', title: 'Stock Bajo de EPP', message: `${inventory.eppItem.name}: ${inventory.quantity} unidades`, data: { inventoryId: inventory.id }, priority: 'normal' }); } async alertExpiringEPP(assignments: EPPAssignment[]): Promise { for (const assignment of assignments) { await this.notificationsService.send({ userId: assignment.workerId, type: 'epp_expiring', title: 'EPP Próximo a Vencer', message: `Tu ${assignment.eppItem.name} debe devolverse el ${assignment.expectedReturnDate}`, data: { assignmentId: assignment.id }, priority: 'normal' }); } } /** * Alertas de inspecciones */ async alertOverdueInspection(inspection: SafetyInspection): Promise { await this.notificationsService.send({ userId: inspection.inspectorId, type: 'inspection_overdue', title: 'Inspección Pendiente', message: `Inspección ${inspection.inspectionNumber} está vencida`, data: { inspectionId: inspection.id }, priority: 'high' }); } /** * Alertas de acciones correctivas */ async alertOverdueAction(action: CorrectiveAction): Promise { await this.notificationsService.send({ userId: action.responsibleId, type: 'action_overdue', title: 'Acción Correctiva Vencida', message: `${action.description} - Vencimiento: ${action.dueDate}`, data: { actionId: action.id }, priority: 'high' }); // Notificar también al supervisor await this.notificationsService.sendToRole({ role: 'safety_manager', type: 'action_overdue_escalation', title: 'Acción Correctiva Vencida', message: `Acción ${action.actionNumber} está vencida`, data: { actionId: action.id }, priority: 'high' }); } /** * Alertas de capacitaciones */ async alertExpiringCertifications(certifications: TrainingCertification[]): Promise { for (const cert of certifications) { await this.notificationsService.send({ userId: cert.workerId, type: 'certification_expiring', title: 'Certificación Próxima a Vencer', message: `Tu certificación ${cert.course.name} vence el ${cert.expirationDate}`, data: { certificationId: cert.id }, priority: 'normal' }); } } private buildAlertMessage(data: any): string { const messages = { 'incident_requires_authority': 'Incidente que requiere notificación a STPS', 'critical_inspection_findings': 'Hallazgos críticos en inspección', 'unsafe_condition': 'Condición insegura detectada' }; return messages[data.type] || 'Alerta de seguridad'; } private buildSMSAlert(data: any): string { return `ALERTA CRÍTICA: ${data.type} - Severidad: ${data.severity}. Revisar sistema inmediatamente.`; } } ``` ### 7.2. Scheduled Jobs para Alertas ```typescript // shared/alerts/alert-jobs.service.ts import { Injectable } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; @Injectable() export class AlertJobsService { constructor( private alertsService: AlertsService, private eppService: EPPService, private certificationsService: TrainingCertificationsService, private actionsService: CorrectiveActionsService ) {} /** * Diario: Verificar EPP bajo en stock */ @Cron(CronExpression.EVERY_DAY_AT_8AM) async checkLowStock(): Promise { const lowStock = await this.eppService.getLowStockItems(); for (const item of lowStock) { await this.alertsService.alertLowStock(item); } } /** * Diario: Verificar EPP próximo a vencer */ @Cron(CronExpression.EVERY_DAY_AT_9AM) async checkExpiringEPP(): Promise { const expiring = await this.eppService.getExpiringEPP(7); // 7 días if (expiring.length > 0) { await this.alertsService.alertExpiringEPP(expiring); } } /** * Diario: Verificar certificaciones próximas a vencer */ @Cron(CronExpression.EVERY_DAY_AT_10AM) async checkExpiringCertifications(): Promise { const expiring = await this.certificationsService.getExpiringCertifications(30); if (expiring.length > 0) { await this.alertsService.alertExpiringCertifications(expiring); } } /** * Diario: Verificar acciones correctivas vencidas */ @Cron(CronExpression.EVERY_DAY_AT_11AM) async checkOverdueActions(): Promise { const overdue = await this.actionsService.getOverdueActions(); for (const action of overdue) { await this.alertsService.alertOverdueAction(action); } } } ``` ## 8. Reportes para Cumplimiento NOM-STPS ### 8.1. Reports Service ```typescript // shared/reporting/safety-reports.service.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, Between } from 'typeorm'; @Injectable() export class SafetyReportsService { constructor( @InjectRepository(Incident) private incidentRepository: Repository, @InjectRepository(SafetyInspection) private inspectionRepository: Repository, @InjectRepository(TrainingCertification) private certificationRepository: Repository, private pdfService: PDFService ) {} /** * NOM-019-STPS-2011: Reporte de accidentes y enfermedades de trabajo */ async generateNOM019Report(projectId: string, startDate: Date, endDate: Date): Promise { const incidents = await this.incidentRepository.find({ where: { projectId, incidentDate: Between(startDate, endDate), type: 'accident' }, relations: ['investigations', 'investigations.correctiveActions'] }); const data = { period: { startDate, endDate }, totalIncidents: incidents.length, byType: this.groupIncidentsByType(incidents), bySeverity: this.groupIncidentsBySeverity(incidents), workDaysLost: incidents.reduce((sum, i) => sum + i.workDaysLost, 0), incidentRate: this.calculateIncidentRate(incidents, projectId), severityRate: this.calculateSeverityRate(incidents, projectId), details: incidents.map(i => ({ incidentNumber: i.incidentNumber, date: i.incidentDate, type: i.type, severity: i.severity, location: i.location, description: i.description, workDaysLost: i.workDaysLost, investigated: i.investigations.length > 0, correctiveActions: i.investigations[0]?.correctiveActions.length || 0 })) }; return this.pdfService.generate('nom-019-report', data); } /** * NOM-030-STPS-2009: Servicios preventivos de seguridad y salud */ async generateNOM030Report(projectId: string, year: number): Promise { const startDate = new Date(year, 0, 1); const endDate = new Date(year, 11, 31); const [inspections, trainings, incidents] = await Promise.all([ this.inspectionRepository.count({ where: { projectId, inspectionDate: Between(startDate, endDate) } }), this.certificationRepository .createQueryBuilder('cert') .where('cert.issueDate BETWEEN :startDate AND :endDate', { startDate, endDate }) .getCount(), this.incidentRepository.count({ where: { projectId, incidentDate: Between(startDate, endDate) } }) ]); const data = { year, preventiveActivities: { inspections, trainings, incidents }, complianceStatus: this.calculateComplianceStatus(projectId), recommendations: await this.generateRecommendations(projectId) }; return this.pdfService.generate('nom-030-report', data); } /** * Reporte de capacitaciones impartidas */ async generateTrainingReport(projectId: string, startDate: Date, endDate: Date): Promise { const trainings = await this.trainingRepository.find({ where: { projectId, trainingDate: Between(startDate, endDate), status: 'completed' }, relations: ['course', 'attendances'] }); const data = { period: { startDate, endDate }, totalTrainings: trainings.length, totalAttendees: trainings.reduce((sum, t) => sum + t.attendances.length, 0), byCategory: this.groupTrainingsByCategory(trainings), certificationIssued: trainings.reduce((sum, t) => sum + t.attendances.filter(a => a.passed).length, 0 ), details: trainings.map(t => ({ trainingNumber: t.trainingNumber, course: t.course.name, date: t.trainingDate, attendees: t.attendances.length, approved: t.attendances.filter(a => a.passed).length, averageScore: this.calculateAverageScore(t.attendances) })) }; return this.pdfService.generate('training-report', data); } /** * Reporte de inspecciones de seguridad */ async generateInspectionReport(projectId: string, startDate: Date, endDate: Date): Promise { const inspections = await this.inspectionRepository.find({ where: { projectId, inspectionDate: Between(startDate, endDate), status: 'completed' }, relations: ['template', 'responses'] }); const data = { period: { startDate, endDate }, totalInspections: inspections.length, averageScore: this.calculateAverageScore(inspections.map(i => ({ score: i.overallScore }))), findings: { critical: inspections.reduce((sum, i) => sum + i.criticalFindings, 0), major: inspections.reduce((sum, i) => sum + i.majorFindings, 0), minor: inspections.reduce((sum, i) => sum + i.minorFindings, 0) }, byType: this.groupInspectionsByType(inspections), details: inspections.map(i => ({ inspectionNumber: i.inspectionNumber, date: i.inspectionDate, type: i.template.inspectionType, score: i.overallScore, findings: i.criticalFindings + i.majorFindings + i.minorFindings })) }; return this.pdfService.generate('inspection-report', data); } /** * Dashboard ejecutivo de seguridad */ async getExecutiveDashboard(projectId: string, month: number, year: number): Promise { const startDate = new Date(year, month - 1, 1); const endDate = new Date(year, month, 0); const [incidents, inspections, trainings] = await Promise.all([ this.incidentRepository.count({ where: { projectId, incidentDate: Between(startDate, endDate) } }), this.inspectionRepository.count({ where: { projectId, inspectionDate: Between(startDate, endDate), status: 'completed' } }), this.trainingRepository.count({ where: { projectId, trainingDate: Between(startDate, endDate), status: 'completed' } }) ]); return { period: { month, year }, kpis: { incidentRate: await this.calculateIncidentRate(projectId, startDate, endDate), inspectionScore: await this.getAverageInspectionScore(projectId, startDate, endDate), trainingCompliance: await this.getTrainingCompliance(projectId), zeroIncidentDays: await this.getZeroIncidentDays(projectId, startDate, endDate) }, summary: { incidents, inspections, trainings }, trends: await this.getTrends(projectId, year) }; } // Métodos auxiliares private groupIncidentsByType(incidents: Incident[]) { return incidents.reduce((acc, i) => { acc[i.type] = (acc[i.type] || 0) + 1; return acc; }, {}); } private groupIncidentsBySeverity(incidents: Incident[]) { return incidents.reduce((acc, i) => { acc[i.severity] = (acc[i.severity] || 0) + 1; return acc; }, {}); } private async calculateIncidentRate(projectId: string, startDate: Date, endDate: Date): Promise { // Tasa de incidentes = (Número de incidentes / Horas trabajadas) * 200,000 // 200,000 = 100 trabajadores trabajando 40 horas/semana durante 50 semanas const incidents = await this.incidentRepository.count({ where: { projectId, incidentDate: Between(startDate, endDate) } }); // Obtener horas trabajadas del proyecto (desde módulo de asistencia) const hoursWorked = await this.getProjectHoursWorked(projectId, startDate, endDate); return hoursWorked > 0 ? (incidents / hoursWorked) * 200000 : 0; } private async calculateSeverityRate(projectId: string, startDate: Date, endDate: Date): Promise { const incidents = await this.incidentRepository.find({ where: { projectId, incidentDate: Between(startDate, endDate) } }); const totalDaysLost = incidents.reduce((sum, i) => sum + i.workDaysLost, 0); const hoursWorked = await this.getProjectHoursWorked(projectId, startDate, endDate); return hoursWorked > 0 ? (totalDaysLost / hoursWorked) * 200000 : 0; } private async getProjectHoursWorked(projectId: string, startDate: Date, endDate: Date): Promise { // Integración con módulo de asistencia/nómina // Por ahora retornar valor estimado return 10000; // Placeholder } private calculateAverageScore(items: any[]): number { if (items.length === 0) return 0; const sum = items.reduce((acc, item) => acc + (item.score || 0), 0); return sum / items.length; } } ``` ## 9. Configuración y Buenas Prácticas ### 9.1. Validación Global ```typescript // main.ts import { ValidationPipe } from '@nestjs/common'; app.useGlobalPipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true, transformOptions: { enableImplicitConversion: true } }) ); ``` ### 9.2. Exception Filters ```typescript // filters/safety-exception.filter.ts import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common'; @Catch() export class SafetyExceptionFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest(); const status = exception instanceof HttpException ? exception.getStatus() : 500; // Log crítico para errores en módulo de seguridad if (request.path.includes('/safety/')) { console.error('SAFETY MODULE ERROR:', { path: request.path, method: request.method, error: exception }); } response.status(status).json({ statusCode: status, timestamp: new Date().toISOString(), path: request.url, message: exception instanceof HttpException ? exception.message : 'Internal server error' }); } } ``` ### 9.3. Interceptor de Auditoría ```typescript // interceptors/audit.interceptor.ts import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; @Injectable() export class AuditInterceptor implements NestInterceptor { constructor(private auditService: AuditService) {} intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); const { method, url, user, body } = request; // Auditar operaciones críticas de seguridad const criticalOperations = [ '/safety/incidents', '/safety/inspections', '/safety/epp/assignments' ]; const isCritical = criticalOperations.some(op => url.includes(op)); return next.handle().pipe( tap((data) => { if (isCritical) { this.auditService.log({ module: 'safety', action: method, endpoint: url, userId: user?.id, data: body, timestamp: new Date() }); } }) ); } } ``` ## 10. Pruebas Unitarias ### 10.1. EPP Service Test ```typescript // epp.service.spec.ts import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { EPPService } from './epp.service'; import { EPPItem, EPPInventory } from './entities'; describe('EPPService', () => { let service: EPPService; let eppItemRepository: any; let inventoryRepository: any; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ EPPService, { provide: getRepositoryToken(EPPItem), useValue: { find: jest.fn(), findOne: jest.fn(), create: jest.fn(), save: jest.fn() } }, { provide: getRepositoryToken(EPPInventory), useValue: { find: jest.fn(), findOne: jest.fn(), decrement: jest.fn() } } ] }).compile(); service = module.get(EPPService); eppItemRepository = module.get(getRepositoryToken(EPPItem)); inventoryRepository = module.get(getRepositoryToken(EPPInventory)); }); it('should be defined', () => { expect(service).toBeDefined(); }); describe('checkAvailability', () => { it('should return true if sufficient inventory', async () => { inventoryRepository.findOne.mockResolvedValue({ quantity: 100, reserved: 20 }); const result = await service.checkAvailability('epp-id', 'project-id', 50); expect(result).toBe(true); }); it('should return false if insufficient inventory', async () => { inventoryRepository.findOne.mockResolvedValue({ quantity: 100, reserved: 90 }); const result = await service.checkAvailability('epp-id', 'project-id', 50); expect(result).toBe(false); }); }); }); ``` ## 11. Documentación API ### 11.1. Swagger Configuration ```typescript // main.ts import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; const config = new DocumentBuilder() .setTitle('API de Seguridad Industrial') .setDescription('Módulo de gestión de seguridad para construcción') .setVersion('1.0') .addTag('EPP', 'Equipos de Protección Personal') .addTag('Inspecciones', 'Inspecciones de Seguridad') .addTag('Incidentes', 'Gestión de Incidentes y Accidentes') .addTag('Capacitaciones', 'Capacitaciones de Seguridad') .addBearerAuth() .build(); const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('api/docs/safety', app, document); ``` ## 12. Notas de Implementación ### 12.1. Prioridades de Desarrollo 1. **Fase 1 - Core (Sprint 1-2)** - Entities y DTOs básicos - EPP Module (asignación y devolución) - Incidents Module (reporte básico) - Endpoints para apps móviles MOB-003 y MOB-004 2. **Fase 2 - Workflows (Sprint 3-4)** - Workflow completo de investigación de incidentes - Safety Inspections Module - Sistema de alertas básico 3. **Fase 3 - Avanzado (Sprint 5-6)** - Safety Trainings Module - Reportes NOM-STPS - Dashboard ejecutivo ### 12.2. Integraciones Requeridas - **Módulo de RH**: Obtener datos de trabajadores - **Módulo de Proyectos**: Validar proyectos y áreas - **Módulo de Asistencia**: Calcular horas trabajadas para métricas - **Storage Service**: Almacenar fotos, firmas digitales, PDFs - **Notification Service**: Push notifications para apps móviles ### 12.3. Consideraciones de Seguridad - Encriptar firmas digitales - Logs de auditoría para todas las operaciones - Backup diario de incidentes y evidencias - Restricción de eliminación de registros (soft delete) --- **Documento:** ET-SEG-001-backend.md **Versión:** 1.0.0 **Última Actualización:** 2025-12-06