erp-construccion/docs/02-definicion-modulos/MAI-005-control-obra-avances/especificaciones/ET-PROG-002-implementacion-captura-avances.md

40 KiB

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

- 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

- React 18 con TypeScript
- React Hook Form para formularios
- Zustand para state management
- React Query para cache y sync
- Leaflet para mapas

Mobile App

- 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

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

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

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

// 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<ProgressRecord>,
    @InjectRepository(UnitProgress)
    private unitProgressRepo: Repository<UnitProgress>,
    private geoService: GeolocationService,
    private eventEmitter: EventEmitter2,
  ) {}

  /**
   * Crear registro de avance
   */
  async create(dto: CreateProgressRecordDto, userId: string): Promise<ProgressRecord> {
    // 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<ProgressRecord> {
    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<ProgressRecord> {
    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<ProgressRecord> {
    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<void> {
    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<void> {
    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<ProgressRecord[]> {
    return this.progressRepo.find({
      where: {
        projectId,
        status: RecordStatus.SUBMITTED,
      },
      relations: ['activity', 'unit', 'recorder', 'crew'],
      order: { submittedAt: 'ASC' },
    });
  }

  async findOne(id: string): Promise<ProgressRecord> {
    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

// 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<BatchProgressUpdate>,
    @InjectRepository(Unit)
    private unitRepo: Repository<Unit>,
    private progressService: ProgressRecordService,
    @InjectQueue('batch-progress')
    private batchQueue: Queue,
  ) {}

  /**
   * Crear actualización masiva de avances
   */
  async createBatchUpdate(dto: CreateBatchUpdateDto, userId: string): Promise<BatchProgressUpdate> {
    // 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<void> {
    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)

// 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<string[]> {
    // 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

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

  • Schema progress creado con tablas y relaciones
  • Entities TypeORM con decoradores correctos
  • Services con lógica de aprobación de avances
  • Validaciones de integridad (no permitir retrocesos)
  • Actualización automática de actividades del schedule
  • Actualización automática de avances por unidad
  • Eventos emitidos para actualización de Curva S
  • Endpoints REST completamente funcionales
  • Soporte para geolocalización con PostGIS
  • Validación de distancia del sitio
  • Batch updates con procesamiento asíncrono (Bull)
  • Cola offline para sincronización móvil
  • Triggers para cálculos automáticos
  • Tests unitarios >80% coverage

Fecha: 2025-11-17 Preparado por: Equipo Técnico Versión: 1.0 Estado: Listo para Implementación