1378 lines
40 KiB
Markdown
1378 lines
40 KiB
Markdown
# 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<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
|
|
|
|
```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<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)
|
|
|
|
```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<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
|
|
|
|
```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
|