40 KiB
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
progresscreado 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