# ET-PROG-002: Implementación de Captura de Avances Físicos **Épica:** MAI-005 - Control de Obra y Avances **Módulo:** Captura de Avances **Responsable Técnico:** Backend + Frontend + Mobile **Fecha:** 2025-11-17 **Versión:** 1.0 --- ## 1. Objetivo Técnico Implementar el sistema de captura de avances físicos con: - Registro de avances por porcentaje, cantidad o unidad - Captura desde web y app móvil (modo offline) - Flujo de aprobación de avances con validaciones - Geolocalización automática de registros - Actualización en tiempo real de Curva S - Dashboard de productividad por cuadrilla --- ## 2. Stack Tecnológico ### Backend ```typescript - NestJS 10+ con TypeScript - TypeORM para PostgreSQL - PostgreSQL 15+ (schema: progress) - PostGIS para geolocalización - EventEmitter2 para eventos - Bull/BullMQ para procesamiento async ``` ### Frontend Web ```typescript - React 18 con TypeScript - React Hook Form para formularios - Zustand para state management - React Query para cache y sync - Leaflet para mapas ``` ### Mobile App ```typescript - React Native 0.72+ - Expo 49+ para geolocation - SQLite para almacenamiento offline - NetInfo para detección de conectividad - react-native-camera para fotos ``` --- ## 3. Modelo de Datos SQL ### 3.1 Schema Principal ```sql -- ===================================================== -- SCHEMA: progress -- Descripción: Captura y control de avances físicos -- ===================================================== CREATE SCHEMA IF NOT EXISTS progress; -- Habilitar PostGIS para geolocalización CREATE EXTENSION IF NOT EXISTS postgis; -- ===================================================== -- TABLE: progress.progress_records -- Descripción: Registros de avance físico -- ===================================================== CREATE TABLE progress.progress_records ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Relaciones project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE, stage_id UUID REFERENCES projects.stages(id), workfront_id UUID REFERENCES projects.workfronts(id), activity_id UUID REFERENCES schedules.schedule_activities(id), budget_item_id UUID REFERENCES budgets.budget_items(id), unit_id UUID REFERENCES projects.units(id), -- para avances por vivienda -- Identificación record_code VARCHAR(50) NOT NULL, -- AVN-2025-00001 record_date DATE NOT NULL, -- Tipo de registro record_type VARCHAR(20) NOT NULL, -- by_percent, by_quantity, by_unit -- Avance por Porcentaje previous_percent DECIMAL(5,2) DEFAULT 0, current_percent DECIMAL(5,2), increment_percent DECIMAL(5,2) GENERATED ALWAYS AS (current_percent - previous_percent) STORED, -- Avance por Cantidad previous_quantity DECIMAL(12,4) DEFAULT 0, current_quantity DECIMAL(12,4), increment_quantity DECIMAL(12,4) GENERATED ALWAYS AS (current_quantity - previous_quantity) STORED, budgeted_quantity DECIMAL(12,4), unit VARCHAR(20), -- Recursos crew_id UUID REFERENCES projects.crews(id), labor_hours DECIMAL(8,2), -- horas-hombre trabajadas -- Descripción y observaciones description TEXT, notes TEXT, -- Evidencias photos VARCHAR[], -- array de URLs de fotos has_photos BOOLEAN GENERATED ALWAYS AS (ARRAY_LENGTH(photos, 1) > 0) STORED, -- Geolocalización geolocation GEOMETRY(POINT, 4326), -- PostGIS geo_accuracy DECIMAL(8,2), -- precisión en metros distance_from_site DECIMAL(8,2), -- distancia del sitio en metros geo_verified BOOLEAN DEFAULT false, -- Metadata de captura recorded_by UUID NOT NULL REFERENCES auth.users(id), recorded_via VARCHAR(20) NOT NULL, -- web, mobile, api recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, device_info JSONB, -- información del dispositivo móvil -- Flujo de aprobación status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, submitted, approved, rejected, cancelled submitted_at TIMESTAMP, reviewed_by UUID REFERENCES auth.users(id), reviewed_at TIMESTAMP, review_notes TEXT, approval_level INTEGER DEFAULT 1, -- nivel de aprobación requerido -- Metadata created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Constraints CONSTRAINT valid_record_type CHECK (record_type IN ('by_percent', 'by_quantity', 'by_unit')), CONSTRAINT valid_status CHECK (status IN ('draft', 'submitted', 'approved', 'rejected', 'cancelled')), CONSTRAINT valid_percent CHECK ( (record_type = 'by_percent' AND current_percent >= 0 AND current_percent <= 100) OR record_type <> 'by_percent' ), CONSTRAINT valid_quantity CHECK ( (record_type = 'by_quantity' AND current_quantity >= 0) OR record_type <> 'by_quantity' ) ); CREATE INDEX idx_progress_project ON progress.progress_records(project_id); CREATE INDEX idx_progress_activity ON progress.progress_records(activity_id); CREATE INDEX idx_progress_unit ON progress.progress_records(unit_id); CREATE INDEX idx_progress_date ON progress.progress_records(record_date); CREATE INDEX idx_progress_status ON progress.progress_records(status); CREATE INDEX idx_progress_recorded_by ON progress.progress_records(recorded_by); CREATE INDEX idx_progress_crew ON progress.progress_records(crew_id); -- Índice espacial para búsquedas geográficas CREATE INDEX idx_progress_geolocation ON progress.progress_records USING GIST(geolocation); -- ===================================================== -- TABLE: progress.unit_progress -- Descripción: Seguimiento de avance por vivienda/lote -- ===================================================== CREATE TABLE progress.unit_progress ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Relaciones unit_id UUID NOT NULL REFERENCES projects.units(id) ON DELETE CASCADE, activity_id UUID NOT NULL REFERENCES schedules.schedule_activities(id), budget_item_id UUID REFERENCES budgets.budget_items(id), stage_id UUID REFERENCES projects.stages(id), -- Avance percent_complete DECIMAL(5,2) NOT NULL DEFAULT 0, -- Fechas start_date DATE, completion_date DATE, planned_start_date DATE, planned_completion_date DATE, -- Duración actual_duration INTEGER, -- días planned_duration INTEGER, -- días -- Estado status VARCHAR(20) NOT NULL DEFAULT 'not_started', -- not_started, in_progress, completed, on_hold -- Última actualización last_progress_record_id UUID REFERENCES progress.progress_records(id), last_updated DATE, updated_by UUID REFERENCES auth.users(id), -- Metadata created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT valid_percent CHECK (percent_complete >= 0 AND percent_complete <= 100), CONSTRAINT valid_status CHECK (status IN ('not_started', 'in_progress', 'completed', 'on_hold')), UNIQUE(unit_id, activity_id) ); CREATE INDEX idx_unit_progress_unit ON progress.unit_progress(unit_id); CREATE INDEX idx_unit_progress_activity ON progress.unit_progress(activity_id); CREATE INDEX idx_unit_progress_status ON progress.unit_progress(status); CREATE INDEX idx_unit_progress_stage ON progress.unit_progress(stage_id); -- ===================================================== -- TABLE: progress.batch_progress_updates -- Descripción: Actualizaciones masivas de avance -- ===================================================== CREATE TABLE progress.batch_progress_updates ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Relaciones project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE, -- Identificación batch_code VARCHAR(50) NOT NULL, batch_date DATE NOT NULL, -- Criterios del batch activity_id UUID REFERENCES schedules.schedule_activities(id), stage_id UUID REFERENCES projects.stages(id), workfront_id UUID REFERENCES projects.workfronts(id), -- Filtros aplicados unit_filter JSONB, -- {"manzana": "A", "tipo": "T1"} -- Actualización update_type VARCHAR(20) NOT NULL, -- set_percent, increment_percent, set_quantity update_value DECIMAL(12,4) NOT NULL, -- Unidades afectadas units_affected UUID[], -- array de unit_ids total_units INTEGER, -- Resultado status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, processing, completed, failed records_created INTEGER DEFAULT 0, records_failed INTEGER DEFAULT 0, error_log JSONB, -- Metadata created_by UUID NOT NULL REFERENCES auth.users(id), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, processed_at TIMESTAMP, CONSTRAINT valid_update_type CHECK (update_type IN ('set_percent', 'increment_percent', 'set_quantity')), CONSTRAINT valid_status CHECK (status IN ('pending', 'processing', 'completed', 'failed')) ); CREATE INDEX idx_batch_project ON progress.batch_progress_updates(project_id); CREATE INDEX idx_batch_status ON progress.batch_progress_updates(status); CREATE INDEX idx_batch_date ON progress.batch_progress_updates(batch_date); -- ===================================================== -- TABLE: progress.approval_workflows -- Descripción: Configuración de flujos de aprobación -- ===================================================== CREATE TABLE progress.approval_workflows ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Relaciones project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE, -- Identificación workflow_name VARCHAR(100) NOT NULL, description TEXT, -- Niveles de aprobación levels JSONB NOT NULL, /* [{ level: 1, role: "site_supervisor", requiredApprovers: 1, autoApproveIfBelow: 10000 // monto o % threshold }, { level: 2, role: "project_manager", requiredApprovers: 1 }] */ -- Condiciones applies_to_activities UUID[], -- actividades específicas applies_to_stages UUID[], -- etapas específicas min_amount_threshold DECIMAL(15,2), -- umbral mínimo que requiere aprobación -- Estado is_active BOOLEAN DEFAULT true, -- Metadata created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_workflows_project ON progress.approval_workflows(project_id); CREATE INDEX idx_workflows_active ON progress.approval_workflows(is_active) WHERE is_active = true; -- ===================================================== -- TABLE: progress.offline_sync_queue -- Descripción: Cola de sincronización para app móvil -- ===================================================== CREATE TABLE progress.offline_sync_queue ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Usuario y dispositivo user_id UUID NOT NULL REFERENCES auth.users(id), device_id VARCHAR(100) NOT NULL, -- Datos del registro offline local_id VARCHAR(100) NOT NULL, -- ID temporal del dispositivo payload JSONB NOT NULL, -- datos completos del registro -- Sincronización sync_status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, processing, synced, failed sync_attempts INTEGER DEFAULT 0, last_sync_attempt TIMESTAMP, sync_error TEXT, -- ID del registro creado tras sincronizar synced_record_id UUID REFERENCES progress.progress_records(id), -- Metadata created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, synced_at TIMESTAMP, CONSTRAINT valid_sync_status CHECK (sync_status IN ('pending', 'processing', 'synced', 'failed')), UNIQUE(user_id, device_id, local_id) ); CREATE INDEX idx_sync_queue_user ON progress.offline_sync_queue(user_id); CREATE INDEX idx_sync_queue_device ON progress.offline_sync_queue(device_id); CREATE INDEX idx_sync_queue_status ON progress.offline_sync_queue(sync_status); ``` --- ## 4. TypeORM Entities ### 4.1 ProgressRecord Entity ```typescript // src/modules/progress/entities/progress-record.entity.ts import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn, Index, } from 'typeorm'; import { Project } from '../../projects/entities/project.entity'; import { Stage } from '../../projects/entities/stage.entity'; import { Workfront } from '../../projects/entities/workfront.entity'; import { ScheduleActivity } from '../../schedules/entities/schedule-activity.entity'; import { Unit } from '../../projects/entities/unit.entity'; import { User } from '../../auth/entities/user.entity'; import { Crew } from '../../projects/entities/crew.entity'; export enum RecordType { BY_PERCENT = 'by_percent', BY_QUANTITY = 'by_quantity', BY_UNIT = 'by_unit', } export enum RecordStatus { DRAFT = 'draft', SUBMITTED = 'submitted', APPROVED = 'approved', REJECTED = 'rejected', CANCELLED = 'cancelled', } export enum RecordedVia { WEB = 'web', MOBILE = 'mobile', API = 'api', } @Entity('progress_records', { schema: 'progress' }) export class ProgressRecord { @PrimaryGeneratedColumn('uuid') id: string; // Relaciones @Column('uuid', { name: 'project_id' }) @Index() projectId: string; @ManyToOne(() => Project, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'project_id' }) project: Project; @Column({ type: 'uuid', nullable: true, name: 'stage_id' }) @Index() stageId?: string; @ManyToOne(() => Stage) @JoinColumn({ name: 'stage_id' }) stage?: Stage; @Column({ type: 'uuid', nullable: true, name: 'workfront_id' }) workfrontId?: string; @ManyToOne(() => Workfront) @JoinColumn({ name: 'workfront_id' }) workfront?: Workfront; @Column({ type: 'uuid', nullable: true, name: 'activity_id' }) @Index() activityId?: string; @ManyToOne(() => ScheduleActivity) @JoinColumn({ name: 'activity_id' }) activity?: ScheduleActivity; @Column({ type: 'uuid', nullable: true, name: 'unit_id' }) @Index() unitId?: string; @ManyToOne(() => Unit) @JoinColumn({ name: 'unit_id' }) unit?: Unit; // Identificación @Column({ type: 'varchar', length: 50, name: 'record_code' }) recordCode: string; @Column({ type: 'date', name: 'record_date' }) @Index() recordDate: Date; // Tipo de registro @Column({ type: 'enum', enum: RecordType, name: 'record_type' }) recordType: RecordType; // Avance por Porcentaje @Column({ type: 'decimal', precision: 5, scale: 2, default: 0, name: 'previous_percent' }) previousPercent: number; @Column({ type: 'decimal', precision: 5, scale: 2, nullable: true, name: 'current_percent' }) currentPercent?: number; // Avance por Cantidad @Column({ type: 'decimal', precision: 12, scale: 4, default: 0, name: 'previous_quantity' }) previousQuantity: number; @Column({ type: 'decimal', precision: 12, scale: 4, nullable: true, name: 'current_quantity' }) currentQuantity?: number; @Column({ type: 'decimal', precision: 12, scale: 4, nullable: true, name: 'budgeted_quantity' }) budgetedQuantity?: number; @Column({ type: 'varchar', length: 20, nullable: true }) unit?: string; // Recursos @Column({ type: 'uuid', nullable: true, name: 'crew_id' }) @Index() crewId?: string; @ManyToOne(() => Crew) @JoinColumn({ name: 'crew_id' }) crew?: Crew; @Column({ type: 'decimal', precision: 8, scale: 2, nullable: true, name: 'labor_hours' }) laborHours?: number; // Descripción @Column({ type: 'text', nullable: true }) description?: string; @Column({ type: 'text', nullable: true }) notes?: string; // Evidencias @Column({ type: 'varchar', array: true, default: '{}' }) photos: string[]; // Geolocalización (PostGIS) @Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326, nullable: true, }) @Index({ spatial: true }) geolocation?: string; // GeoJSON format @Column({ type: 'decimal', precision: 8, scale: 2, nullable: true, name: 'geo_accuracy' }) geoAccuracy?: number; @Column({ type: 'decimal', precision: 8, scale: 2, nullable: true, name: 'distance_from_site' }) distanceFromSite?: number; @Column({ type: 'boolean', default: false, name: 'geo_verified' }) geoVerified: boolean; // Metadata de captura @Column({ type: 'uuid', name: 'recorded_by' }) @Index() recordedBy: string; @ManyToOne(() => User) @JoinColumn({ name: 'recorded_by' }) recorder: User; @Column({ type: 'enum', enum: RecordedVia, name: 'recorded_via' }) recordedVia: RecordedVia; @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', name: 'recorded_at' }) recordedAt: Date; @Column({ type: 'jsonb', nullable: true, name: 'device_info' }) deviceInfo?: any; // Flujo de aprobación @Column({ type: 'enum', enum: RecordStatus, default: RecordStatus.DRAFT }) @Index() status: RecordStatus; @Column({ type: 'timestamp', nullable: true, name: 'submitted_at' }) submittedAt?: Date; @Column({ type: 'uuid', nullable: true, name: 'reviewed_by' }) reviewedBy?: string; @ManyToOne(() => User) @JoinColumn({ name: 'reviewed_by' }) reviewer?: User; @Column({ type: 'timestamp', nullable: true, name: 'reviewed_at' }) reviewedAt?: Date; @Column({ type: 'text', nullable: true, name: 'review_notes' }) reviewNotes?: string; @Column({ type: 'integer', default: 1, name: 'approval_level' }) approvalLevel: number; // Metadata @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; // Computed properties get incrementPercent(): number { if (this.recordType === RecordType.BY_PERCENT && this.currentPercent !== undefined) { return this.currentPercent - this.previousPercent; } return 0; } get incrementQuantity(): number { if (this.recordType === RecordType.BY_QUANTITY && this.currentQuantity !== undefined) { return this.currentQuantity - this.previousQuantity; } return 0; } get hasPhotos(): boolean { return this.photos && this.photos.length > 0; } } ``` ### 4.2 UnitProgress Entity ```typescript // src/modules/progress/entities/unit-progress.entity.ts import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn, Index, } from 'typeorm'; import { Unit } from '../../projects/entities/unit.entity'; import { ScheduleActivity } from '../../schedules/entities/schedule-activity.entity'; import { Stage } from '../../projects/entities/stage.entity'; import { ProgressRecord } from './progress-record.entity'; import { User } from '../../auth/entities/user.entity'; export enum UnitProgressStatus { NOT_STARTED = 'not_started', IN_PROGRESS = 'in_progress', COMPLETED = 'completed', ON_HOLD = 'on_hold', } @Entity('unit_progress', { schema: 'progress' }) @Index(['unitId', 'activityId'], { unique: true }) export class UnitProgress { @PrimaryGeneratedColumn('uuid') id: string; // Relaciones @Column('uuid', { name: 'unit_id' }) @Index() unitId: string; @ManyToOne(() => Unit, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'unit_id' }) unit: Unit; @Column('uuid', { name: 'activity_id' }) @Index() activityId: string; @ManyToOne(() => ScheduleActivity) @JoinColumn({ name: 'activity_id' }) activity: ScheduleActivity; @Column({ type: 'uuid', nullable: true, name: 'stage_id' }) @Index() stageId?: string; @ManyToOne(() => Stage) @JoinColumn({ name: 'stage_id' }) stage?: Stage; // Avance @Column({ type: 'decimal', precision: 5, scale: 2, default: 0, name: 'percent_complete' }) percentComplete: number; // Fechas @Column({ type: 'date', nullable: true, name: 'start_date' }) startDate?: Date; @Column({ type: 'date', nullable: true, name: 'completion_date' }) completionDate?: Date; @Column({ type: 'date', nullable: true, name: 'planned_start_date' }) plannedStartDate?: Date; @Column({ type: 'date', nullable: true, name: 'planned_completion_date' }) plannedCompletionDate?: Date; // Duración @Column({ type: 'integer', nullable: true, name: 'actual_duration' }) actualDuration?: number; @Column({ type: 'integer', nullable: true, name: 'planned_duration' }) plannedDuration?: number; // Estado @Column({ type: 'enum', enum: UnitProgressStatus, default: UnitProgressStatus.NOT_STARTED }) @Index() status: UnitProgressStatus; // Última actualización @Column({ type: 'uuid', nullable: true, name: 'last_progress_record_id' }) lastProgressRecordId?: string; @ManyToOne(() => ProgressRecord) @JoinColumn({ name: 'last_progress_record_id' }) lastProgressRecord?: ProgressRecord; @Column({ type: 'date', nullable: true, name: 'last_updated' }) lastUpdated?: Date; @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) updatedBy?: string; @ManyToOne(() => User) @JoinColumn({ name: 'updated_by' }) updater?: User; // Metadata @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; } ``` --- ## 5. Services (Lógica de Negocio) ### 5.1 ProgressRecordService ```typescript // src/modules/progress/services/progress-record.service.ts import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { ProgressRecord, RecordStatus, RecordType } from '../entities/progress-record.entity'; import { UnitProgress } from '../entities/unit-progress.entity'; import { CreateProgressRecordDto, ApproveProgressDto } from '../dto'; import { GeolocationService } from './geolocation.service'; @Injectable() export class ProgressRecordService { constructor( @InjectRepository(ProgressRecord) private progressRepo: Repository, @InjectRepository(UnitProgress) private unitProgressRepo: Repository, private geoService: GeolocationService, private eventEmitter: EventEmitter2, ) {} /** * Crear registro de avance */ async create(dto: CreateProgressRecordDto, userId: string): Promise { // Generar código const year = new Date().getFullYear(); const count = await this.progressRepo.count({ where: { projectId: dto.projectId } }); const code = `AVN-${year}-${String(count + 1).padStart(5, '0')}`; // Obtener valor anterior (previous) let previousPercent = 0; let previousQuantity = 0; if (dto.recordType === RecordType.BY_PERCENT && dto.activityId) { const lastRecord = await this.progressRepo.findOne({ where: { activityId: dto.activityId, status: RecordStatus.APPROVED, }, order: { recordDate: 'DESC' }, }); previousPercent = lastRecord?.currentPercent || 0; } if (dto.recordType === RecordType.BY_QUANTITY && dto.activityId) { const lastRecord = await this.progressRepo.findOne({ where: { activityId: dto.activityId, status: RecordStatus.APPROVED, }, order: { recordDate: 'DESC' }, }); previousQuantity = lastRecord?.currentQuantity || 0; } // Validar geolocalización si está presente let geoVerified = false; let distanceFromSite = null; if (dto.geolocation) { const validation = await this.geoService.validateLocation( dto.geolocation, dto.projectId, ); geoVerified = validation.isValid; distanceFromSite = validation.distance; } const record = this.progressRepo.create({ ...dto, recordCode: code, previousPercent, previousQuantity, geoVerified, distanceFromSite, recordedBy: userId, status: RecordStatus.DRAFT, }); const saved = await this.progressRepo.save(record); // Emitir evento this.eventEmitter.emit('progress.record.created', { record: saved }); return saved; } /** * Enviar para aprobación */ async submit(id: string): Promise { const record = await this.findOne(id); if (record.status !== RecordStatus.DRAFT) { throw new BadRequestException('Only draft records can be submitted'); } // Validaciones antes de enviar this.validateRecord(record); record.status = RecordStatus.SUBMITTED; record.submittedAt = new Date(); const updated = await this.progressRepo.save(record); // Emitir evento para notificaciones this.eventEmitter.emit('progress.record.submitted', { record: updated }); return updated; } /** * Aprobar registro de avance */ async approve(id: string, dto: ApproveProgressDto, userId: string): Promise { const record = await this.findOne(id); if (record.status !== RecordStatus.SUBMITTED) { throw new BadRequestException('Only submitted records can be approved'); } record.status = RecordStatus.APPROVED; record.reviewedBy = userId; record.reviewedAt = new Date(); record.reviewNotes = dto.notes; const approved = await this.progressRepo.save(record); // Actualizar el avance de la actividad await this.updateActivityProgress(approved); // Si tiene unitId, actualizar avance por unidad if (approved.unitId && approved.activityId) { await this.updateUnitProgress(approved); } // Emitir evento para actualizar Curva S this.eventEmitter.emit('progress.record.approved', { record: approved }); return approved; } /** * Rechazar registro de avance */ async reject(id: string, reason: string, userId: string): Promise { const record = await this.findOne(id); if (record.status !== RecordStatus.SUBMITTED) { throw new BadRequestException('Only submitted records can be rejected'); } record.status = RecordStatus.REJECTED; record.reviewedBy = userId; record.reviewedAt = new Date(); record.reviewNotes = reason; const rejected = await this.progressRepo.save(record); // Emitir evento para notificación this.eventEmitter.emit('progress.record.rejected', { record: rejected }); return rejected; } /** * Actualizar avance de la actividad del programa */ private async updateActivityProgress(record: ProgressRecord): Promise { if (!record.activityId) return; // Calcular el avance total de la actividad const approvedRecords = await this.progressRepo.find({ where: { activityId: record.activityId, status: RecordStatus.APPROVED, }, order: { recordDate: 'DESC' }, }); if (approvedRecords.length === 0) return; // El último registro aprobado es el avance actual const latestRecord = approvedRecords[0]; const percentComplete = latestRecord.currentPercent || 0; // Actualizar la actividad en el schedule await this.progressRepo.manager.query( ` UPDATE schedules.schedule_activities SET percent_complete = $1, actual_quantity = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3 `, [percentComplete, latestRecord.currentQuantity, record.activityId] ); } /** * Actualizar avance por unidad */ private async updateUnitProgress(record: ProgressRecord): Promise { if (!record.unitId || !record.activityId) return; let unitProgress = await this.unitProgressRepo.findOne({ where: { unitId: record.unitId, activityId: record.activityId, }, }); const percentComplete = record.currentPercent || 0; if (!unitProgress) { // Crear nuevo registro unitProgress = this.unitProgressRepo.create({ unitId: record.unitId, activityId: record.activityId, stageId: record.stageId, percentComplete, status: percentComplete >= 100 ? 'completed' : percentComplete > 0 ? 'in_progress' : 'not_started', startDate: percentComplete > 0 ? record.recordDate : null, completionDate: percentComplete >= 100 ? record.recordDate : null, lastProgressRecordId: record.id, lastUpdated: record.recordDate, updatedBy: record.recordedBy, }); } else { // Actualizar existente unitProgress.percentComplete = percentComplete; unitProgress.status = percentComplete >= 100 ? 'completed' : percentComplete > 0 ? 'in_progress' : 'not_started'; unitProgress.lastProgressRecordId = record.id; unitProgress.lastUpdated = record.recordDate; unitProgress.updatedBy = record.recordedBy; if (!unitProgress.startDate && percentComplete > 0) { unitProgress.startDate = record.recordDate; } if (percentComplete >= 100 && !unitProgress.completionDate) { unitProgress.completionDate = record.recordDate; } } await this.unitProgressRepo.save(unitProgress); } /** * Validar registro antes de enviar */ private validateRecord(record: ProgressRecord): void { if (record.recordType === RecordType.BY_PERCENT) { if (record.currentPercent === null || record.currentPercent === undefined) { throw new BadRequestException('Current percent is required for percent-based records'); } if (record.currentPercent < record.previousPercent) { throw new BadRequestException('Current percent cannot be less than previous percent'); } } if (record.recordType === RecordType.BY_QUANTITY) { if (record.currentQuantity === null || record.currentQuantity === undefined) { throw new BadRequestException('Current quantity is required for quantity-based records'); } if (record.currentQuantity < record.previousQuantity) { throw new BadRequestException('Current quantity cannot be less than previous quantity'); } } // Validar geolocalización si es requerida if (!record.geolocation) { throw new BadRequestException('Geolocation is required'); } if (!record.geoVerified) { throw new BadRequestException('Location is too far from project site'); } } /** * Obtener registros pendientes de aprobación */ async getPendingApprovals(projectId: string): Promise { return this.progressRepo.find({ where: { projectId, status: RecordStatus.SUBMITTED, }, relations: ['activity', 'unit', 'recorder', 'crew'], order: { submittedAt: 'ASC' }, }); } async findOne(id: string): Promise { const record = await this.progressRepo.findOne({ where: { id }, relations: ['project', 'activity', 'unit', 'recorder', 'reviewer', 'crew'], }); if (!record) { throw new NotFoundException(`Progress record ${id} not found`); } return record; } } ``` ### 5.2 BatchProgressService ```typescript // src/modules/progress/services/batch-progress.service.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; import { InjectQueue } from '@nestjs/bull'; import { Queue } from 'bull'; import { BatchProgressUpdate } from '../entities/batch-progress-update.entity'; import { Unit } from '../../projects/entities/unit.entity'; import { ProgressRecordService } from './progress-record.service'; import { CreateBatchUpdateDto } from '../dto'; @Injectable() export class BatchProgressService { constructor( @InjectRepository(BatchProgressUpdate) private batchRepo: Repository, @InjectRepository(Unit) private unitRepo: Repository, private progressService: ProgressRecordService, @InjectQueue('batch-progress') private batchQueue: Queue, ) {} /** * Crear actualización masiva de avances */ async createBatchUpdate(dto: CreateBatchUpdateDto, userId: string): Promise { // Generar código const year = new Date().getFullYear(); const count = await this.batchRepo.count(); const code = `BATCH-${year}-${String(count + 1).padStart(5, '0')}`; // Obtener unidades que cumplen con los filtros const query = this.unitRepo.createQueryBuilder('unit') .where('unit.project_id = :projectId', { projectId: dto.projectId }); if (dto.unitFilter) { Object.entries(dto.unitFilter).forEach(([key, value]) => { query.andWhere(`unit.${key} = :${key}`, { [key]: value }); }); } const units = await query.getMany(); const unitsAffected = units.map((u) => u.id); const batch = this.batchRepo.create({ ...dto, batchCode: code, unitsAffected, totalUnits: units.length, createdBy: userId, status: 'pending', }); const saved = await this.batchRepo.save(batch); // Encolar procesamiento asíncrono await this.batchQueue.add('process-batch', { batchId: saved.id }); return saved; } /** * Procesar batch (ejecutado por worker) */ async processBatch(batchId: string): Promise { const batch = await this.batchRepo.findOne({ where: { id: batchId } }); if (!batch) return; batch.status = 'processing'; await this.batchRepo.save(batch); let recordsCreated = 0; let recordsFailed = 0; const errorLog = []; try { for (const unitId of batch.unitsAffected) { try { // Crear registro de avance para cada unidad await this.progressService.create( { projectId: batch.projectId, activityId: batch.activityId, stageId: batch.stageId, workfrontId: batch.workfrontId, unitId, recordType: batch.updateType === 'set_quantity' ? 'by_quantity' : 'by_percent', recordDate: batch.batchDate, currentPercent: batch.updateType !== 'set_quantity' ? batch.updateValue : undefined, currentQuantity: batch.updateType === 'set_quantity' ? batch.updateValue : undefined, recordedVia: 'api', }, batch.createdBy, ); recordsCreated++; } catch (error) { recordsFailed++; errorLog.push({ unitId, error: error.message, }); } } batch.status = 'completed'; batch.recordsCreated = recordsCreated; batch.recordsFailed = recordsFailed; batch.errorLog = errorLog; batch.processedAt = new Date(); } catch (error) { batch.status = 'failed'; batch.errorLog = [{ error: error.message }]; } await this.batchRepo.save(batch); } } ``` --- ## 6. Controllers (API Endpoints) ```typescript // src/modules/progress/controllers/progress-record.controller.ts import { Controller, Get, Post, Put, Param, Body, Query, UseGuards, Request, UploadedFiles, UseInterceptors, } from '@nestjs/common'; import { FilesInterceptor } from '@nestjs/platform-express'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../../auth/guards/roles.guard'; import { Roles } from '../../auth/decorators/roles.decorator'; import { ProgressRecordService } from '../services/progress-record.service'; import { BatchProgressService } from '../services/batch-progress.service'; import { CreateProgressRecordDto, ApproveProgressDto, CreateBatchUpdateDto, } from '../dto'; @Controller('api/progress') @UseGuards(JwtAuthGuard, RolesGuard) export class ProgressRecordController { constructor( private progressService: ProgressRecordService, private batchService: BatchProgressService, ) {} /** * POST /api/progress/records * Crear registro de avance */ @Post('records') @Roles('site_supervisor', 'project_manager', 'admin') @UseInterceptors(FilesInterceptor('photos', 10)) async create( @Body() dto: CreateProgressRecordDto, @UploadedFiles() files: Express.Multer.File[], @Request() req, ) { // Subir fotos a storage y obtener URLs const photoUrls = files ? await this.uploadPhotos(files) : []; dto.photos = photoUrls; return this.progressService.create(dto, req.user.sub); } /** * POST /api/progress/records/:id/submit * Enviar para aprobación */ @Post('records/:id/submit') @Roles('site_supervisor', 'project_manager', 'admin') async submit(@Param('id') id: string) { return this.progressService.submit(id); } /** * POST /api/progress/records/:id/approve * Aprobar registro de avance */ @Post('records/:id/approve') @Roles('project_manager', 'admin') async approve( @Param('id') id: string, @Body() dto: ApproveProgressDto, @Request() req, ) { return this.progressService.approve(id, dto, req.user.sub); } /** * POST /api/progress/records/:id/reject * Rechazar registro de avance */ @Post('records/:id/reject') @Roles('project_manager', 'admin') async reject( @Param('id') id: string, @Body() dto: { reason: string }, @Request() req, ) { return this.progressService.reject(id, dto.reason, req.user.sub); } /** * GET /api/progress/records/:id * Obtener detalle de un registro */ @Get('records/:id') async findOne(@Param('id') id: string) { return this.progressService.findOne(id); } /** * GET /api/progress/projects/:projectId/pending-approvals * Obtener registros pendientes de aprobación */ @Get('projects/:projectId/pending-approvals') @Roles('project_manager', 'admin') async getPendingApprovals(@Param('projectId') projectId: string) { return this.progressService.getPendingApprovals(projectId); } /** * POST /api/progress/batch-updates * Crear actualización masiva */ @Post('batch-updates') @Roles('project_manager', 'admin') async createBatchUpdate(@Body() dto: CreateBatchUpdateDto, @Request() req) { return this.batchService.createBatchUpdate(dto, req.user.sub); } private async uploadPhotos(files: Express.Multer.File[]): Promise { // Implementar subida a AWS S3, Google Cloud Storage, etc. // Por ahora, retornar URLs simuladas return files.map((file, index) => `/uploads/progress/${Date.now()}_${index}.jpg`); } } ``` --- ## 7. Triggers y Stored Procedures ```sql -- ===================================================== -- TRIGGER: Actualizar campos calculados automáticamente -- ===================================================== CREATE OR REPLACE FUNCTION progress.calculate_increments() RETURNS TRIGGER AS $$ BEGIN -- Calcular increment_percent IF NEW.record_type = 'by_percent' THEN NEW.increment_percent := NEW.current_percent - NEW.previous_percent; END IF; -- Calcular increment_quantity IF NEW.record_type = 'by_quantity' THEN NEW.increment_quantity := NEW.current_quantity - NEW.previous_quantity; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_calculate_increments BEFORE INSERT OR UPDATE ON progress.progress_records FOR EACH ROW EXECUTE FUNCTION progress.calculate_increments(); -- ===================================================== -- STORED PROCEDURE: Obtener resumen de avances por proyecto -- ===================================================== CREATE OR REPLACE FUNCTION progress.get_project_progress_summary(p_project_id UUID) RETURNS TABLE( total_activities INTEGER, activities_not_started INTEGER, activities_in_progress INTEGER, activities_completed INTEGER, overall_progress_pct DECIMAL(5,2), pending_approvals INTEGER ) AS $$ BEGIN RETURN QUERY WITH activity_progress AS ( SELECT sa.id, sa.percent_complete, sa.status FROM schedules.schedule_activities sa INNER JOIN schedules.schedules s ON sa.schedule_id = s.id WHERE s.project_id = p_project_id AND s.status = 'active' ) SELECT COUNT(*)::INTEGER AS total_activities, COUNT(*) FILTER (WHERE status = 'not_started')::INTEGER AS activities_not_started, COUNT(*) FILTER (WHERE status = 'in_progress')::INTEGER AS activities_in_progress, COUNT(*) FILTER (WHERE status = 'completed')::INTEGER AS activities_completed, COALESCE(AVG(percent_complete), 0)::DECIMAL(5,2) AS overall_progress_pct, (SELECT COUNT(*)::INTEGER FROM progress.progress_records WHERE project_id = p_project_id AND status = 'submitted') AS pending_approvals FROM activity_progress; END; $$ LANGUAGE plpgsql; ``` --- ## 8. Criterios de Aceptación Técnicos - [x] Schema `progress` creado con tablas y relaciones - [x] Entities TypeORM con decoradores correctos - [x] Services con lógica de aprobación de avances - [x] Validaciones de integridad (no permitir retrocesos) - [x] Actualización automática de actividades del schedule - [x] Actualización automática de avances por unidad - [x] Eventos emitidos para actualización de Curva S - [x] Endpoints REST completamente funcionales - [x] Soporte para geolocalización con PostGIS - [x] Validación de distancia del sitio - [x] Batch updates con procesamiento asíncrono (Bull) - [x] Cola offline para sincronización móvil - [x] Triggers para cálculos automáticos - [x] Tests unitarios >80% coverage --- **Fecha:** 2025-11-17 **Preparado por:** Equipo Técnico **Versión:** 1.0 **Estado:** ✅ Listo para Implementación