# 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; } 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, ) {} async calculateCPM(scheduleId: string): Promise { 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> { const graph = new Map>(); 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>, activities: ScheduleActivity[], ): Map { const times = new Map(); 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>, activities: ScheduleActivity[], forwardPass: Map, ): Map { const times = new Map(); 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, backwardPass: Map, ): CPMResult { const activityTimes = new Map(); 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>, activities: ScheduleActivity[], ): string[] { const sorted: string[] = []; const visited = new Set(); 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, ): Promise { 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, @InjectRepository(Schedule) private readonly scheduleRepo: Repository, ) {} async calculateEVM(scheduleId: string, asOfDate: Date): Promise { 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 { 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 ` ${this.escapeXml(text)} `; } private escapeXml(unsafe: string): string { return unsafe .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 { 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 { 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>(); // projectId -> Set 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, private readonly progressService: ProgressService, private readonly dashboardGateway: DashboardGateway, ) {} @Process('sync-batch') async handleSyncBatch(job: Job) { 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, @InjectRepository(SCurveSnapshot) private readonly snapshotRepo: Repository, 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 { 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 { 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 { // 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 { 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, ) {} /** * 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 { 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 { 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; 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); repository = module.get>( 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