76 KiB
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
- Resumen Ejecutivo
- Arquitectura del Backend
- Módulos NestJS
- Modelos y Entidades
- Servicios de Negocio
- Controladores y API REST
- WebSocket Gateway
- Procesamiento Asíncrono
- Geolocalización con PostGIS
- Procesamiento de Imágenes
- Autenticación y Autorización
- Validaciones y DTOs
- Testing
- 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
POINTpara 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, validacionesProgressApprovalService: Workflow de aprobación multinivelProgressSyncService: Sincronización desde app móvil offline
Controladores:
ProgressController: API principal para webProgressMobileController: 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 MethodEVMCalculatorService: Cálculo de PV, EV, AC, SPI, CPI, EAC, VACSCurveService: Generación de snapshots diarios y gráficasScheduleService: 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
calculateSHA256(buffer: Buffer): string {
return createHash('sha256').update(buffer).digest('hex');
}
async optimizeImage(
inputBuffer: Buffer,
maxWidth: number = 1920,
maxHeight: number = 1080,
): Promise<Buffer> {
return sharp(inputBuffer)
.resize(maxWidth, maxHeight, {
fit: 'inside',
withoutEnlargement: true,
})
.jpeg({ quality: 85 })
.toBuffer();
}
}
ExifService (Extracción de Metadatos EXIF)
// 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