2988 lines
76 KiB
Markdown
2988 lines
76 KiB
Markdown
# ET-OBRA-001: Especificaciones Técnicas Backend - Control de Obra
|
||
|
||
**Módulo:** MAI-005 - Control de Obra
|
||
**Componente:** Backend (NestJS)
|
||
**Versión:** 1.0.0
|
||
**Fecha:** 2025-12-06
|
||
**Estado:** Documentado
|
||
|
||
---
|
||
|
||
## Tabla de Contenidos
|
||
|
||
1. [Resumen Ejecutivo](#resumen-ejecutivo)
|
||
2. [Arquitectura del Backend](#arquitectura-del-backend)
|
||
3. [Módulos NestJS](#módulos-nestjs)
|
||
4. [Modelos y Entidades](#modelos-y-entidades)
|
||
5. [Servicios de Negocio](#servicios-de-negocio)
|
||
6. [Controladores y API REST](#controladores-y-api-rest)
|
||
7. [WebSocket Gateway](#websocket-gateway)
|
||
8. [Procesamiento Asíncrono](#procesamiento-asíncrono)
|
||
9. [Geolocalización con PostGIS](#geolocalización-con-postgis)
|
||
10. [Procesamiento de Imágenes](#procesamiento-de-imágenes)
|
||
11. [Autenticación y Autorización](#autenticación-y-autorización)
|
||
12. [Validaciones y DTOs](#validaciones-y-dtos)
|
||
13. [Testing](#testing)
|
||
14. [Deployment](#deployment)
|
||
|
||
---
|
||
|
||
## Resumen Ejecutivo
|
||
|
||
Esta especificación detalla la implementación del backend para el módulo de Control de Obra (MAI-005), construido sobre **NestJS 10+**, **TypeORM**, **PostgreSQL 15+** con **PostGIS**, **Sharp** para procesamiento de imágenes, **BullMQ** para trabajos asíncronos y **Socket.io** para comunicación en tiempo real.
|
||
|
||
### Características Principales
|
||
|
||
- **4 Módulos Core:** Progress, WorkLog, Estimations, Resources
|
||
- **PostGIS:** Geolocalización con tipo `POINT` para validación de ubicación
|
||
- **Algoritmos Avanzados:** CPM (Critical Path Method), EVM (Earned Value Management), Curva S
|
||
- **API Móvil:** Endpoints optimizados para app MOB-003 (Supervisor de Obra)
|
||
- **WebSocket:** Dashboard en tiempo real con actualizaciones push
|
||
- **Procesamiento de Imágenes:** Sharp para marca de agua, thumbnails y optimización
|
||
- **Sincronización Offline:** Cola de trabajos con BullMQ para sincronización desde móvil
|
||
- **Hash y Firma Digital:** SHA256 para integridad de evidencias
|
||
|
||
---
|
||
|
||
## Arquitectura del Backend
|
||
|
||
### Estructura de Directorios
|
||
|
||
```
|
||
src/
|
||
├── modules/
|
||
│ ├── progress/ # Módulo de avances físicos
|
||
│ │ ├── dto/
|
||
│ │ ├── entities/
|
||
│ │ ├── services/
|
||
│ │ ├── controllers/
|
||
│ │ ├── processors/
|
||
│ │ └── progress.module.ts
|
||
│ │
|
||
│ ├── work-log/ # Módulo de bitácora de obra
|
||
│ │ ├── dto/
|
||
│ │ ├── entities/
|
||
│ │ ├── services/
|
||
│ │ ├── controllers/
|
||
│ │ └── work-log.module.ts
|
||
│ │
|
||
│ ├── estimations/ # Módulo de estimaciones (CPM/EVM)
|
||
│ │ ├── dto/
|
||
│ │ ├── entities/
|
||
│ │ ├── services/
|
||
│ │ │ ├── cpm-calculator.service.ts
|
||
│ │ │ ├── evm-calculator.service.ts
|
||
│ │ │ └── s-curve.service.ts
|
||
│ │ ├── controllers/
|
||
│ │ └── estimations.module.ts
|
||
│ │
|
||
│ ├── resources/ # Módulo de recursos (fotos, docs)
|
||
│ │ ├── dto/
|
||
│ │ ├── entities/
|
||
│ │ ├── services/
|
||
│ │ │ ├── image-processor.service.ts
|
||
│ │ │ ├── hash.service.ts
|
||
│ │ │ └── storage.service.ts
|
||
│ │ ├── controllers/
|
||
│ │ └── resources.module.ts
|
||
│ │
|
||
│ ├── dashboard/ # Módulo de dashboard y métricas
|
||
│ │ ├── dto/
|
||
│ │ ├── services/
|
||
│ │ ├── controllers/
|
||
│ │ ├── gateways/
|
||
│ │ │ └── dashboard.gateway.ts
|
||
│ │ └── dashboard.module.ts
|
||
│ │
|
||
│ └── shared/ # Módulos compartidos
|
||
│ ├── database/
|
||
│ ├── config/
|
||
│ └── utils/
|
||
│
|
||
├── common/
|
||
│ ├── decorators/
|
||
│ ├── guards/
|
||
│ ├── interceptors/
|
||
│ ├── filters/
|
||
│ └── pipes/
|
||
│
|
||
├── config/
|
||
│ ├── database.config.ts
|
||
│ ├── storage.config.ts
|
||
│ └── websocket.config.ts
|
||
│
|
||
└── main.ts
|
||
```
|
||
|
||
### Stack Tecnológico
|
||
|
||
```yaml
|
||
# Core
|
||
framework: NestJS 10.3+
|
||
runtime: Node.js 20 LTS
|
||
language: TypeScript 5.3+
|
||
|
||
# Base de Datos
|
||
database: PostgreSQL 15+
|
||
orm: TypeORM 0.3+
|
||
extensions: PostGIS 3.4+
|
||
migrations: TypeORM CLI
|
||
|
||
# Procesamiento Asíncrono
|
||
queue: BullMQ 5.0+
|
||
cache: Redis 7.2+
|
||
scheduler: @nestjs/schedule
|
||
|
||
# Comunicación
|
||
websocket: Socket.io 4.6+
|
||
api_validation: class-validator 0.14+
|
||
api_transform: class-transformer 0.5+
|
||
|
||
# Procesamiento de Archivos
|
||
image_processing: Sharp 0.33+
|
||
exif_reader: exifr 7.1+
|
||
hashing: crypto (Node.js nativo)
|
||
pdf_generation: PDFKit 0.14+
|
||
excel_export: ExcelJS 4.4+
|
||
|
||
# Storage
|
||
storage: AWS SDK v3 / Google Cloud Storage
|
||
cdn: CloudFront / Cloud CDN
|
||
|
||
# Observabilidad
|
||
logging: Winston 3.11+
|
||
monitoring: @nestjs/terminus (health checks)
|
||
apm: Elastic APM / New Relic
|
||
|
||
# Testing
|
||
testing_framework: Jest 29+
|
||
e2e_testing: Supertest
|
||
mocking: @nestjs/testing
|
||
```
|
||
|
||
---
|
||
|
||
## Módulos NestJS
|
||
|
||
### 1. ProgressModule
|
||
|
||
**Responsabilidad:** Gestión de avances físicos, aprobaciones y sincronización offline.
|
||
|
||
```typescript
|
||
// src/modules/progress/progress.module.ts
|
||
|
||
import { Module } from '@nestjs/common';
|
||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||
import { BullModule } from '@nestjs/bull';
|
||
|
||
import { ProgressRecord } from './entities/progress-record.entity';
|
||
import { UnitProgress } from './entities/unit-progress.entity';
|
||
import { OfflineSyncQueue } from './entities/offline-sync-queue.entity';
|
||
import { ApprovalWorkflow } from './entities/approval-workflow.entity';
|
||
|
||
import { ProgressService } from './services/progress.service';
|
||
import { ProgressApprovalService } from './services/progress-approval.service';
|
||
import { ProgressSyncService } from './services/progress-sync.service';
|
||
|
||
import { ProgressController } from './controllers/progress.controller';
|
||
import { ProgressMobileController } from './controllers/progress-mobile.controller';
|
||
|
||
import { ProgressSyncProcessor } from './processors/progress-sync.processor';
|
||
|
||
@Module({
|
||
imports: [
|
||
TypeOrmModule.forFeature([
|
||
ProgressRecord,
|
||
UnitProgress,
|
||
OfflineSyncQueue,
|
||
ApprovalWorkflow,
|
||
]),
|
||
BullModule.registerQueue({
|
||
name: 'progress-sync',
|
||
}),
|
||
],
|
||
controllers: [ProgressController, ProgressMobileController],
|
||
providers: [
|
||
ProgressService,
|
||
ProgressApprovalService,
|
||
ProgressSyncService,
|
||
ProgressSyncProcessor,
|
||
],
|
||
exports: [ProgressService],
|
||
})
|
||
export class ProgressModule {}
|
||
```
|
||
|
||
**Servicios Principales:**
|
||
|
||
- `ProgressService`: CRUD de avances, validaciones
|
||
- `ProgressApprovalService`: Workflow de aprobación multinivel
|
||
- `ProgressSyncService`: Sincronización desde app móvil offline
|
||
|
||
**Controladores:**
|
||
|
||
- `ProgressController`: API principal para web
|
||
- `ProgressMobileController`: API optimizada para móvil (MOB-003)
|
||
|
||
### 2. WorkLogModule
|
||
|
||
**Responsabilidad:** Bitácora digital de obra con multimedia geolocalizada.
|
||
|
||
```typescript
|
||
// src/modules/work-log/work-log.module.ts
|
||
|
||
import { Module } from '@nestjs/common';
|
||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||
|
||
import { WorkLogEntry } from './entities/work-log-entry.entity';
|
||
import { WorkLogAttachment } from './entities/work-log-attachment.entity';
|
||
|
||
import { WorkLogService } from './services/work-log.service';
|
||
import { WorkLogController } from './controllers/work-log.controller';
|
||
|
||
import { ResourcesModule } from '../resources/resources.module';
|
||
|
||
@Module({
|
||
imports: [
|
||
TypeOrmModule.forFeature([WorkLogEntry, WorkLogAttachment]),
|
||
ResourcesModule,
|
||
],
|
||
controllers: [WorkLogController],
|
||
providers: [WorkLogService],
|
||
exports: [WorkLogService],
|
||
})
|
||
export class WorkLogModule {}
|
||
```
|
||
|
||
**Funcionalidades:**
|
||
|
||
- Registro de eventos diarios: Avance, Incidencia, Clima, Visita
|
||
- Adjuntos multimedia: Fotos, videos, grabaciones de voz
|
||
- Geolocalización automática con PostGIS
|
||
- Timeline visual por proyecto
|
||
- Exportación a PDF
|
||
|
||
### 3. EstimationsModule
|
||
|
||
**Responsabilidad:** Programación CPM, cálculo de Curva S y métricas EVM.
|
||
|
||
```typescript
|
||
// src/modules/estimations/estimations.module.ts
|
||
|
||
import { Module } from '@nestjs/common';
|
||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||
import { ScheduleModule as NestScheduleModule } from '@nestjs/schedule';
|
||
|
||
import { Schedule } from './entities/schedule.entity';
|
||
import { ScheduleActivity } from './entities/schedule-activity.entity';
|
||
import { Milestone } from './entities/milestone.entity';
|
||
import { SCurveSnapshot } from './entities/s-curve-snapshot.entity';
|
||
|
||
import { CPMCalculatorService } from './services/cpm-calculator.service';
|
||
import { EVMCalculatorService } from './services/evm-calculator.service';
|
||
import { SCurveService } from './services/s-curve.service';
|
||
import { ScheduleService } from './services/schedule.service';
|
||
|
||
import { ScheduleController } from './controllers/schedule.controller';
|
||
import { SCurveController } from './controllers/s-curve.controller';
|
||
|
||
@Module({
|
||
imports: [
|
||
TypeOrmModule.forFeature([
|
||
Schedule,
|
||
ScheduleActivity,
|
||
Milestone,
|
||
SCurveSnapshot,
|
||
]),
|
||
NestScheduleModule.forRoot(),
|
||
],
|
||
controllers: [ScheduleController, SCurveController],
|
||
providers: [
|
||
ScheduleService,
|
||
CPMCalculatorService,
|
||
EVMCalculatorService,
|
||
SCurveService,
|
||
],
|
||
exports: [ScheduleService, EVMCalculatorService],
|
||
})
|
||
export class EstimationsModule {}
|
||
```
|
||
|
||
**Servicios Clave:**
|
||
|
||
- `CPMCalculatorService`: Algoritmo Critical Path Method
|
||
- `EVMCalculatorService`: Cálculo de PV, EV, AC, SPI, CPI, EAC, VAC
|
||
- `SCurveService`: Generación de snapshots diarios y gráficas
|
||
- `ScheduleService`: CRUD de cronogramas y actividades
|
||
|
||
### 4. ResourcesModule
|
||
|
||
**Responsabilidad:** Gestión de fotos, documentos, checklists y evidencias.
|
||
|
||
```typescript
|
||
// src/modules/resources/resources.module.ts
|
||
|
||
import { Module } from '@nestjs/common';
|
||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||
import { BullModule } from '@nestjs/bull';
|
||
|
||
import { Photo } from './entities/photo.entity';
|
||
import { QualityChecklist } from './entities/quality-checklist.entity';
|
||
import { ChecklistTemplate } from './entities/checklist-template.entity';
|
||
import { NonConformity } from './entities/non-conformity.entity';
|
||
import { PhotoAlbum } from './entities/photo-album.entity';
|
||
|
||
import { ImageProcessorService } from './services/image-processor.service';
|
||
import { HashService } from './services/hash.service';
|
||
import { StorageService } from './services/storage.service';
|
||
import { ExifService } from './services/exif.service';
|
||
import { ChecklistService } from './services/checklist.service';
|
||
|
||
import { PhotoController } from './controllers/photo.controller';
|
||
import { ChecklistController } from './controllers/checklist.controller';
|
||
|
||
import { ImageProcessor } from './processors/image.processor';
|
||
|
||
@Module({
|
||
imports: [
|
||
TypeOrmModule.forFeature([
|
||
Photo,
|
||
QualityChecklist,
|
||
ChecklistTemplate,
|
||
NonConformity,
|
||
PhotoAlbum,
|
||
]),
|
||
BullModule.registerQueue({
|
||
name: 'image-processing',
|
||
}),
|
||
],
|
||
controllers: [PhotoController, ChecklistController],
|
||
providers: [
|
||
ImageProcessorService,
|
||
HashService,
|
||
StorageService,
|
||
ExifService,
|
||
ChecklistService,
|
||
ImageProcessor,
|
||
],
|
||
exports: [ImageProcessorService, StorageService, ChecklistService],
|
||
})
|
||
export class ResourcesModule {}
|
||
```
|
||
|
||
**Procesamiento de Imágenes:**
|
||
|
||
- Marca de agua inmutable con Sharp
|
||
- Generación de thumbnails 300x225px
|
||
- Extracción de metadatos EXIF
|
||
- Compresión JPEG calidad 85%
|
||
- Hash SHA256 para integridad
|
||
|
||
---
|
||
|
||
## Modelos y Entidades
|
||
|
||
### Entidades con PostGIS
|
||
|
||
#### ProgressRecord (Registro de Avance)
|
||
|
||
```typescript
|
||
// src/modules/progress/entities/progress-record.entity.ts
|
||
|
||
import {
|
||
Entity,
|
||
PrimaryGeneratedColumn,
|
||
Column,
|
||
ManyToOne,
|
||
OneToMany,
|
||
CreateDateColumn,
|
||
UpdateDateColumn,
|
||
Index,
|
||
} from 'typeorm';
|
||
import { Point } from 'geojson';
|
||
|
||
@Entity('progress_records')
|
||
@Index(['projectId', 'createdAt'])
|
||
@Index(['status', 'createdAt'])
|
||
export class ProgressRecord {
|
||
@PrimaryGeneratedColumn('uuid')
|
||
id: string;
|
||
|
||
@Column('uuid')
|
||
projectId: string;
|
||
|
||
@Column('uuid')
|
||
activityId: string;
|
||
|
||
@Column('uuid')
|
||
unitId: string;
|
||
|
||
@Column({ type: 'varchar', length: 50 })
|
||
captureMode: 'percentage' | 'quantity' | 'unit';
|
||
|
||
@Column({ type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||
previousPercent: number;
|
||
|
||
@Column({ type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||
currentPercent: number;
|
||
|
||
@Column({ type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||
previousQuantity: number;
|
||
|
||
@Column({ type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||
currentQuantity: number;
|
||
|
||
@Column({ type: 'varchar', length: 20 })
|
||
unit: string; // m³, m², pza, etc.
|
||
|
||
@Column({ type: 'text', nullable: true })
|
||
notes: string;
|
||
|
||
// Geolocalización con PostGIS
|
||
@Column({
|
||
type: 'geography',
|
||
spatialFeatureType: 'Point',
|
||
srid: 4326,
|
||
nullable: true,
|
||
})
|
||
location: Point;
|
||
|
||
@Column({ type: 'decimal', precision: 10, scale: 6, nullable: true })
|
||
latitude: number;
|
||
|
||
@Column({ type: 'decimal', precision: 10, scale: 6, nullable: true })
|
||
longitude: number;
|
||
|
||
@Column({ type: 'decimal', precision: 8, scale: 2, nullable: true })
|
||
altitude: number;
|
||
|
||
@Column({ type: 'boolean', default: false })
|
||
isLocationValidated: boolean;
|
||
|
||
@Column({ type: 'decimal', precision: 8, scale: 2, nullable: true })
|
||
distanceFromSite: number; // en metros
|
||
|
||
@Column({ type: 'varchar', length: 50 })
|
||
status: 'pending' | 'reviewed' | 'approved' | 'rejected';
|
||
|
||
@Column({ type: 'uuid', nullable: true })
|
||
capturedBy: string;
|
||
|
||
@Column({ type: 'uuid', nullable: true })
|
||
reviewedBy: string;
|
||
|
||
@Column({ type: 'uuid', nullable: true })
|
||
approvedBy: string;
|
||
|
||
@Column({ type: 'timestamp', nullable: true })
|
||
capturedAt: Date;
|
||
|
||
@Column({ type: 'timestamp', nullable: true })
|
||
reviewedAt: Date;
|
||
|
||
@Column({ type: 'timestamp', nullable: true })
|
||
approvedAt: Date;
|
||
|
||
@Column({ type: 'text', nullable: true })
|
||
rejectionReason: string;
|
||
|
||
@Column({ type: 'varchar', length: 100, nullable: true })
|
||
deviceId: string;
|
||
|
||
@Column({ type: 'boolean', default: false })
|
||
isOfflineSync: boolean;
|
||
|
||
@Column({ type: 'uuid', nullable: true })
|
||
offlineSyncId: string;
|
||
|
||
@CreateDateColumn()
|
||
createdAt: Date;
|
||
|
||
@UpdateDateColumn()
|
||
updatedAt: Date;
|
||
|
||
@OneToMany(() => Photo, (photo) => photo.progressRecord)
|
||
photos: Photo[];
|
||
}
|
||
```
|
||
|
||
#### Photo (Evidencia Fotográfica)
|
||
|
||
```typescript
|
||
// src/modules/resources/entities/photo.entity.ts
|
||
|
||
import {
|
||
Entity,
|
||
PrimaryGeneratedColumn,
|
||
Column,
|
||
ManyToOne,
|
||
CreateDateColumn,
|
||
Index,
|
||
} from 'typeorm';
|
||
import { Point } from 'geojson';
|
||
|
||
@Entity('photos')
|
||
@Index(['projectId', 'createdAt'])
|
||
@Index(['sha256Hash'], { unique: true })
|
||
export class Photo {
|
||
@PrimaryGeneratedColumn('uuid')
|
||
id: string;
|
||
|
||
@Column('uuid')
|
||
projectId: string;
|
||
|
||
@Column({ type: 'uuid', nullable: true })
|
||
progressRecordId: string;
|
||
|
||
@Column({ type: 'uuid', nullable: true })
|
||
checklistId: string;
|
||
|
||
@Column({ type: 'uuid', nullable: true })
|
||
workLogEntryId: string;
|
||
|
||
@Column({ type: 'varchar', length: 255 })
|
||
originalFilename: string;
|
||
|
||
@Column({ type: 'varchar', length: 500 })
|
||
storageUrl: string;
|
||
|
||
@Column({ type: 'varchar', length: 500 })
|
||
thumbnailUrl: string;
|
||
|
||
@Column({ type: 'varchar', length: 64 })
|
||
sha256Hash: string;
|
||
|
||
@Column({ type: 'int' })
|
||
fileSizeBytes: number;
|
||
|
||
@Column({ type: 'varchar', length: 50 })
|
||
mimeType: string;
|
||
|
||
@Column({ type: 'int' })
|
||
width: number;
|
||
|
||
@Column({ type: 'int' })
|
||
height: number;
|
||
|
||
@Column({ type: 'boolean', default: true })
|
||
hasWatermark: boolean;
|
||
|
||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||
watermarkText: string;
|
||
|
||
// Geolocalización con PostGIS
|
||
@Column({
|
||
type: 'geography',
|
||
spatialFeatureType: 'Point',
|
||
srid: 4326,
|
||
nullable: true,
|
||
})
|
||
location: Point;
|
||
|
||
@Column({ type: 'decimal', precision: 10, scale: 6, nullable: true })
|
||
latitude: number;
|
||
|
||
@Column({ type: 'decimal', precision: 10, scale: 6, nullable: true })
|
||
longitude: number;
|
||
|
||
@Column({ type: 'decimal', precision: 8, scale: 2, nullable: true })
|
||
altitude: number;
|
||
|
||
// Metadatos EXIF
|
||
@Column({ type: 'jsonb', nullable: true })
|
||
exifData: {
|
||
device?: string;
|
||
make?: string;
|
||
model?: string;
|
||
dateTimeOriginal?: string;
|
||
orientation?: number;
|
||
flash?: string;
|
||
iso?: number;
|
||
exposureTime?: string;
|
||
fNumber?: number;
|
||
focalLength?: number;
|
||
gpsLatitude?: number;
|
||
gpsLongitude?: number;
|
||
gpsAltitude?: number;
|
||
};
|
||
|
||
@Column({ type: 'uuid', nullable: true })
|
||
uploadedBy: string;
|
||
|
||
@Column({ type: 'timestamp', nullable: true })
|
||
capturedAt: Date;
|
||
|
||
@CreateDateColumn()
|
||
uploadedAt: Date;
|
||
|
||
@ManyToOne(() => ProgressRecord, (pr) => pr.photos)
|
||
progressRecord: ProgressRecord;
|
||
}
|
||
```
|
||
|
||
#### ScheduleActivity (Actividad del Cronograma)
|
||
|
||
```typescript
|
||
// src/modules/estimations/entities/schedule-activity.entity.ts
|
||
|
||
import {
|
||
Entity,
|
||
PrimaryGeneratedColumn,
|
||
Column,
|
||
ManyToOne,
|
||
CreateDateColumn,
|
||
UpdateDateColumn,
|
||
Index,
|
||
} from 'typeorm';
|
||
|
||
@Entity('schedule_activities')
|
||
@Index(['scheduleId', 'wbs'])
|
||
@Index(['scheduleId', 'isCriticalPath'])
|
||
export class ScheduleActivity {
|
||
@PrimaryGeneratedColumn('uuid')
|
||
id: string;
|
||
|
||
@Column('uuid')
|
||
scheduleId: string;
|
||
|
||
@Column({ type: 'varchar', length: 50 })
|
||
wbs: string; // Work Breakdown Structure code
|
||
|
||
@Column({ type: 'varchar', length: 255 })
|
||
name: string;
|
||
|
||
@Column({ type: 'text', nullable: true })
|
||
description: string;
|
||
|
||
@Column({ type: 'int' })
|
||
durationDays: number;
|
||
|
||
@Column({ type: 'date' })
|
||
plannedStartDate: Date;
|
||
|
||
@Column({ type: 'date' })
|
||
plannedFinishDate: Date;
|
||
|
||
@Column({ type: 'date', nullable: true })
|
||
actualStartDate: Date;
|
||
|
||
@Column({ type: 'date', nullable: true })
|
||
actualFinishDate: Date;
|
||
|
||
// Campos CPM
|
||
@Column({ type: 'int', default: 0 })
|
||
earliestStart: number; // ES (días desde inicio del proyecto)
|
||
|
||
@Column({ type: 'int', default: 0 })
|
||
earliestFinish: number; // EF
|
||
|
||
@Column({ type: 'int', default: 0 })
|
||
latestStart: number; // LS
|
||
|
||
@Column({ type: 'int', default: 0 })
|
||
latestFinish: number; // LF
|
||
|
||
@Column({ type: 'int', default: 0 })
|
||
totalFloat: number; // Holgura total
|
||
|
||
@Column({ type: 'int', default: 0 })
|
||
freeFloat: number; // Holgura libre
|
||
|
||
@Column({ type: 'boolean', default: false })
|
||
isCriticalPath: boolean;
|
||
|
||
@Column({ type: 'jsonb', default: [] })
|
||
predecessors: Array<{
|
||
activityId: string;
|
||
type: 'FS' | 'SS' | 'FF' | 'SF'; // Finish-Start, Start-Start, Finish-Finish, Start-Finish
|
||
lag: number; // días de retraso/adelanto
|
||
}>;
|
||
|
||
@Column({ type: 'decimal', precision: 5, scale: 2, default: 0 })
|
||
percentComplete: number;
|
||
|
||
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||
budgetedCost: number; // Para EVM (Planned Value)
|
||
|
||
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||
actualCost: number; // Para EVM (Actual Cost)
|
||
|
||
@Column({ type: 'varchar', length: 50, default: 'not_started' })
|
||
status: 'not_started' | 'in_progress' | 'completed' | 'on_hold' | 'cancelled';
|
||
|
||
@CreateDateColumn()
|
||
createdAt: Date;
|
||
|
||
@UpdateDateColumn()
|
||
updatedAt: Date;
|
||
|
||
@ManyToOne(() => Schedule, (schedule) => schedule.activities)
|
||
schedule: Schedule;
|
||
}
|
||
```
|
||
|
||
#### SCurveSnapshot (Snapshot de Curva S)
|
||
|
||
```typescript
|
||
// src/modules/estimations/entities/s-curve-snapshot.entity.ts
|
||
|
||
import {
|
||
Entity,
|
||
PrimaryGeneratedColumn,
|
||
Column,
|
||
ManyToOne,
|
||
CreateDateColumn,
|
||
Index,
|
||
} from 'typeorm';
|
||
|
||
@Entity('s_curve_snapshots')
|
||
@Index(['scheduleId', 'snapshotDate'], { unique: true })
|
||
export class SCurveSnapshot {
|
||
@PrimaryGeneratedColumn('uuid')
|
||
id: string;
|
||
|
||
@Column('uuid')
|
||
scheduleId: string;
|
||
|
||
@Column({ type: 'date' })
|
||
snapshotDate: Date;
|
||
|
||
@Column({ type: 'int' })
|
||
dayNumber: number; // Día desde inicio del proyecto
|
||
|
||
// Porcentajes
|
||
@Column({ type: 'decimal', precision: 5, scale: 2 })
|
||
plannedPercent: number; // % planificado acumulado
|
||
|
||
@Column({ type: 'decimal', precision: 5, scale: 2 })
|
||
actualPercent: number; // % real acumulado
|
||
|
||
// EVM - Valores monetarios
|
||
@Column({ type: 'decimal', precision: 12, scale: 2 })
|
||
plannedValue: number; // PV
|
||
|
||
@Column({ type: 'decimal', precision: 12, scale: 2 })
|
||
earnedValue: number; // EV
|
||
|
||
@Column({ type: 'decimal', precision: 12, scale: 2 })
|
||
actualCost: number; // AC
|
||
|
||
// EVM - Índices
|
||
@Column({ type: 'decimal', precision: 5, scale: 3 })
|
||
spi: number; // Schedule Performance Index = EV / PV
|
||
|
||
@Column({ type: 'decimal', precision: 5, scale: 3 })
|
||
cpi: number; // Cost Performance Index = EV / AC
|
||
|
||
// EVM - Varianzas
|
||
@Column({ type: 'decimal', precision: 12, scale: 2 })
|
||
scheduleVariance: number; // SV = EV - PV
|
||
|
||
@Column({ type: 'decimal', precision: 12, scale: 2 })
|
||
costVariance: number; // CV = EV - AC
|
||
|
||
// EVM - Proyecciones
|
||
@Column({ type: 'decimal', precision: 12, scale: 2 })
|
||
estimateAtCompletion: number; // EAC = BAC / CPI
|
||
|
||
@Column({ type: 'decimal', precision: 12, scale: 2 })
|
||
estimateToComplete: number; // ETC = EAC - AC
|
||
|
||
@Column({ type: 'decimal', precision: 12, scale: 2 })
|
||
varianceAtCompletion: number; // VAC = BAC - EAC
|
||
|
||
@Column({ type: 'decimal', precision: 12, scale: 2 })
|
||
budgetAtCompletion: number; // BAC (presupuesto total)
|
||
|
||
@Column({ type: 'boolean', default: false })
|
||
isManual: boolean; // true si fue creado manualmente, false si es automático (CRON)
|
||
|
||
@CreateDateColumn()
|
||
createdAt: Date;
|
||
|
||
@ManyToOne(() => Schedule, (schedule) => schedule.snapshots)
|
||
schedule: Schedule;
|
||
}
|
||
```
|
||
|
||
#### QualityChecklist (Checklist de Calidad)
|
||
|
||
```typescript
|
||
// src/modules/resources/entities/quality-checklist.entity.ts
|
||
|
||
import {
|
||
Entity,
|
||
PrimaryGeneratedColumn,
|
||
Column,
|
||
ManyToOne,
|
||
OneToMany,
|
||
CreateDateColumn,
|
||
UpdateDateColumn,
|
||
Index,
|
||
} from 'typeorm';
|
||
import { Point } from 'geojson';
|
||
|
||
@Entity('quality_checklists')
|
||
@Index(['projectId', 'createdAt'])
|
||
export class QualityChecklist {
|
||
@PrimaryGeneratedColumn('uuid')
|
||
id: string;
|
||
|
||
@Column('uuid')
|
||
projectId: string;
|
||
|
||
@Column('uuid')
|
||
templateId: string;
|
||
|
||
@Column({ type: 'uuid', nullable: true })
|
||
unitId: string;
|
||
|
||
@Column({ type: 'varchar', length: 255 })
|
||
title: string;
|
||
|
||
@Column({ type: 'jsonb' })
|
||
items: Array<{
|
||
id: string;
|
||
type: 'boolean' | 'numeric' | 'text' | 'photo';
|
||
question: string;
|
||
answer?: boolean | number | string;
|
||
photoId?: string;
|
||
isConform: boolean;
|
||
tolerance?: { min: number; max: number };
|
||
reference?: string;
|
||
}>;
|
||
|
||
@Column({ type: 'decimal', precision: 5, scale: 2 })
|
||
compliancePercent: number; // (Items conformes / Total items) × 100
|
||
|
||
@Column({ type: 'varchar', length: 50 })
|
||
complianceLevel: 'critical' | 'warning' | 'good'; // Rojo <80%, Amarillo 80-94%, Verde ≥95%
|
||
|
||
// Firma digital
|
||
@Column({ type: 'text', nullable: true })
|
||
signatureData: string; // Base64 del canvas
|
||
|
||
@Column({ type: 'uuid', nullable: true })
|
||
signedBy: string;
|
||
|
||
@Column({ type: 'timestamp', nullable: true })
|
||
signedAt: Date;
|
||
|
||
@Column({ type: 'varchar', length: 64, nullable: true })
|
||
documentHash: string; // SHA256 del documento completo
|
||
|
||
@Column({ type: 'varchar', length: 500, nullable: true })
|
||
pdfUrl: string;
|
||
|
||
// Geolocalización
|
||
@Column({
|
||
type: 'geography',
|
||
spatialFeatureType: 'Point',
|
||
srid: 4326,
|
||
nullable: true,
|
||
})
|
||
location: Point;
|
||
|
||
@Column({ type: 'uuid' })
|
||
inspectorId: string;
|
||
|
||
@Column({ type: 'varchar', length: 50, default: 'draft' })
|
||
status: 'draft' | 'completed' | 'signed';
|
||
|
||
@CreateDateColumn()
|
||
createdAt: Date;
|
||
|
||
@UpdateDateColumn()
|
||
updatedAt: Date;
|
||
|
||
@OneToMany(() => NonConformity, (nc) => nc.checklist)
|
||
nonConformities: NonConformity[];
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Servicios de Negocio
|
||
|
||
### CPMCalculatorService (Algoritmo Critical Path Method)
|
||
|
||
```typescript
|
||
// src/modules/estimations/services/cpm-calculator.service.ts
|
||
|
||
import { Injectable, Logger } from '@nestjs/common';
|
||
import { InjectRepository } from '@nestjs/typeorm';
|
||
import { Repository } from 'typeorm';
|
||
import { ScheduleActivity } from '../entities/schedule-activity.entity';
|
||
|
||
interface CPMResult {
|
||
criticalPath: string[]; // IDs de actividades en ruta crítica
|
||
projectDuration: number; // Duración total del proyecto
|
||
activities: Map<string, ActivityTimes>;
|
||
}
|
||
|
||
interface ActivityTimes {
|
||
ES: number; // Earliest Start
|
||
EF: number; // Earliest Finish
|
||
LS: number; // Latest Start
|
||
LF: number; // Latest Finish
|
||
TF: number; // Total Float
|
||
FF: number; // Free Float
|
||
isCritical: boolean;
|
||
}
|
||
|
||
@Injectable()
|
||
export class CPMCalculatorService {
|
||
private readonly logger = new Logger(CPMCalculatorService.name);
|
||
|
||
constructor(
|
||
@InjectRepository(ScheduleActivity)
|
||
private readonly activityRepo: Repository<ScheduleActivity>,
|
||
) {}
|
||
|
||
async calculateCPM(scheduleId: string): Promise<CPMResult> {
|
||
this.logger.log(`Calculating CPM for schedule ${scheduleId}`);
|
||
|
||
const activities = await this.activityRepo.find({
|
||
where: { scheduleId },
|
||
order: { wbs: 'ASC' },
|
||
});
|
||
|
||
if (activities.length === 0) {
|
||
throw new Error('No activities found for schedule');
|
||
}
|
||
|
||
// Construir grafo de dependencias
|
||
const graph = this.buildDependencyGraph(activities);
|
||
|
||
// Forward Pass: Calcular ES y EF
|
||
const forwardPass = this.forwardPass(graph, activities);
|
||
|
||
// Backward Pass: Calcular LS y LF
|
||
const backwardPass = this.backwardPass(graph, activities, forwardPass);
|
||
|
||
// Calcular holguras y ruta crítica
|
||
const result = this.calculateFloatsAndCriticalPath(
|
||
activities,
|
||
forwardPass,
|
||
backwardPass,
|
||
);
|
||
|
||
// Actualizar entidades en base de datos
|
||
await this.updateActivitiesWithCPM(activities, result.activities);
|
||
|
||
this.logger.log(
|
||
`CPM calculation completed. Critical path: ${result.criticalPath.length} activities, Project duration: ${result.projectDuration} days`,
|
||
);
|
||
|
||
return result;
|
||
}
|
||
|
||
private buildDependencyGraph(
|
||
activities: ScheduleActivity[],
|
||
): Map<string, Set<string>> {
|
||
const graph = new Map<string, Set<string>>();
|
||
|
||
activities.forEach((activity) => {
|
||
if (!graph.has(activity.id)) {
|
||
graph.set(activity.id, new Set());
|
||
}
|
||
|
||
activity.predecessors.forEach((pred) => {
|
||
if (pred.type === 'FS') {
|
||
// Finish-to-Start (más común)
|
||
if (!graph.has(pred.activityId)) {
|
||
graph.set(pred.activityId, new Set());
|
||
}
|
||
graph.get(pred.activityId).add(activity.id);
|
||
}
|
||
// TODO: Implementar otros tipos de dependencias (SS, FF, SF)
|
||
});
|
||
});
|
||
|
||
return graph;
|
||
}
|
||
|
||
private forwardPass(
|
||
graph: Map<string, Set<string>>,
|
||
activities: ScheduleActivity[],
|
||
): Map<string, { ES: number; EF: number }> {
|
||
const times = new Map<string, { ES: number; EF: number }>();
|
||
const activityMap = new Map(activities.map((a) => [a.id, a]));
|
||
|
||
// Ordenamiento topológico para procesar actividades en orden
|
||
const sorted = this.topologicalSort(graph, activities);
|
||
|
||
sorted.forEach((activityId) => {
|
||
const activity = activityMap.get(activityId);
|
||
let ES = 0;
|
||
|
||
// ES = max(EF de predecesores) + lag
|
||
activity.predecessors.forEach((pred) => {
|
||
const predTimes = times.get(pred.activityId);
|
||
if (predTimes) {
|
||
ES = Math.max(ES, predTimes.EF + pred.lag);
|
||
}
|
||
});
|
||
|
||
const EF = ES + activity.durationDays;
|
||
|
||
times.set(activityId, { ES, EF });
|
||
});
|
||
|
||
return times;
|
||
}
|
||
|
||
private backwardPass(
|
||
graph: Map<string, Set<string>>,
|
||
activities: ScheduleActivity[],
|
||
forwardPass: Map<string, { ES: number; EF: number }>,
|
||
): Map<string, { LS: number; LF: number }> {
|
||
const times = new Map<string, { LS: number; LF: number }>();
|
||
const activityMap = new Map(activities.map((a) => [a.id, a]));
|
||
|
||
// Encontrar duración máxima del proyecto
|
||
let projectDuration = 0;
|
||
forwardPass.forEach(({ EF }) => {
|
||
projectDuration = Math.max(projectDuration, EF);
|
||
});
|
||
|
||
// Ordenamiento topológico inverso
|
||
const sorted = this.topologicalSort(graph, activities).reverse();
|
||
|
||
sorted.forEach((activityId) => {
|
||
const activity = activityMap.get(activityId);
|
||
const successors = graph.get(activityId) || new Set();
|
||
|
||
let LF = projectDuration;
|
||
|
||
// Si tiene sucesores, LF = min(LS de sucesores) - lag
|
||
if (successors.size > 0) {
|
||
LF = Number.MAX_SAFE_INTEGER;
|
||
successors.forEach((successorId) => {
|
||
const successor = activityMap.get(successorId);
|
||
const successorTimes = times.get(successorId);
|
||
|
||
if (successorTimes) {
|
||
const pred = successor.predecessors.find(
|
||
(p) => p.activityId === activityId,
|
||
);
|
||
const lag = pred ? pred.lag : 0;
|
||
LF = Math.min(LF, successorTimes.LS - lag);
|
||
}
|
||
});
|
||
}
|
||
|
||
const LS = LF - activity.durationDays;
|
||
|
||
times.set(activityId, { LS, LF });
|
||
});
|
||
|
||
return times;
|
||
}
|
||
|
||
private calculateFloatsAndCriticalPath(
|
||
activities: ScheduleActivity[],
|
||
forwardPass: Map<string, { ES: number; EF: number }>,
|
||
backwardPass: Map<string, { LS: number; LF: number }>,
|
||
): CPMResult {
|
||
const activityTimes = new Map<string, ActivityTimes>();
|
||
const criticalPath: string[] = [];
|
||
let projectDuration = 0;
|
||
|
||
activities.forEach((activity) => {
|
||
const forward = forwardPass.get(activity.id);
|
||
const backward = backwardPass.get(activity.id);
|
||
|
||
const ES = forward.ES;
|
||
const EF = forward.EF;
|
||
const LS = backward.LS;
|
||
const LF = backward.LF;
|
||
|
||
const TF = LF - EF; // Total Float
|
||
const FF = 0; // Free Float (simplificado, requiere análisis de sucesores)
|
||
|
||
const isCritical = TF === 0;
|
||
|
||
activityTimes.set(activity.id, {
|
||
ES,
|
||
EF,
|
||
LS,
|
||
LF,
|
||
TF,
|
||
FF,
|
||
isCritical,
|
||
});
|
||
|
||
if (isCritical) {
|
||
criticalPath.push(activity.id);
|
||
}
|
||
|
||
projectDuration = Math.max(projectDuration, EF);
|
||
});
|
||
|
||
return {
|
||
criticalPath,
|
||
projectDuration,
|
||
activities: activityTimes,
|
||
};
|
||
}
|
||
|
||
private topologicalSort(
|
||
graph: Map<string, Set<string>>,
|
||
activities: ScheduleActivity[],
|
||
): string[] {
|
||
const sorted: string[] = [];
|
||
const visited = new Set<string>();
|
||
const activityMap = new Map(activities.map((a) => [a.id, a]));
|
||
|
||
const visit = (activityId: string) => {
|
||
if (visited.has(activityId)) return;
|
||
|
||
visited.add(activityId);
|
||
|
||
const activity = activityMap.get(activityId);
|
||
activity.predecessors.forEach((pred) => {
|
||
visit(pred.activityId);
|
||
});
|
||
|
||
sorted.push(activityId);
|
||
};
|
||
|
||
activities.forEach((activity) => {
|
||
visit(activity.id);
|
||
});
|
||
|
||
return sorted;
|
||
}
|
||
|
||
private async updateActivitiesWithCPM(
|
||
activities: ScheduleActivity[],
|
||
times: Map<string, ActivityTimes>,
|
||
): Promise<void> {
|
||
const updates = activities.map((activity) => {
|
||
const t = times.get(activity.id);
|
||
return this.activityRepo.update(activity.id, {
|
||
earliestStart: t.ES,
|
||
earliestFinish: t.EF,
|
||
latestStart: t.LS,
|
||
latestFinish: t.LF,
|
||
totalFloat: t.TF,
|
||
freeFloat: t.FF,
|
||
isCriticalPath: t.isCritical,
|
||
});
|
||
});
|
||
|
||
await Promise.all(updates);
|
||
}
|
||
}
|
||
```
|
||
|
||
### EVMCalculatorService (Earned Value Management)
|
||
|
||
```typescript
|
||
// src/modules/estimations/services/evm-calculator.service.ts
|
||
|
||
import { Injectable, Logger } from '@nestjs/common';
|
||
import { InjectRepository } from '@nestjs/typeorm';
|
||
import { Repository } from 'typeorm';
|
||
import { ScheduleActivity } from '../entities/schedule-activity.entity';
|
||
import { Schedule } from '../entities/schedule.entity';
|
||
|
||
export interface EVMMetrics {
|
||
// Valores
|
||
BAC: number; // Budget at Completion
|
||
PV: number; // Planned Value
|
||
EV: number; // Earned Value
|
||
AC: number; // Actual Cost
|
||
|
||
// Varianzas
|
||
SV: number; // Schedule Variance = EV - PV
|
||
CV: number; // Cost Variance = EV - AC
|
||
|
||
// Índices
|
||
SPI: number; // Schedule Performance Index = EV / PV
|
||
CPI: number; // Cost Performance Index = EV / AC
|
||
|
||
// Proyecciones
|
||
EAC: number; // Estimate at Completion = BAC / CPI
|
||
ETC: number; // Estimate to Complete = EAC - AC
|
||
VAC: number; // Variance at Completion = BAC - EAC
|
||
|
||
// Porcentajes
|
||
percentComplete: number; // % físico = EV / BAC
|
||
percentSchedule: number; // % programado = PV / BAC
|
||
|
||
// Clasificación
|
||
spiStatus: 'ahead' | 'on_track' | 'behind'; // >1.05, 0.95-1.05, <0.95
|
||
cpiStatus: 'under_budget' | 'on_budget' | 'over_budget';
|
||
}
|
||
|
||
@Injectable()
|
||
export class EVMCalculatorService {
|
||
private readonly logger = new Logger(EVMCalculatorService.name);
|
||
|
||
constructor(
|
||
@InjectRepository(ScheduleActivity)
|
||
private readonly activityRepo: Repository<ScheduleActivity>,
|
||
@InjectRepository(Schedule)
|
||
private readonly scheduleRepo: Repository<Schedule>,
|
||
) {}
|
||
|
||
async calculateEVM(scheduleId: string, asOfDate: Date): Promise<EVMMetrics> {
|
||
this.logger.log(
|
||
`Calculating EVM for schedule ${scheduleId} as of ${asOfDate.toISOString()}`,
|
||
);
|
||
|
||
const schedule = await this.scheduleRepo.findOne({
|
||
where: { id: scheduleId },
|
||
});
|
||
|
||
if (!schedule) {
|
||
throw new Error(`Schedule ${scheduleId} not found`);
|
||
}
|
||
|
||
const activities = await this.activityRepo.find({
|
||
where: { scheduleId },
|
||
});
|
||
|
||
// BAC: Budget at Completion (presupuesto total)
|
||
const BAC = activities.reduce((sum, a) => sum + Number(a.budgetedCost), 0);
|
||
|
||
// PV: Planned Value (valor planificado a la fecha)
|
||
const PV = this.calculatePlannedValue(activities, asOfDate);
|
||
|
||
// EV: Earned Value (valor ganado)
|
||
const EV = activities.reduce(
|
||
(sum, a) =>
|
||
sum + Number(a.budgetedCost) * (Number(a.percentComplete) / 100),
|
||
0,
|
||
);
|
||
|
||
// AC: Actual Cost (costo real)
|
||
const AC = activities.reduce((sum, a) => sum + Number(a.actualCost), 0);
|
||
|
||
// Varianzas
|
||
const SV = EV - PV;
|
||
const CV = EV - AC;
|
||
|
||
// Índices
|
||
const SPI = PV > 0 ? EV / PV : 1;
|
||
const CPI = AC > 0 ? EV / AC : 1;
|
||
|
||
// Proyecciones
|
||
const EAC = CPI > 0 ? BAC / CPI : BAC;
|
||
const ETC = EAC - AC;
|
||
const VAC = BAC - EAC;
|
||
|
||
// Porcentajes
|
||
const percentComplete = BAC > 0 ? (EV / BAC) * 100 : 0;
|
||
const percentSchedule = BAC > 0 ? (PV / BAC) * 100 : 0;
|
||
|
||
// Clasificación
|
||
const spiStatus =
|
||
SPI > 1.05 ? 'ahead' : SPI >= 0.95 ? 'on_track' : 'behind';
|
||
const cpiStatus =
|
||
CPI > 1.05 ? 'under_budget' : CPI >= 0.95 ? 'on_budget' : 'over_budget';
|
||
|
||
const metrics: EVMMetrics = {
|
||
BAC,
|
||
PV,
|
||
EV,
|
||
AC,
|
||
SV,
|
||
CV,
|
||
SPI,
|
||
CPI,
|
||
EAC,
|
||
ETC,
|
||
VAC,
|
||
percentComplete,
|
||
percentSchedule,
|
||
spiStatus,
|
||
cpiStatus,
|
||
};
|
||
|
||
this.logger.log(
|
||
`EVM calculated: SPI=${SPI.toFixed(3)} (${spiStatus}), CPI=${CPI.toFixed(3)} (${cpiStatus})`,
|
||
);
|
||
|
||
return metrics;
|
||
}
|
||
|
||
private calculatePlannedValue(
|
||
activities: ScheduleActivity[],
|
||
asOfDate: Date,
|
||
): number {
|
||
let PV = 0;
|
||
|
||
activities.forEach((activity) => {
|
||
const start = new Date(activity.plannedStartDate);
|
||
const finish = new Date(activity.plannedFinishDate);
|
||
const budget = Number(activity.budgetedCost);
|
||
|
||
if (asOfDate >= finish) {
|
||
// Actividad completamente planificada
|
||
PV += budget;
|
||
} else if (asOfDate > start && asOfDate < finish) {
|
||
// Actividad parcialmente planificada
|
||
const totalDuration =
|
||
(finish.getTime() - start.getTime()) / (1000 * 60 * 60 * 24);
|
||
const elapsedDuration =
|
||
(asOfDate.getTime() - start.getTime()) / (1000 * 60 * 60 * 24);
|
||
const plannedPercent = (elapsedDuration / totalDuration) * 100;
|
||
|
||
PV += budget * (plannedPercent / 100);
|
||
}
|
||
// Si asOfDate < start, no se suma nada
|
||
});
|
||
|
||
return PV;
|
||
}
|
||
|
||
async checkVarianceAlerts(
|
||
scheduleId: string,
|
||
threshold: number = 5,
|
||
): Promise<{
|
||
hasAlert: boolean;
|
||
alerts: string[];
|
||
}> {
|
||
const metrics = await this.calculateEVM(scheduleId, new Date());
|
||
|
||
const alerts: string[] = [];
|
||
|
||
// Alerta de varianza de costo
|
||
const cvPercent = (Math.abs(metrics.CV) / metrics.BAC) * 100;
|
||
if (cvPercent > threshold) {
|
||
alerts.push(
|
||
`Cost variance of ${cvPercent.toFixed(1)}% exceeds threshold (${threshold}%)`,
|
||
);
|
||
}
|
||
|
||
// Alerta de varianza de tiempo
|
||
const svPercent = (Math.abs(metrics.SV) / metrics.BAC) * 100;
|
||
if (svPercent > threshold) {
|
||
alerts.push(
|
||
`Schedule variance of ${svPercent.toFixed(1)}% exceeds threshold (${threshold}%)`,
|
||
);
|
||
}
|
||
|
||
// Alerta de SPI
|
||
if (metrics.SPI < 0.85) {
|
||
alerts.push(
|
||
`SPI of ${metrics.SPI.toFixed(3)} indicates significant schedule delay`,
|
||
);
|
||
}
|
||
|
||
// Alerta de CPI
|
||
if (metrics.CPI < 0.85) {
|
||
alerts.push(
|
||
`CPI of ${metrics.CPI.toFixed(3)} indicates significant cost overrun`,
|
||
);
|
||
}
|
||
|
||
return {
|
||
hasAlert: alerts.length > 0,
|
||
alerts,
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
### ImageProcessorService (Procesamiento de Imágenes con Sharp)
|
||
|
||
```typescript
|
||
// src/modules/resources/services/image-processor.service.ts
|
||
|
||
import { Injectable, Logger } from '@nestjs/common';
|
||
import * as sharp from 'sharp';
|
||
import * as path from 'path';
|
||
import { createHash } from 'crypto';
|
||
|
||
export interface WatermarkOptions {
|
||
text: string;
|
||
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||
fontSize?: number;
|
||
opacity?: number;
|
||
color?: string;
|
||
}
|
||
|
||
export interface ProcessImageResult {
|
||
originalPath: string;
|
||
thumbnailPath: string;
|
||
sha256Hash: string;
|
||
fileSizeBytes: number;
|
||
width: number;
|
||
height: number;
|
||
mimeType: string;
|
||
}
|
||
|
||
@Injectable()
|
||
export class ImageProcessorService {
|
||
private readonly logger = new Logger(ImageProcessorService.name);
|
||
|
||
async processImage(
|
||
inputBuffer: Buffer,
|
||
watermarkOptions: WatermarkOptions,
|
||
): Promise<{
|
||
processedBuffer: Buffer;
|
||
thumbnailBuffer: Buffer;
|
||
metadata: {
|
||
width: number;
|
||
height: number;
|
||
size: number;
|
||
format: string;
|
||
};
|
||
}> {
|
||
this.logger.log('Processing image with Sharp');
|
||
|
||
try {
|
||
// 1. Obtener metadatos originales
|
||
const metadata = await sharp(inputBuffer).metadata();
|
||
|
||
// 2. Aplicar marca de agua
|
||
const watermarkedBuffer = await this.addWatermark(
|
||
inputBuffer,
|
||
watermarkOptions,
|
||
);
|
||
|
||
// 3. Comprimir imagen (calidad 85%)
|
||
const processedBuffer = await sharp(watermarkedBuffer)
|
||
.jpeg({ quality: 85 })
|
||
.toBuffer();
|
||
|
||
// 4. Generar thumbnail 300x225
|
||
const thumbnailBuffer = await sharp(watermarkedBuffer)
|
||
.resize(300, 225, {
|
||
fit: 'cover',
|
||
position: 'center',
|
||
})
|
||
.jpeg({ quality: 80 })
|
||
.toBuffer();
|
||
|
||
return {
|
||
processedBuffer,
|
||
thumbnailBuffer,
|
||
metadata: {
|
||
width: metadata.width,
|
||
height: metadata.height,
|
||
size: processedBuffer.length,
|
||
format: metadata.format,
|
||
},
|
||
};
|
||
} catch (error) {
|
||
this.logger.error('Error processing image', error);
|
||
throw new Error(`Image processing failed: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
private async addWatermark(
|
||
inputBuffer: Buffer,
|
||
options: WatermarkOptions,
|
||
): Promise<Buffer> {
|
||
const {
|
||
text,
|
||
position = 'bottom-right',
|
||
fontSize = 24,
|
||
opacity = 0.7,
|
||
color = 'white',
|
||
} = options;
|
||
|
||
const image = sharp(inputBuffer);
|
||
const metadata = await image.metadata();
|
||
|
||
// Crear SVG para marca de agua
|
||
const svgText = this.createWatermarkSVG(
|
||
text,
|
||
metadata.width,
|
||
metadata.height,
|
||
position,
|
||
fontSize,
|
||
opacity,
|
||
color,
|
||
);
|
||
|
||
const svgBuffer = Buffer.from(svgText);
|
||
|
||
// Componer marca de agua sobre imagen
|
||
const watermarkedBuffer = await image
|
||
.composite([
|
||
{
|
||
input: svgBuffer,
|
||
top: 0,
|
||
left: 0,
|
||
},
|
||
])
|
||
.toBuffer();
|
||
|
||
return watermarkedBuffer;
|
||
}
|
||
|
||
private createWatermarkSVG(
|
||
text: string,
|
||
width: number,
|
||
height: number,
|
||
position: string,
|
||
fontSize: number,
|
||
opacity: number,
|
||
color: string,
|
||
): string {
|
||
const padding = 20;
|
||
let x: number;
|
||
let y: number;
|
||
let anchor: string;
|
||
|
||
switch (position) {
|
||
case 'top-left':
|
||
x = padding;
|
||
y = padding + fontSize;
|
||
anchor = 'start';
|
||
break;
|
||
case 'top-right':
|
||
x = width - padding;
|
||
y = padding + fontSize;
|
||
anchor = 'end';
|
||
break;
|
||
case 'bottom-left':
|
||
x = padding;
|
||
y = height - padding;
|
||
anchor = 'start';
|
||
break;
|
||
case 'bottom-right':
|
||
default:
|
||
x = width - padding;
|
||
y = height - padding;
|
||
anchor = 'end';
|
||
break;
|
||
}
|
||
|
||
return `
|
||
<svg width="${width}" height="${height}">
|
||
<style>
|
||
.watermark {
|
||
font-family: Arial, sans-serif;
|
||
font-size: ${fontSize}px;
|
||
font-weight: bold;
|
||
fill: ${color};
|
||
opacity: ${opacity};
|
||
text-shadow: 2px 2px 4px rgba(0,0,0,0.8);
|
||
}
|
||
</style>
|
||
<text
|
||
x="${x}"
|
||
y="${y}"
|
||
text-anchor="${anchor}"
|
||
class="watermark"
|
||
>
|
||
${this.escapeXml(text)}
|
||
</text>
|
||
</svg>
|
||
`;
|
||
}
|
||
|
||
private escapeXml(unsafe: string): string {
|
||
return unsafe
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
calculateSHA256(buffer: Buffer): string {
|
||
return createHash('sha256').update(buffer).digest('hex');
|
||
}
|
||
|
||
async optimizeImage(
|
||
inputBuffer: Buffer,
|
||
maxWidth: number = 1920,
|
||
maxHeight: number = 1080,
|
||
): Promise<Buffer> {
|
||
return sharp(inputBuffer)
|
||
.resize(maxWidth, maxHeight, {
|
||
fit: 'inside',
|
||
withoutEnlargement: true,
|
||
})
|
||
.jpeg({ quality: 85 })
|
||
.toBuffer();
|
||
}
|
||
}
|
||
```
|
||
|
||
### ExifService (Extracción de Metadatos EXIF)
|
||
|
||
```typescript
|
||
// src/modules/resources/services/exif.service.ts
|
||
|
||
import { Injectable, Logger } from '@nestjs/common';
|
||
import exifr from 'exifr';
|
||
|
||
export interface ExifData {
|
||
device?: string;
|
||
make?: string;
|
||
model?: string;
|
||
dateTimeOriginal?: string;
|
||
orientation?: number;
|
||
flash?: string;
|
||
iso?: number;
|
||
exposureTime?: string;
|
||
fNumber?: number;
|
||
focalLength?: number;
|
||
gpsLatitude?: number;
|
||
gpsLongitude?: number;
|
||
gpsAltitude?: number;
|
||
}
|
||
|
||
@Injectable()
|
||
export class ExifService {
|
||
private readonly logger = new Logger(ExifService.name);
|
||
|
||
async extractExif(buffer: Buffer): Promise<ExifData> {
|
||
try {
|
||
const exif = await exifr.parse(buffer, {
|
||
gps: true,
|
||
tiff: true,
|
||
exif: true,
|
||
});
|
||
|
||
if (!exif) {
|
||
this.logger.warn('No EXIF data found in image');
|
||
return {};
|
||
}
|
||
|
||
const data: ExifData = {
|
||
make: exif.Make,
|
||
model: exif.Model,
|
||
device: exif.Make && exif.Model ? `${exif.Make} ${exif.Model}` : undefined,
|
||
dateTimeOriginal: exif.DateTimeOriginal
|
||
? new Date(exif.DateTimeOriginal).toISOString()
|
||
: undefined,
|
||
orientation: exif.Orientation,
|
||
flash: exif.Flash !== undefined ? this.parseFlash(exif.Flash) : undefined,
|
||
iso: exif.ISO,
|
||
exposureTime: exif.ExposureTime,
|
||
fNumber: exif.FNumber,
|
||
focalLength: exif.FocalLength,
|
||
gpsLatitude: exif.latitude,
|
||
gpsLongitude: exif.longitude,
|
||
gpsAltitude: exif.GPSAltitude,
|
||
};
|
||
|
||
this.logger.log(`Extracted EXIF data: ${JSON.stringify(data)}`);
|
||
|
||
return data;
|
||
} catch (error) {
|
||
this.logger.error('Error extracting EXIF data', error);
|
||
return {};
|
||
}
|
||
}
|
||
|
||
private parseFlash(flashValue: number): string {
|
||
const flashMap = {
|
||
0x0000: 'No Flash',
|
||
0x0001: 'Fired',
|
||
0x0005: 'Fired, Return not detected',
|
||
0x0007: 'Fired, Return detected',
|
||
0x0008: 'On, Did not fire',
|
||
0x0009: 'On, Fired',
|
||
0x000d: 'On, Return not detected',
|
||
0x000f: 'On, Return detected',
|
||
0x0010: 'Off, Did not fire',
|
||
0x0018: 'Auto, Did not fire',
|
||
0x0019: 'Auto, Fired',
|
||
0x001d: 'Auto, Fired, Return not detected',
|
||
0x001f: 'Auto, Fired, Return detected',
|
||
0x0020: 'No flash function',
|
||
0x0041: 'Fired, Red-eye reduction',
|
||
0x0045: 'Fired, Red-eye reduction, Return not detected',
|
||
0x0047: 'Fired, Red-eye reduction, Return detected',
|
||
0x0049: 'On, Red-eye reduction',
|
||
0x004d: 'On, Red-eye reduction, Return not detected',
|
||
0x004f: 'On, Red-eye reduction, Return detected',
|
||
0x0059: 'Auto, Fired, Red-eye reduction',
|
||
0x005d: 'Auto, Fired, Red-eye reduction, Return not detected',
|
||
0x005f: 'Auto, Fired, Red-eye reduction, Return detected',
|
||
};
|
||
|
||
return flashMap[flashValue] || `Unknown (${flashValue})`;
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Controladores y API REST
|
||
|
||
### ProgressMobileController (API para MOB-003)
|
||
|
||
```typescript
|
||
// src/modules/progress/controllers/progress-mobile.controller.ts
|
||
|
||
import {
|
||
Controller,
|
||
Post,
|
||
Get,
|
||
Body,
|
||
Param,
|
||
Query,
|
||
UseGuards,
|
||
UseInterceptors,
|
||
UploadedFiles,
|
||
} from '@nestjs/common';
|
||
import { FilesInterceptor } from '@nestjs/platform-express';
|
||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||
import { RolesGuard } from '@/common/guards/roles.guard';
|
||
import { Roles } from '@/common/decorators/roles.decorator';
|
||
import { CurrentUser } from '@/common/decorators/current-user.decorator';
|
||
|
||
import { ProgressService } from '../services/progress.service';
|
||
import { ProgressSyncService } from '../services/progress-sync.service';
|
||
import { CreateProgressDto } from '../dto/create-progress.dto';
|
||
import { SyncBatchDto } from '../dto/sync-batch.dto';
|
||
|
||
@Controller('api/mobile/v1/progress')
|
||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||
export class ProgressMobileController {
|
||
constructor(
|
||
private readonly progressService: ProgressService,
|
||
private readonly syncService: ProgressSyncService,
|
||
) {}
|
||
|
||
/**
|
||
* Endpoint optimizado para captura desde app móvil
|
||
* Acepta múltiples fotos en una sola request
|
||
*/
|
||
@Post()
|
||
@Roles('residente', 'jefe_proyecto')
|
||
@UseInterceptors(FilesInterceptor('photos', 10))
|
||
async createProgress(
|
||
@CurrentUser() user: any,
|
||
@Body() dto: CreateProgressDto,
|
||
@UploadedFiles() photos: Express.Multer.File[],
|
||
) {
|
||
return this.progressService.createProgressWithPhotos({
|
||
...dto,
|
||
capturedBy: user.id,
|
||
photos,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Sincronización en lote desde app offline
|
||
* Procesa múltiples registros en una sola llamada
|
||
*/
|
||
@Post('sync/batch')
|
||
@Roles('residente', 'jefe_proyecto')
|
||
async syncBatch(@CurrentUser() user: any, @Body() dto: SyncBatchDto) {
|
||
return this.syncService.processBatchSync({
|
||
...dto,
|
||
userId: user.id,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Verificar estado de sincronización
|
||
*/
|
||
@Get('sync/status')
|
||
@Roles('residente', 'jefe_proyecto')
|
||
async getSyncStatus(
|
||
@CurrentUser() user: any,
|
||
@Query('deviceId') deviceId: string,
|
||
) {
|
||
return this.syncService.getSyncStatus(user.id, deviceId);
|
||
}
|
||
|
||
/**
|
||
* Obtener avances pendientes de sincronización
|
||
*/
|
||
@Get('sync/pending')
|
||
@Roles('residente', 'jefe_proyecto')
|
||
async getPendingSync(
|
||
@CurrentUser() user: any,
|
||
@Query('deviceId') deviceId: string,
|
||
@Query('limit') limit: number = 50,
|
||
) {
|
||
return this.syncService.getPendingRecords(user.id, deviceId, limit);
|
||
}
|
||
|
||
/**
|
||
* Validar geolocalización antes de enviar
|
||
*/
|
||
@Post('validate-location')
|
||
@Roles('residente', 'jefe_proyecto')
|
||
async validateLocation(
|
||
@Body() body: { projectId: string; latitude: number; longitude: number },
|
||
) {
|
||
return this.progressService.validateLocation(
|
||
body.projectId,
|
||
body.latitude,
|
||
body.longitude,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Obtener últimos avances del proyecto (para visualización móvil)
|
||
*/
|
||
@Get('project/:projectId/recent')
|
||
@Roles('residente', 'jefe_proyecto', 'director')
|
||
async getRecentProgress(
|
||
@Param('projectId') projectId: string,
|
||
@Query('limit') limit: number = 20,
|
||
) {
|
||
return this.progressService.getRecentProgress(projectId, limit);
|
||
}
|
||
}
|
||
```
|
||
|
||
### ScheduleController (API de Programación)
|
||
|
||
```typescript
|
||
// src/modules/estimations/controllers/schedule.controller.ts
|
||
|
||
import {
|
||
Controller,
|
||
Post,
|
||
Get,
|
||
Put,
|
||
Delete,
|
||
Body,
|
||
Param,
|
||
Query,
|
||
UseGuards,
|
||
} from '@nestjs/common';
|
||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||
import { RolesGuard } from '@/common/guards/roles.guard';
|
||
import { Roles } from '@/common/decorators/roles.decorator';
|
||
|
||
import { ScheduleService } from '../services/schedule.service';
|
||
import { CPMCalculatorService } from '../services/cpm-calculator.service';
|
||
import { CreateScheduleDto } from '../dto/create-schedule.dto';
|
||
import { UpdateActivityDto } from '../dto/update-activity.dto';
|
||
|
||
@Controller('api/schedules')
|
||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||
export class ScheduleController {
|
||
constructor(
|
||
private readonly scheduleService: ScheduleService,
|
||
private readonly cpmService: CPMCalculatorService,
|
||
) {}
|
||
|
||
@Post()
|
||
@Roles('jefe_proyecto', 'director')
|
||
async createSchedule(@Body() dto: CreateScheduleDto) {
|
||
return this.scheduleService.create(dto);
|
||
}
|
||
|
||
@Get(':scheduleId')
|
||
@Roles('residente', 'jefe_proyecto', 'director')
|
||
async getSchedule(@Param('scheduleId') scheduleId: string) {
|
||
return this.scheduleService.findOne(scheduleId);
|
||
}
|
||
|
||
@Get(':scheduleId/activities')
|
||
@Roles('residente', 'jefe_proyecto', 'director')
|
||
async getActivities(
|
||
@Param('scheduleId') scheduleId: string,
|
||
@Query('includeCompleted') includeCompleted: boolean = false,
|
||
) {
|
||
return this.scheduleService.getActivities(scheduleId, includeCompleted);
|
||
}
|
||
|
||
@Put(':scheduleId/activities/:activityId')
|
||
@Roles('jefe_proyecto', 'director')
|
||
async updateActivity(
|
||
@Param('scheduleId') scheduleId: string,
|
||
@Param('activityId') activityId: string,
|
||
@Body() dto: UpdateActivityDto,
|
||
) {
|
||
return this.scheduleService.updateActivity(scheduleId, activityId, dto);
|
||
}
|
||
|
||
/**
|
||
* Calcular ruta crítica (CPM)
|
||
*/
|
||
@Post(':scheduleId/calculate-cpm')
|
||
@Roles('jefe_proyecto', 'director')
|
||
async calculateCPM(@Param('scheduleId') scheduleId: string) {
|
||
const result = await this.cpmService.calculateCPM(scheduleId);
|
||
|
||
return {
|
||
scheduleId,
|
||
projectDuration: result.projectDuration,
|
||
criticalPathActivities: result.criticalPath.length,
|
||
criticalPath: result.criticalPath,
|
||
calculatedAt: new Date(),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Obtener actividades de ruta crítica
|
||
*/
|
||
@Get(':scheduleId/critical-path')
|
||
@Roles('jefe_proyecto', 'director')
|
||
async getCriticalPath(@Param('scheduleId') scheduleId: string) {
|
||
return this.scheduleService.getCriticalPathActivities(scheduleId);
|
||
}
|
||
|
||
/**
|
||
* Aprobar cronograma (crear baseline)
|
||
*/
|
||
@Post(':scheduleId/approve')
|
||
@Roles('director')
|
||
async approveSchedule(@Param('scheduleId') scheduleId: string) {
|
||
return this.scheduleService.approve(scheduleId);
|
||
}
|
||
|
||
/**
|
||
* Reprogramar (crear nueva versión)
|
||
*/
|
||
@Post(':scheduleId/reschedule')
|
||
@Roles('jefe_proyecto', 'director')
|
||
async reschedule(
|
||
@Param('scheduleId') scheduleId: string,
|
||
@Body() body: { reason: string },
|
||
) {
|
||
return this.scheduleService.reschedule(scheduleId, body.reason);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## WebSocket Gateway
|
||
|
||
### DashboardGateway (Tiempo Real)
|
||
|
||
```typescript
|
||
// src/modules/dashboard/gateways/dashboard.gateway.ts
|
||
|
||
import {
|
||
WebSocketGateway,
|
||
WebSocketServer,
|
||
SubscribeMessage,
|
||
OnGatewayConnection,
|
||
OnGatewayDisconnect,
|
||
ConnectedSocket,
|
||
MessageBody,
|
||
} from '@nestjs/websockets';
|
||
import { Server, Socket } from 'socket.io';
|
||
import { Logger, UseGuards } from '@nestjs/common';
|
||
import { WsJwtGuard } from '@/common/guards/ws-jwt.guard';
|
||
|
||
@WebSocketGateway({
|
||
namespace: 'dashboard',
|
||
cors: {
|
||
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
|
||
credentials: true,
|
||
},
|
||
})
|
||
@UseGuards(WsJwtGuard)
|
||
export class DashboardGateway
|
||
implements OnGatewayConnection, OnGatewayDisconnect
|
||
{
|
||
@WebSocketServer()
|
||
server: Server;
|
||
|
||
private readonly logger = new Logger(DashboardGateway.name);
|
||
private connectedClients = new Map<string, Set<string>>(); // projectId -> Set<socketId>
|
||
|
||
handleConnection(client: Socket) {
|
||
this.logger.log(`Client connected: ${client.id}`);
|
||
}
|
||
|
||
handleDisconnect(client: Socket) {
|
||
this.logger.log(`Client disconnected: ${client.id}`);
|
||
|
||
// Limpiar suscripciones
|
||
this.connectedClients.forEach((sockets, projectId) => {
|
||
sockets.delete(client.id);
|
||
if (sockets.size === 0) {
|
||
this.connectedClients.delete(projectId);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Cliente se suscribe a actualizaciones de un proyecto
|
||
*/
|
||
@SubscribeMessage('subscribe:project')
|
||
handleSubscribeProject(
|
||
@ConnectedSocket() client: Socket,
|
||
@MessageBody() data: { projectId: string },
|
||
) {
|
||
const { projectId } = data;
|
||
|
||
if (!this.connectedClients.has(projectId)) {
|
||
this.connectedClients.set(projectId, new Set());
|
||
}
|
||
|
||
this.connectedClients.get(projectId).add(client.id);
|
||
client.join(`project:${projectId}`);
|
||
|
||
this.logger.log(`Client ${client.id} subscribed to project ${projectId}`);
|
||
|
||
return { success: true, projectId };
|
||
}
|
||
|
||
/**
|
||
* Cliente se desuscribe de un proyecto
|
||
*/
|
||
@SubscribeMessage('unsubscribe:project')
|
||
handleUnsubscribeProject(
|
||
@ConnectedSocket() client: Socket,
|
||
@MessageBody() data: { projectId: string },
|
||
) {
|
||
const { projectId } = data;
|
||
|
||
const sockets = this.connectedClients.get(projectId);
|
||
if (sockets) {
|
||
sockets.delete(client.id);
|
||
if (sockets.size === 0) {
|
||
this.connectedClients.delete(projectId);
|
||
}
|
||
}
|
||
|
||
client.leave(`project:${projectId}`);
|
||
|
||
this.logger.log(
|
||
`Client ${client.id} unsubscribed from project ${projectId}`,
|
||
);
|
||
|
||
return { success: true, projectId };
|
||
}
|
||
|
||
/**
|
||
* Emitir actualización de avance a todos los clientes suscritos
|
||
*/
|
||
emitProgressUpdate(projectId: string, data: any) {
|
||
this.server.to(`project:${projectId}`).emit('progress:updated', data);
|
||
|
||
this.logger.log(`Emitted progress update for project ${projectId}`);
|
||
}
|
||
|
||
/**
|
||
* Emitir actualización de métricas EVM
|
||
*/
|
||
emitEVMUpdate(projectId: string, metrics: any) {
|
||
this.server.to(`project:${projectId}`).emit('evm:updated', metrics);
|
||
|
||
this.logger.log(`Emitted EVM update for project ${projectId}`);
|
||
}
|
||
|
||
/**
|
||
* Emitir nueva alerta crítica
|
||
*/
|
||
emitCriticalAlert(projectId: string, alert: any) {
|
||
this.server.to(`project:${projectId}`).emit('alert:critical', alert);
|
||
|
||
this.logger.log(`Emitted critical alert for project ${projectId}`);
|
||
}
|
||
|
||
/**
|
||
* Emitir nuevo snapshot de Curva S
|
||
*/
|
||
emitSCurveSnapshot(projectId: string, snapshot: any) {
|
||
this.server.to(`project:${projectId}`).emit('scurve:snapshot', snapshot);
|
||
|
||
this.logger.log(`Emitted S-Curve snapshot for project ${projectId}`);
|
||
}
|
||
|
||
/**
|
||
* Obtener estadísticas de conexiones
|
||
*/
|
||
getConnectionStats() {
|
||
const stats = {
|
||
totalConnections: this.server.sockets.sockets.size,
|
||
projectSubscriptions: Array.from(this.connectedClients.entries()).map(
|
||
([projectId, sockets]) => ({
|
||
projectId,
|
||
subscribers: sockets.size,
|
||
}),
|
||
),
|
||
};
|
||
|
||
return stats;
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Procesamiento Asíncrono
|
||
|
||
### ProgressSyncProcessor (BullMQ)
|
||
|
||
```typescript
|
||
// src/modules/progress/processors/progress-sync.processor.ts
|
||
|
||
import { Processor, Process, OnQueueActive, OnQueueCompleted, OnQueueFailed } from '@nestjs/bull';
|
||
import { Logger } from '@nestjs/common';
|
||
import { Job } from 'bull';
|
||
import { InjectRepository } from '@nestjs/typeorm';
|
||
import { Repository } from 'typeorm';
|
||
|
||
import { OfflineSyncQueue } from '../entities/offline-sync-queue.entity';
|
||
import { ProgressService } from '../services/progress.service';
|
||
import { DashboardGateway } from '@/modules/dashboard/gateways/dashboard.gateway';
|
||
|
||
interface SyncJobData {
|
||
userId: string;
|
||
deviceId: string;
|
||
records: Array<{
|
||
localId: string;
|
||
projectId: string;
|
||
activityId: string;
|
||
data: any;
|
||
photos?: Array<{
|
||
base64: string;
|
||
filename: string;
|
||
}>;
|
||
}>;
|
||
}
|
||
|
||
@Processor('progress-sync')
|
||
export class ProgressSyncProcessor {
|
||
private readonly logger = new Logger(ProgressSyncProcessor.name);
|
||
|
||
constructor(
|
||
@InjectRepository(OfflineSyncQueue)
|
||
private readonly syncQueueRepo: Repository<OfflineSyncQueue>,
|
||
private readonly progressService: ProgressService,
|
||
private readonly dashboardGateway: DashboardGateway,
|
||
) {}
|
||
|
||
@Process('sync-batch')
|
||
async handleSyncBatch(job: Job<SyncJobData>) {
|
||
this.logger.log(
|
||
`Processing sync batch job ${job.id} for device ${job.data.deviceId}`,
|
||
);
|
||
|
||
const { userId, deviceId, records } = job.data;
|
||
const results = [];
|
||
|
||
for (const record of records) {
|
||
try {
|
||
// Crear registro de avance
|
||
const progress = await this.progressService.createProgressWithPhotos({
|
||
...record.data,
|
||
capturedBy: userId,
|
||
deviceId,
|
||
isOfflineSync: true,
|
||
photos: record.photos
|
||
? record.photos.map((p) => ({
|
||
buffer: Buffer.from(p.base64, 'base64'),
|
||
originalname: p.filename,
|
||
}))
|
||
: [],
|
||
});
|
||
|
||
// Actualizar cola de sincronización
|
||
await this.syncQueueRepo.update(
|
||
{
|
||
deviceId,
|
||
localId: record.localId,
|
||
},
|
||
{
|
||
syncStatus: 'synced',
|
||
serverId: progress.id,
|
||
syncedAt: new Date(),
|
||
},
|
||
);
|
||
|
||
// Emitir evento WebSocket
|
||
this.dashboardGateway.emitProgressUpdate(record.projectId, {
|
||
type: 'progress_created',
|
||
data: progress,
|
||
});
|
||
|
||
results.push({
|
||
localId: record.localId,
|
||
serverId: progress.id,
|
||
status: 'success',
|
||
});
|
||
|
||
this.logger.log(`Synced record ${record.localId} successfully`);
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`Failed to sync record ${record.localId}`,
|
||
error.stack,
|
||
);
|
||
|
||
// Marcar como error en cola
|
||
await this.syncQueueRepo.update(
|
||
{
|
||
deviceId,
|
||
localId: record.localId,
|
||
},
|
||
{
|
||
syncStatus: 'error',
|
||
errorMessage: error.message,
|
||
retryCount: () => 'retry_count + 1',
|
||
},
|
||
);
|
||
|
||
results.push({
|
||
localId: record.localId,
|
||
status: 'error',
|
||
error: error.message,
|
||
});
|
||
}
|
||
}
|
||
|
||
return {
|
||
totalRecords: records.length,
|
||
successCount: results.filter((r) => r.status === 'success').length,
|
||
errorCount: results.filter((r) => r.status === 'error').length,
|
||
results,
|
||
};
|
||
}
|
||
|
||
@OnQueueActive()
|
||
onActive(job: Job) {
|
||
this.logger.debug(`Processing job ${job.id} of type ${job.name}`);
|
||
}
|
||
|
||
@OnQueueCompleted()
|
||
onComplete(job: Job, result: any) {
|
||
this.logger.log(
|
||
`Completed job ${job.id}. Success: ${result.successCount}, Errors: ${result.errorCount}`,
|
||
);
|
||
}
|
||
|
||
@OnQueueFailed()
|
||
onError(job: Job, error: Error) {
|
||
this.logger.error(`Failed job ${job.id}`, error.stack);
|
||
}
|
||
}
|
||
```
|
||
|
||
### SCurveSnapshotScheduler (CRON)
|
||
|
||
```typescript
|
||
// src/modules/estimations/services/s-curve.service.ts
|
||
|
||
import { Injectable, Logger } from '@nestjs/common';
|
||
import { InjectRepository } from '@nestjs/typeorm';
|
||
import { Repository } from 'typeorm';
|
||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||
|
||
import { Schedule } from '../entities/schedule.entity';
|
||
import { SCurveSnapshot } from '../entities/s-curve-snapshot.entity';
|
||
import { EVMCalculatorService } from './evm-calculator.service';
|
||
import { DashboardGateway } from '@/modules/dashboard/gateways/dashboard.gateway';
|
||
|
||
@Injectable()
|
||
export class SCurveService {
|
||
private readonly logger = new Logger(SCurveService.name);
|
||
|
||
constructor(
|
||
@InjectRepository(Schedule)
|
||
private readonly scheduleRepo: Repository<Schedule>,
|
||
@InjectRepository(SCurveSnapshot)
|
||
private readonly snapshotRepo: Repository<SCurveSnapshot>,
|
||
private readonly evmService: EVMCalculatorService,
|
||
private readonly dashboardGateway: DashboardGateway,
|
||
) {}
|
||
|
||
/**
|
||
* CRON Job: Genera snapshots diarios a las 23:00
|
||
*/
|
||
@Cron('0 23 * * *', {
|
||
name: 's-curve-daily-snapshot',
|
||
timeZone: 'America/Mexico_City',
|
||
})
|
||
async generateDailySnapshots() {
|
||
this.logger.log('Running daily S-Curve snapshot generation');
|
||
|
||
const activeSchedules = await this.scheduleRepo.find({
|
||
where: { status: 'active', isBaseline: true },
|
||
});
|
||
|
||
this.logger.log(`Found ${activeSchedules.length} active schedules`);
|
||
|
||
for (const schedule of activeSchedules) {
|
||
try {
|
||
await this.createSnapshot(schedule.id, new Date(), false);
|
||
this.logger.log(`Created snapshot for schedule ${schedule.id}`);
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`Failed to create snapshot for schedule ${schedule.id}`,
|
||
error.stack,
|
||
);
|
||
}
|
||
}
|
||
|
||
this.logger.log('Daily S-Curve snapshot generation completed');
|
||
}
|
||
|
||
async createSnapshot(
|
||
scheduleId: string,
|
||
snapshotDate: Date,
|
||
isManual: boolean = false,
|
||
): Promise<SCurveSnapshot> {
|
||
const schedule = await this.scheduleRepo.findOne({
|
||
where: { id: scheduleId },
|
||
});
|
||
|
||
if (!schedule) {
|
||
throw new Error(`Schedule ${scheduleId} not found`);
|
||
}
|
||
|
||
// Calcular métricas EVM
|
||
const metrics = await this.evmService.calculateEVM(scheduleId, snapshotDate);
|
||
|
||
// Calcular día desde inicio del proyecto
|
||
const projectStart = new Date(schedule.startDate);
|
||
const dayNumber = Math.floor(
|
||
(snapshotDate.getTime() - projectStart.getTime()) / (1000 * 60 * 60 * 24),
|
||
);
|
||
|
||
// Crear snapshot
|
||
const snapshot = this.snapshotRepo.create({
|
||
scheduleId,
|
||
snapshotDate,
|
||
dayNumber,
|
||
plannedPercent: metrics.percentSchedule,
|
||
actualPercent: metrics.percentComplete,
|
||
plannedValue: metrics.PV,
|
||
earnedValue: metrics.EV,
|
||
actualCost: metrics.AC,
|
||
spi: metrics.SPI,
|
||
cpi: metrics.CPI,
|
||
scheduleVariance: metrics.SV,
|
||
costVariance: metrics.CV,
|
||
estimateAtCompletion: metrics.EAC,
|
||
estimateToComplete: metrics.ETC,
|
||
varianceAtCompletion: metrics.VAC,
|
||
budgetAtCompletion: metrics.BAC,
|
||
isManual,
|
||
});
|
||
|
||
await this.snapshotRepo.save(snapshot);
|
||
|
||
// Emitir evento WebSocket
|
||
this.dashboardGateway.emitSCurveSnapshot(schedule.projectId, snapshot);
|
||
|
||
// Verificar alertas de varianza
|
||
const alerts = await this.evmService.checkVarianceAlerts(scheduleId);
|
||
if (alerts.hasAlert) {
|
||
alerts.alerts.forEach((alert) => {
|
||
this.dashboardGateway.emitCriticalAlert(schedule.projectId, {
|
||
severity: 'critical',
|
||
message: alert,
|
||
scheduleId,
|
||
snapshotDate,
|
||
});
|
||
});
|
||
}
|
||
|
||
return snapshot;
|
||
}
|
||
|
||
async getSnapshots(
|
||
scheduleId: string,
|
||
startDate?: Date,
|
||
endDate?: Date,
|
||
): Promise<SCurveSnapshot[]> {
|
||
const query = this.snapshotRepo
|
||
.createQueryBuilder('snapshot')
|
||
.where('snapshot.scheduleId = :scheduleId', { scheduleId })
|
||
.orderBy('snapshot.snapshotDate', 'ASC');
|
||
|
||
if (startDate) {
|
||
query.andWhere('snapshot.snapshotDate >= :startDate', { startDate });
|
||
}
|
||
|
||
if (endDate) {
|
||
query.andWhere('snapshot.snapshotDate <= :endDate', { endDate });
|
||
}
|
||
|
||
return query.getMany();
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Geolocalización con PostGIS
|
||
|
||
### Configuración de PostGIS
|
||
|
||
```typescript
|
||
// src/config/database.config.ts
|
||
|
||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||
import { ConfigService } from '@nestjs/config';
|
||
|
||
export const getDatabaseConfig = (
|
||
configService: ConfigService,
|
||
): TypeOrmModuleOptions => ({
|
||
type: 'postgres',
|
||
host: configService.get('DB_HOST'),
|
||
port: configService.get('DB_PORT'),
|
||
username: configService.get('DB_USERNAME'),
|
||
password: configService.get('DB_PASSWORD'),
|
||
database: configService.get('DB_DATABASE'),
|
||
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
|
||
migrations: [__dirname + '/../migrations/*{.ts,.js}'],
|
||
synchronize: false, // NUNCA en producción
|
||
logging: configService.get('NODE_ENV') === 'development',
|
||
ssl:
|
||
configService.get('NODE_ENV') === 'production'
|
||
? { rejectUnauthorized: false }
|
||
: false,
|
||
|
||
// Configuración para PostGIS
|
||
extra: {
|
||
// Habilitar extensión PostGIS
|
||
application_name: 'erp-construccion',
|
||
},
|
||
});
|
||
```
|
||
|
||
### Migration para habilitar PostGIS
|
||
|
||
```typescript
|
||
// src/migrations/1701000000000-EnablePostGIS.ts
|
||
|
||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||
|
||
export class EnablePostGIS1701000000000 implements MigrationInterface {
|
||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||
// Habilitar extensión PostGIS
|
||
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS postgis;`);
|
||
|
||
// Verificar versión
|
||
await queryRunner.query(`SELECT PostGIS_Version();`);
|
||
}
|
||
|
||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||
await queryRunner.query(`DROP EXTENSION IF EXISTS postgis CASCADE;`);
|
||
}
|
||
}
|
||
```
|
||
|
||
### LocationValidationService (Validación de Geolocalización)
|
||
|
||
```typescript
|
||
// src/modules/progress/services/location-validation.service.ts
|
||
|
||
import { Injectable, Logger } from '@nestjs/common';
|
||
import { InjectRepository } from '@nestjs/typeorm';
|
||
import { Repository } from 'typeorm';
|
||
|
||
@Injectable()
|
||
export class LocationValidationService {
|
||
private readonly logger = new Logger(LocationValidationService.name);
|
||
private readonly DEFAULT_RADIUS_METERS = 500;
|
||
|
||
constructor(
|
||
@InjectRepository(Project)
|
||
private readonly projectRepo: Repository<Project>,
|
||
) {}
|
||
|
||
/**
|
||
* Valida si una ubicación está dentro del radio permitido del proyecto
|
||
* Usa función ST_Distance de PostGIS
|
||
*/
|
||
async validateLocation(
|
||
projectId: string,
|
||
latitude: number,
|
||
longitude: number,
|
||
radiusMeters: number = this.DEFAULT_RADIUS_METERS,
|
||
): Promise<{
|
||
isValid: boolean;
|
||
distance: number;
|
||
allowedRadius: number;
|
||
}> {
|
||
// Obtener ubicación del proyecto
|
||
const project = await this.projectRepo
|
||
.createQueryBuilder('project')
|
||
.select([
|
||
'project.id',
|
||
'project.latitude',
|
||
'project.longitude',
|
||
'ST_AsText(project.location) as location_wkt',
|
||
])
|
||
.where('project.id = :projectId', { projectId })
|
||
.getRawOne();
|
||
|
||
if (!project || !project.latitude || !project.longitude) {
|
||
this.logger.warn(
|
||
`Project ${projectId} not found or has no location configured`,
|
||
);
|
||
return {
|
||
isValid: false,
|
||
distance: null,
|
||
allowedRadius: radiusMeters,
|
||
};
|
||
}
|
||
|
||
// Calcular distancia usando PostGIS
|
||
const result = await this.projectRepo.query(
|
||
`
|
||
SELECT ST_Distance(
|
||
ST_GeographyFromText('SRID=4326;POINT($1 $2)'),
|
||
ST_GeographyFromText('SRID=4326;POINT($3 $4)')
|
||
) as distance
|
||
`,
|
||
[longitude, latitude, project.longitude, project.latitude],
|
||
);
|
||
|
||
const distance = parseFloat(result[0].distance);
|
||
|
||
const isValid = distance <= radiusMeters;
|
||
|
||
this.logger.log(
|
||
`Location validation for project ${projectId}: distance=${distance.toFixed(2)}m, allowed=${radiusMeters}m, valid=${isValid}`,
|
||
);
|
||
|
||
return {
|
||
isValid,
|
||
distance: Math.round(distance * 100) / 100,
|
||
allowedRadius: radiusMeters,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Encuentra fotos dentro de un radio específico
|
||
*/
|
||
async findPhotosNearLocation(
|
||
latitude: number,
|
||
longitude: number,
|
||
radiusMeters: number = 100,
|
||
): Promise<any[]> {
|
||
const photos = await this.projectRepo.query(
|
||
`
|
||
SELECT
|
||
id,
|
||
storage_url,
|
||
ST_Distance(
|
||
location,
|
||
ST_GeographyFromText('SRID=4326;POINT($1 $2)')
|
||
) as distance
|
||
FROM photos
|
||
WHERE location IS NOT NULL
|
||
AND ST_DWithin(
|
||
location,
|
||
ST_GeographyFromText('SRID=4326;POINT($1 $2)'),
|
||
$3
|
||
)
|
||
ORDER BY distance ASC
|
||
`,
|
||
[longitude, latitude, radiusMeters],
|
||
);
|
||
|
||
return photos;
|
||
}
|
||
|
||
/**
|
||
* Obtiene todas las ubicaciones de avances en un mapa
|
||
*/
|
||
async getProgressLocationsMap(projectId: string): Promise<any[]> {
|
||
const locations = await this.projectRepo.query(
|
||
`
|
||
SELECT
|
||
pr.id,
|
||
pr.created_at,
|
||
pr.current_percent,
|
||
pr.status,
|
||
ST_X(pr.location::geometry) as longitude,
|
||
ST_Y(pr.location::geometry) as latitude,
|
||
u.first_name || ' ' || u.last_name as captured_by_name
|
||
FROM progress_records pr
|
||
LEFT JOIN users u ON pr.captured_by = u.id
|
||
WHERE pr.project_id = $1
|
||
AND pr.location IS NOT NULL
|
||
ORDER BY pr.created_at DESC
|
||
`,
|
||
[projectId],
|
||
);
|
||
|
||
return locations;
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Validaciones y DTOs
|
||
|
||
### CreateProgressDto
|
||
|
||
```typescript
|
||
// src/modules/progress/dto/create-progress.dto.ts
|
||
|
||
import {
|
||
IsUUID,
|
||
IsEnum,
|
||
IsNumber,
|
||
IsString,
|
||
IsOptional,
|
||
IsLatitude,
|
||
IsLongitude,
|
||
ValidateNested,
|
||
Min,
|
||
Max,
|
||
} from 'class-validator';
|
||
import { Type } from 'class-transformer';
|
||
|
||
export enum CaptureMode {
|
||
PERCENTAGE = 'percentage',
|
||
QUANTITY = 'quantity',
|
||
UNIT = 'unit',
|
||
}
|
||
|
||
export class CreateProgressDto {
|
||
@IsUUID()
|
||
projectId: string;
|
||
|
||
@IsUUID()
|
||
activityId: string;
|
||
|
||
@IsUUID()
|
||
unitId: string;
|
||
|
||
@IsEnum(CaptureMode)
|
||
captureMode: CaptureMode;
|
||
|
||
@IsOptional()
|
||
@IsNumber()
|
||
@Min(0)
|
||
@Max(100)
|
||
currentPercent?: number;
|
||
|
||
@IsOptional()
|
||
@IsNumber()
|
||
@Min(0)
|
||
currentQuantity?: number;
|
||
|
||
@IsOptional()
|
||
@IsString()
|
||
unit?: string;
|
||
|
||
@IsOptional()
|
||
@IsString()
|
||
notes?: string;
|
||
|
||
@IsOptional()
|
||
@IsLatitude()
|
||
latitude?: number;
|
||
|
||
@IsOptional()
|
||
@IsLongitude()
|
||
longitude?: number;
|
||
|
||
@IsOptional()
|
||
@IsNumber()
|
||
altitude?: number;
|
||
|
||
@IsOptional()
|
||
@IsString()
|
||
deviceId?: string;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Testing
|
||
|
||
### CPM Calculator Test
|
||
|
||
```typescript
|
||
// src/modules/estimations/services/cpm-calculator.service.spec.ts
|
||
|
||
import { Test, TestingModule } from '@nestjs/testing';
|
||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||
import { Repository } from 'typeorm';
|
||
import { CPMCalculatorService } from './cpm-calculator.service';
|
||
import { ScheduleActivity } from '../entities/schedule-activity.entity';
|
||
|
||
describe('CPMCalculatorService', () => {
|
||
let service: CPMCalculatorService;
|
||
let repository: Repository<ScheduleActivity>;
|
||
|
||
const mockRepository = {
|
||
find: jest.fn(),
|
||
update: jest.fn(),
|
||
};
|
||
|
||
beforeEach(async () => {
|
||
const module: TestingModule = await Test.createTestingModule({
|
||
providers: [
|
||
CPMCalculatorService,
|
||
{
|
||
provide: getRepositoryToken(ScheduleActivity),
|
||
useValue: mockRepository,
|
||
},
|
||
],
|
||
}).compile();
|
||
|
||
service = module.get<CPMCalculatorService>(CPMCalculatorService);
|
||
repository = module.get<Repository<ScheduleActivity>>(
|
||
getRepositoryToken(ScheduleActivity),
|
||
);
|
||
});
|
||
|
||
it('should be defined', () => {
|
||
expect(service).toBeDefined();
|
||
});
|
||
|
||
describe('calculateCPM', () => {
|
||
it('should calculate critical path correctly for simple linear schedule', async () => {
|
||
const activities = [
|
||
{
|
||
id: 'a1',
|
||
wbs: '1.0',
|
||
durationDays: 5,
|
||
predecessors: [],
|
||
},
|
||
{
|
||
id: 'a2',
|
||
wbs: '2.0',
|
||
durationDays: 3,
|
||
predecessors: [{ activityId: 'a1', type: 'FS', lag: 0 }],
|
||
},
|
||
{
|
||
id: 'a3',
|
||
wbs: '3.0',
|
||
durationDays: 4,
|
||
predecessors: [{ activityId: 'a2', type: 'FS', lag: 0 }],
|
||
},
|
||
];
|
||
|
||
mockRepository.find.mockResolvedValue(activities);
|
||
mockRepository.update.mockResolvedValue({ affected: 1 });
|
||
|
||
const result = await service.calculateCPM('schedule-1');
|
||
|
||
expect(result.projectDuration).toBe(12); // 5 + 3 + 4
|
||
expect(result.criticalPath).toEqual(['a1', 'a2', 'a3']);
|
||
expect(result.activities.get('a1')).toMatchObject({
|
||
ES: 0,
|
||
EF: 5,
|
||
LS: 0,
|
||
LF: 5,
|
||
TF: 0,
|
||
isCritical: true,
|
||
});
|
||
});
|
||
|
||
it('should identify non-critical activities with float', async () => {
|
||
const activities = [
|
||
{
|
||
id: 'a1',
|
||
wbs: '1.0',
|
||
durationDays: 10,
|
||
predecessors: [],
|
||
},
|
||
{
|
||
id: 'a2',
|
||
wbs: '2.0',
|
||
durationDays: 5,
|
||
predecessors: [{ activityId: 'a1', type: 'FS', lag: 0 }],
|
||
},
|
||
{
|
||
id: 'a3',
|
||
wbs: '3.0',
|
||
durationDays: 3,
|
||
predecessors: [{ activityId: 'a1', type: 'FS', lag: 0 }],
|
||
},
|
||
{
|
||
id: 'a4',
|
||
wbs: '4.0',
|
||
durationDays: 2,
|
||
predecessors: [
|
||
{ activityId: 'a2', type: 'FS', lag: 0 },
|
||
{ activityId: 'a3', type: 'FS', lag: 0 },
|
||
],
|
||
},
|
||
];
|
||
|
||
mockRepository.find.mockResolvedValue(activities);
|
||
mockRepository.update.mockResolvedValue({ affected: 1 });
|
||
|
||
const result = await service.calculateCPM('schedule-1');
|
||
|
||
expect(result.projectDuration).toBe(17); // 10 + 5 + 2
|
||
expect(result.criticalPath).toContain('a1');
|
||
expect(result.criticalPath).toContain('a2');
|
||
expect(result.criticalPath).toContain('a4');
|
||
|
||
const a3Times = result.activities.get('a3');
|
||
expect(a3Times.isCritical).toBe(false);
|
||
expect(a3Times.TF).toBeGreaterThan(0);
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## Deployment
|
||
|
||
### Dockerfile
|
||
|
||
```dockerfile
|
||
# Dockerfile
|
||
|
||
FROM node:20-alpine AS builder
|
||
|
||
WORKDIR /app
|
||
|
||
# Instalar dependencias del sistema para Sharp
|
||
RUN apk add --no-cache \
|
||
python3 \
|
||
make \
|
||
g++ \
|
||
cairo-dev \
|
||
jpeg-dev \
|
||
pango-dev \
|
||
giflib-dev
|
||
|
||
# Copiar archivos de dependencias
|
||
COPY package*.json ./
|
||
COPY tsconfig*.json ./
|
||
|
||
# Instalar dependencias
|
||
RUN npm ci
|
||
|
||
# Copiar código fuente
|
||
COPY src ./src
|
||
|
||
# Compilar TypeScript
|
||
RUN npm run build
|
||
|
||
# Limpiar devDependencies
|
||
RUN npm prune --production
|
||
|
||
# --- Imagen de producción ---
|
||
|
||
FROM node:20-alpine
|
||
|
||
WORKDIR /app
|
||
|
||
# Instalar dependencias de runtime para Sharp
|
||
RUN apk add --no-cache \
|
||
cairo \
|
||
jpeg \
|
||
pango \
|
||
giflib
|
||
|
||
# Copiar archivos necesarios
|
||
COPY --from=builder /app/node_modules ./node_modules
|
||
COPY --from=builder /app/dist ./dist
|
||
COPY --from=builder /app/package*.json ./
|
||
|
||
# Usuario no-root
|
||
RUN addgroup -g 1001 -S nodejs && \
|
||
adduser -S nestjs -u 1001
|
||
|
||
USER nestjs
|
||
|
||
# Exponer puerto
|
||
EXPOSE 3000
|
||
|
||
# Health check
|
||
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s \
|
||
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||
|
||
# Comando de inicio
|
||
CMD ["node", "dist/main.js"]
|
||
```
|
||
|
||
### docker-compose.yml
|
||
|
||
```yaml
|
||
# docker-compose.yml
|
||
|
||
version: '3.8'
|
||
|
||
services:
|
||
# PostgreSQL con PostGIS
|
||
postgres:
|
||
image: postgis/postgis:15-3.4-alpine
|
||
container_name: erp-construccion-db
|
||
environment:
|
||
POSTGRES_USER: ${DB_USERNAME}
|
||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||
POSTGRES_DB: ${DB_DATABASE}
|
||
ports:
|
||
- '5432:5432'
|
||
volumes:
|
||
- postgres_data:/var/lib/postgresql/data
|
||
- ./init-scripts:/docker-entrypoint-initdb.d
|
||
networks:
|
||
- erp-network
|
||
|
||
# Redis (para BullMQ)
|
||
redis:
|
||
image: redis:7-alpine
|
||
container_name: erp-construccion-redis
|
||
ports:
|
||
- '6379:6379'
|
||
volumes:
|
||
- redis_data:/data
|
||
networks:
|
||
- erp-network
|
||
|
||
# Backend NestJS
|
||
api:
|
||
build:
|
||
context: .
|
||
dockerfile: Dockerfile
|
||
container_name: erp-construccion-api
|
||
environment:
|
||
NODE_ENV: production
|
||
DB_HOST: postgres
|
||
DB_PORT: 5432
|
||
DB_USERNAME: ${DB_USERNAME}
|
||
DB_PASSWORD: ${DB_PASSWORD}
|
||
DB_DATABASE: ${DB_DATABASE}
|
||
REDIS_HOST: redis
|
||
REDIS_PORT: 6379
|
||
JWT_SECRET: ${JWT_SECRET}
|
||
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
|
||
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
|
||
AWS_S3_BUCKET: ${AWS_S3_BUCKET}
|
||
AWS_REGION: ${AWS_REGION}
|
||
ports:
|
||
- '3000:3000'
|
||
depends_on:
|
||
- postgres
|
||
- redis
|
||
networks:
|
||
- erp-network
|
||
restart: unless-stopped
|
||
|
||
volumes:
|
||
postgres_data:
|
||
redis_data:
|
||
|
||
networks:
|
||
erp-network:
|
||
driver: bridge
|
||
```
|
||
|
||
---
|
||
|
||
## Conclusión
|
||
|
||
Esta especificación técnica detalla la implementación completa del backend para el módulo MAI-005 (Control de Obra), incluyendo:
|
||
|
||
- **4 módulos NestJS:** Progress, WorkLog, Estimations, Resources
|
||
- **Entidades con PostGIS:** Soporte completo para geolocalización con tipo `POINT`
|
||
- **Servicios avanzados:** CPM, EVM, Curva S, procesamiento de imágenes con Sharp
|
||
- **API móvil optimizada:** Endpoints específicos para app MOB-003
|
||
- **WebSocket en tiempo real:** Dashboard con actualizaciones push
|
||
- **Procesamiento asíncrono:** BullMQ para sincronización offline y trabajos batch
|
||
- **Validación de geolocalización:** Verificación de radio con PostGIS
|
||
- **Hash SHA256:** Integridad de evidencias fotográficas
|
||
|
||
**Stack:** NestJS 10+, TypeORM, PostgreSQL 15+ con PostGIS 3.4+, Sharp, BullMQ, Socket.io
|
||
|
||
---
|
||
|
||
**Generado:** 2025-12-06
|
||
**Mantenedor:** @backend-team
|
||
**Revisión:** v1.0.0
|