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

2988 lines
76 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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