3202 lines
79 KiB
Markdown
3202 lines
79 KiB
Markdown
# 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<EPPItem>,
|
|
@InjectRepository(EPPInventory)
|
|
private inventoryRepository: Repository<EPPInventory>
|
|
) {}
|
|
|
|
async findAll(filters?: any): Promise<EPPItem[]> {
|
|
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<EPPItem> {
|
|
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<boolean> {
|
|
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<EPPInventory[]> {
|
|
return this.inventoryRepository.find({
|
|
where: { projectId },
|
|
relations: ['eppItem', 'eppItem.category']
|
|
});
|
|
}
|
|
|
|
async getLowStockItems(projectId?: string): Promise<EPPInventory[]> {
|
|
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<EPPAssignment>,
|
|
@InjectRepository(EPPInventory)
|
|
private inventoryRepository: Repository<EPPInventory>,
|
|
private eppService: EPPService,
|
|
private notificationsService: NotificationsService
|
|
) {}
|
|
|
|
async assignEPP(dto: CreateEPPAssignmentDto, userId: string): Promise<EPPAssignment> {
|
|
// 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<EPPAssignment> {
|
|
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<EPPAssignment[]> {
|
|
return this.assignmentRepository.find({
|
|
where: {
|
|
workerId,
|
|
projectId,
|
|
status: 'assigned'
|
|
},
|
|
relations: ['eppItem', 'eppItem.category']
|
|
});
|
|
}
|
|
|
|
async getExpiringEPP(days: number = 30): Promise<EPPAssignment[]> {
|
|
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<Incident>,
|
|
@InjectRepository(IncidentInvestigation)
|
|
private investigationRepository: Repository<IncidentInvestigation>,
|
|
@InjectRepository(CorrectiveAction)
|
|
private actionRepository: Repository<CorrectiveAction>,
|
|
private notificationsService: NotificationsService,
|
|
private alertsService: AlertsService
|
|
) {}
|
|
|
|
/**
|
|
* Workflow Step 1: Reporte inicial del incidente
|
|
*/
|
|
async reportIncident(dto: CreateIncidentDto, reportedBy: string): Promise<Incident> {
|
|
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<IncidentInvestigation> {
|
|
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<IncidentInvestigation> {
|
|
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<CorrectiveAction> {
|
|
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<CorrectiveAction> {
|
|
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<CorrectiveAction> {
|
|
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<string> {
|
|
// 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<void> {
|
|
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<string> {
|
|
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<string> {
|
|
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<SafetyInspection>,
|
|
@InjectRepository(InspectionResponse)
|
|
private responseRepository: Repository<InspectionResponse>,
|
|
private notificationsService: NotificationsService
|
|
) {}
|
|
|
|
async createInspection(dto: CreateSafetyInspectionDto, inspectorId: string): Promise<SafetyInspection> {
|
|
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<SafetyInspection> {
|
|
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<string> {
|
|
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<void> {
|
|
// 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<Incident>,
|
|
@InjectRepository(SafetyInspection)
|
|
private inspectionRepository: Repository<SafetyInspection>,
|
|
@InjectRepository(TrainingCertification)
|
|
private certificationRepository: Repository<TrainingCertification>,
|
|
private pdfService: PDFService
|
|
) {}
|
|
|
|
/**
|
|
* NOM-019-STPS-2011: Reporte de accidentes y enfermedades de trabajo
|
|
*/
|
|
async generateNOM019Report(projectId: string, startDate: Date, endDate: Date): Promise<Buffer> {
|
|
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<Buffer> {
|
|
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<Buffer> {
|
|
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<Buffer> {
|
|
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<any> {
|
|
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<number> {
|
|
// 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<number> {
|
|
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<number> {
|
|
// 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<any> {
|
|
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>(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
|