erp-construccion/docs/02-definicion-modulos/MAI-005-control-obra/especificaciones/ET-OBRA-001-backend.md

76 KiB
Raw Permalink Blame History

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
  2. Arquitectura del Backend
  3. Módulos NestJS
  4. Modelos y Entidades
  5. Servicios de Negocio
  6. Controladores y API REST
  7. WebSocket Gateway
  8. Procesamiento Asíncrono
  9. Geolocalización con PostGIS
  10. Procesamiento de Imágenes
  11. Autenticación y Autorización
  12. Validaciones y DTOs
  13. Testing
  14. 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

# 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.

// 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.

// 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.

// 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.

// 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)

// 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)

// 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)

// 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)

// 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)

// 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)

// 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)

// 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)

// 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, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&apos;');
  }

  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)

// 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)

// 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)

// 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)

// 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)

// 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)

// 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

// 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

// 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)

// 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

// 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

// 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

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

# 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