52 KiB
52 KiB
ET-BI-003: Implementación de Análisis Predictivo
Épica: MAI-006 - Reportes y Business Intelligence Módulo: Análisis Predictivo y Machine Learning Responsable Técnico: Data Science + Backend + Frontend Fecha: 2025-11-17 Versión: 1.0
1. Objetivo Técnico
Implementar el sistema de análisis predictivo con:
- Predicción de costos y cronogramas usando ML
- Modelos de regresión lineal, ARIMA, Random Forest
- Simulación de escenarios (optimista, realista, pesimista)
- Detección de anomalías en costos
- Forecasting de flujo de caja
- Entrenamiento y re-entrenamiento automático de modelos
- Integración con scikit-learn y TensorFlow.js
- Dashboard de métricas de modelos (accuracy, RMSE, MAE)
2. Stack Tecnológico
Backend
- NestJS 10+ con TypeScript
- PostgreSQL 15+ (schema: predictions)
- Python 3.11+ para ML (Flask micro-service)
- Bull/BullMQ para procesamiento asíncrono
- node-cron para re-entrenamiento programado
ML/Data Science
- scikit-learn 1.3+ (regresión, clasificación)
- statsmodels (ARIMA, time series)
- pandas, numpy para data processing
- joblib para serialización de modelos
- TensorFlow 2.14+ (opcional para deep learning)
Frontend
- React 18 con TypeScript
- Chart.js / Recharts para visualizaciones
- react-query para cache de predicciones
- Zustand para state management
3. Modelo de Datos SQL
3.1 Schema Principal
-- =====================================================
-- SCHEMA: predictions
-- Descripción: Análisis predictivo y ML
-- =====================================================
CREATE SCHEMA IF NOT EXISTS predictions;
-- =====================================================
-- TABLE: predictions.ml_models
-- Descripción: Registro de modelos de ML
-- =====================================================
CREATE TABLE predictions.ml_models (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
constructora_id UUID NOT NULL REFERENCES public.constructoras(id) ON DELETE CASCADE,
-- Identificación
model_code VARCHAR(50) NOT NULL,
model_name VARCHAR(255) NOT NULL,
description TEXT,
-- Tipo de modelo
model_type VARCHAR(30) NOT NULL,
-- linear_regression, random_forest, arima, xgboost, neural_network
prediction_target VARCHAR(50) NOT NULL,
-- cost_overrun, schedule_delay, margin_erosion, cash_flow, risk_score
-- Algoritmo
algorithm VARCHAR(50) NOT NULL,
hyperparameters JSONB DEFAULT '{}',
/*
{
"n_estimators": 100,
"max_depth": 10,
"learning_rate": 0.01
}
*/
-- Features utilizadas
features JSONB NOT NULL,
/*
[
{"name": "project_size", "type": "numeric", "importance": 0.35},
{"name": "project_complexity", "type": "categorical", "importance": 0.25},
{"name": "historical_variance", "type": "numeric", "importance": 0.20}
]
*/
-- Métricas de desempeño
training_metrics JSONB,
/*
{
"train_score": 0.85,
"test_score": 0.78,
"cross_val_score": 0.80,
"rmse": 125000,
"mae": 98000,
"r2_score": 0.82
}
*/
-- Versión del modelo
version INTEGER NOT NULL DEFAULT 1,
parent_model_id UUID REFERENCES predictions.ml_models(id),
-- Archivos
model_file_path VARCHAR(500), -- /models/cost_prediction_v1.joblib
training_data_path VARCHAR(500),
-- Estado
status VARCHAR(20) NOT NULL DEFAULT 'training',
-- training, trained, validated, deployed, deprecated
is_active BOOLEAN DEFAULT false,
-- Metadata
trained_by UUID REFERENCES auth.users(id),
trained_at TIMESTAMP,
last_retrained_at TIMESTAMP,
deployed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT valid_model_type CHECK (model_type IN (
'linear_regression', 'random_forest', 'arima', 'xgboost', 'neural_network'
)),
CONSTRAINT valid_status CHECK (status IN (
'training', 'trained', 'validated', 'deployed', 'deprecated'
)),
UNIQUE(constructora_id, model_code, version)
);
CREATE INDEX idx_ml_models_constructora ON predictions.ml_models(constructora_id);
CREATE INDEX idx_ml_models_type ON predictions.ml_models(model_type);
CREATE INDEX idx_ml_models_active ON predictions.ml_models(is_active) WHERE is_active = true;
-- =====================================================
-- TABLE: predictions.cost_predictions
-- Descripción: Predicciones de costos
-- =====================================================
CREATE TABLE predictions.cost_predictions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE,
model_id UUID NOT NULL REFERENCES predictions.ml_models(id),
-- Predicción
prediction_date DATE NOT NULL,
prediction_horizon INTEGER NOT NULL, -- días hacia el futuro
-- Costos predichos
predicted_total_cost DECIMAL(15,2) NOT NULL,
predicted_cost_variance DECIMAL(15,2), -- diferencia vs presupuesto
predicted_cost_variance_pct DECIMAL(5,2),
-- Intervalos de confianza
confidence_level DECIMAL(5,2) DEFAULT 95.00,
lower_bound DECIMAL(15,2),
upper_bound DECIMAL(15,2),
-- Features utilizadas en la predicción
input_features JSONB,
/*
{
"current_progress": 0.45,
"budget_utilization": 0.52,
"current_spi": 0.98,
"current_cpi": 1.05,
"remaining_duration": 120
}
*/
-- Resultado real (para validación posterior)
actual_cost DECIMAL(15,2),
prediction_error DECIMAL(15,2),
prediction_accuracy DECIMAL(5,2),
-- Metadata
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_cost_pred_project ON predictions.cost_predictions(project_id);
CREATE INDEX idx_cost_pred_model ON predictions.cost_predictions(model_id);
CREATE INDEX idx_cost_pred_date ON predictions.cost_predictions(prediction_date DESC);
-- =====================================================
-- TABLE: predictions.schedule_predictions
-- Descripción: Predicciones de cronograma
-- =====================================================
CREATE TABLE predictions.schedule_predictions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE,
schedule_id UUID NOT NULL REFERENCES schedules.schedules(id),
model_id UUID NOT NULL REFERENCES predictions.ml_models(id),
-- Predicción
prediction_date DATE NOT NULL,
-- Fechas predichas
predicted_completion_date DATE NOT NULL,
predicted_delay_days INTEGER,
baseline_completion_date DATE NOT NULL,
-- Probabilidades
on_time_probability DECIMAL(5,2),
delay_probability DECIMAL(5,2),
-- Actividades críticas en riesgo
critical_activities_at_risk JSONB,
/*
[
{
"activity_id": "uuid-1",
"activity_name": "Cimentación",
"delay_risk_score": 0.75,
"predicted_delay_days": 5
}
]
*/
-- Intervalos de confianza
confidence_level DECIMAL(5,2) DEFAULT 95.00,
earliest_completion DATE,
latest_completion DATE,
-- Features
input_features JSONB,
-- Resultado real
actual_completion_date DATE,
prediction_error_days INTEGER,
-- Metadata
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_schedule_pred_project ON predictions.schedule_predictions(project_id);
CREATE INDEX idx_schedule_pred_schedule ON predictions.schedule_predictions(schedule_id);
CREATE INDEX idx_schedule_pred_date ON predictions.schedule_predictions(prediction_date DESC);
-- =====================================================
-- TABLE: predictions.scenario_simulations
-- Descripción: Simulaciones de escenarios
-- =====================================================
CREATE TABLE predictions.scenario_simulations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE,
-- Identificación
simulation_name VARCHAR(255) NOT NULL,
description TEXT,
-- Tipo de escenario
scenario_type VARCHAR(20) NOT NULL,
-- optimistic, realistic, pessimistic, custom
-- Parámetros del escenario
parameters JSONB NOT NULL,
/*
{
"costVarianceFactor": 0.10, // +10%
"scheduleVarianceFactor": 0.15, // +15%
"productivityFactor": 0.95, // -5%
"weatherImpact": true,
"resourceAvailability": 0.90
}
*/
-- Resultados de la simulación
results JSONB,
/*
{
"projectedCost": 10500000,
"projectedCompletionDate": "2025-12-31",
"projectedMargin": 950000,
"projectedMarginPct": 9.0,
"riskScore": 65.5,
"criticalRisks": [...]
}
*/
-- Comparación con baseline
baseline_comparison JSONB,
/*
{
"costVariance": 500000,
"costVariancePct": 5.0,
"scheduleVarianceDays": 15,
"marginImpact": -100000
}
*/
-- Metadata
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT valid_scenario_type CHECK (scenario_type IN (
'optimistic', 'realistic', 'pessimistic', 'custom'
))
);
CREATE INDEX idx_simulations_project ON predictions.scenario_simulations(project_id);
CREATE INDEX idx_simulations_type ON predictions.scenario_simulations(scenario_type);
-- =====================================================
-- TABLE: predictions.training_data
-- Descripción: Datasets para entrenamiento
-- =====================================================
CREATE TABLE predictions.training_data (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
constructora_id UUID NOT NULL REFERENCES public.constructoras(id) ON DELETE CASCADE,
-- Identificación
dataset_name VARCHAR(255) NOT NULL,
description TEXT,
-- Tipo de dataset
dataset_type VARCHAR(30) NOT NULL,
-- historical_projects, cost_actuals, schedule_actuals, external_factors
-- Configuración
data_source JSONB NOT NULL,
/*
{
"sourceType": "sql_query",
"query": "SELECT...",
"filters": {...}
}
*/
-- Estadísticas del dataset
row_count INTEGER,
feature_count INTEGER,
date_range_start DATE,
date_range_end DATE,
statistics JSONB,
/*
{
"mean_cost": 8500000,
"std_cost": 2100000,
"min_cost": 3000000,
"max_cost": 25000000
}
*/
-- Archivos
file_path VARCHAR(500),
file_format VARCHAR(20), -- csv, parquet, json
-- Metadata
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_training_data_constructora ON predictions.training_data(constructora_id);
-- =====================================================
-- TABLE: predictions.forecast_history
-- Descripción: Historial de forecasts
-- =====================================================
CREATE TABLE predictions.forecast_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE,
-- Tipo de forecast
forecast_type VARCHAR(30) NOT NULL,
-- cash_flow, revenue, cost, resource_demand
forecast_date DATE NOT NULL,
forecast_period_start DATE NOT NULL,
forecast_period_end DATE NOT NULL,
-- Forecast
forecast_values JSONB NOT NULL,
/*
[
{"date": "2025-01", "value": 850000, "lower": 800000, "upper": 900000},
{"date": "2025-02", "value": 920000, "lower": 870000, "upper": 970000}
]
*/
-- Método
forecasting_method VARCHAR(30) NOT NULL,
-- arima, exponential_smoothing, prophet, linear_regression
model_id UUID REFERENCES predictions.ml_models(id),
-- Precisión (calculada después)
mape DECIMAL(5,2), -- Mean Absolute Percentage Error
forecast_bias DECIMAL(10,2),
-- Metadata
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_forecast_project ON predictions.forecast_history(project_id);
CREATE INDEX idx_forecast_type ON predictions.forecast_history(forecast_type);
CREATE INDEX idx_forecast_date ON predictions.forecast_history(forecast_date DESC);
-- =====================================================
-- TABLE: predictions.anomaly_detections
-- Descripción: Detección de anomalías
-- =====================================================
CREATE TABLE predictions.anomaly_detections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE,
-- Detección
detection_date DATE NOT NULL,
anomaly_type VARCHAR(30) NOT NULL,
-- cost_spike, schedule_slippage, margin_erosion, resource_overutilization
-- Severidad
severity VARCHAR(20) NOT NULL,
-- low, medium, high, critical
anomaly_score DECIMAL(5,2), -- 0-100
-- Detalles
description TEXT,
affected_metric VARCHAR(100),
expected_value DECIMAL(15,2),
actual_value DECIMAL(15,2),
deviation_pct DECIMAL(5,2),
-- Contexto
context JSONB,
/*
{
"activity_id": "uuid",
"budget_item_id": "uuid",
"period": "2025-11"
}
*/
-- Recomendaciones
recommendations TEXT[],
-- Estado
status VARCHAR(20) NOT NULL DEFAULT 'new',
-- new, acknowledged, investigating, resolved, false_positive
acknowledged_by UUID REFERENCES auth.users(id),
acknowledged_at TIMESTAMP,
resolution_notes TEXT,
-- Metadata
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT valid_severity CHECK (severity IN ('low', 'medium', 'high', 'critical')),
CONSTRAINT valid_status CHECK (status IN (
'new', 'acknowledged', 'investigating', 'resolved', 'false_positive'
))
);
CREATE INDEX idx_anomalies_project ON predictions.anomaly_detections(project_id);
CREATE INDEX idx_anomalies_type ON predictions.anomaly_detections(anomaly_type);
CREATE INDEX idx_anomalies_severity ON predictions.anomaly_detections(severity);
CREATE INDEX idx_anomalies_status ON predictions.anomaly_detections(status) WHERE status = 'new';
3.2 Functions y Triggers
-- =====================================================
-- FUNCTION: Calcular error de predicción
-- =====================================================
CREATE OR REPLACE FUNCTION predictions.calculate_prediction_error()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.actual_cost IS NOT NULL AND OLD.actual_cost IS NULL THEN
NEW.prediction_error := ABS(NEW.actual_cost - NEW.predicted_total_cost);
NEW.prediction_accuracy := (1 - (NEW.prediction_error / NULLIF(NEW.actual_cost, 0))) * 100;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_calculate_cost_prediction_error
BEFORE UPDATE ON predictions.cost_predictions
FOR EACH ROW
EXECUTE FUNCTION predictions.calculate_prediction_error();
-- =====================================================
-- FUNCTION: Detectar anomalías en costos
-- =====================================================
CREATE OR REPLACE FUNCTION predictions.detect_cost_anomalies(
p_project_id UUID,
p_threshold_pct DECIMAL DEFAULT 15.0
) RETURNS TABLE (
metric VARCHAR,
expected_value DECIMAL,
actual_value DECIMAL,
deviation_pct DECIMAL,
is_anomaly BOOLEAN
) AS $$
BEGIN
RETURN QUERY
WITH project_stats AS (
SELECT
AVG(weekly_cost) AS avg_weekly_cost,
STDDEV(weekly_cost) AS stddev_weekly_cost
FROM (
SELECT
DATE_TRUNC('week', t.transaction_date) AS week,
SUM(t.amount) AS weekly_cost
FROM costs.transactions t
WHERE t.project_id = p_project_id
AND t.transaction_date >= CURRENT_DATE - INTERVAL '12 weeks'
GROUP BY DATE_TRUNC('week', t.transaction_date)
) weekly_costs
),
current_week AS (
SELECT
SUM(t.amount) AS current_weekly_cost
FROM costs.transactions t
WHERE t.project_id = p_project_id
AND t.transaction_date >= DATE_TRUNC('week', CURRENT_DATE)
)
SELECT
'weekly_cost'::VARCHAR AS metric,
ps.avg_weekly_cost AS expected_value,
cw.current_weekly_cost AS actual_value,
((cw.current_weekly_cost - ps.avg_weekly_cost) / NULLIF(ps.avg_weekly_cost, 0) * 100) AS deviation_pct,
(ABS(cw.current_weekly_cost - ps.avg_weekly_cost) > ps.stddev_weekly_cost * 2) AS is_anomaly
FROM project_stats ps, current_week cw;
END;
$$ LANGUAGE plpgsql;
4. TypeORM Entities
4.1 MLModel Entity
// src/modules/predictions/entities/ml-model.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
import { Constructora } from '../../constructoras/entities/constructora.entity';
import { User } from '../../auth/entities/user.entity';
export enum ModelType {
LINEAR_REGRESSION = 'linear_regression',
RANDOM_FOREST = 'random_forest',
ARIMA = 'arima',
XGBOOST = 'xgboost',
NEURAL_NETWORK = 'neural_network',
}
export enum ModelStatus {
TRAINING = 'training',
TRAINED = 'trained',
VALIDATED = 'validated',
DEPLOYED = 'deployed',
DEPRECATED = 'deprecated',
}
export interface Feature {
name: string;
type: 'numeric' | 'categorical';
importance?: number;
}
export interface TrainingMetrics {
train_score: number;
test_score: number;
cross_val_score?: number;
rmse?: number;
mae?: number;
r2_score?: number;
}
@Entity('ml_models', { schema: 'predictions' })
@Index(['constructoraId', 'modelCode', 'version'], { unique: true })
export class MLModel {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'constructora_id', type: 'uuid' })
@Index()
constructoraId: string;
@ManyToOne(() => Constructora)
@JoinColumn({ name: 'constructora_id' })
constructora: Constructora;
// Identificación
@Column({ name: 'model_code', type: 'varchar', length: 50 })
modelCode: string;
@Column({ name: 'model_name', type: 'varchar', length: 255 })
modelName: string;
@Column({ type: 'text', nullable: true })
description?: string;
// Tipo
@Column({ name: 'model_type', type: 'enum', enum: ModelType })
@Index()
modelType: ModelType;
@Column({ name: 'prediction_target', type: 'varchar', length: 50 })
predictionTarget: string;
// Algoritmo
@Column({ type: 'varchar', length: 50 })
algorithm: string;
@Column({ type: 'jsonb', default: {} })
hyperparameters: any;
// Features
@Column({ type: 'jsonb' })
features: Feature[];
// Métricas
@Column({ name: 'training_metrics', type: 'jsonb', nullable: true })
trainingMetrics?: TrainingMetrics;
// Versión
@Column({ type: 'integer', default: 1 })
version: number;
@Column({ name: 'parent_model_id', type: 'uuid', nullable: true })
parentModelId?: string;
@ManyToOne(() => MLModel)
@JoinColumn({ name: 'parent_model_id' })
parentModel?: MLModel;
// Archivos
@Column({ name: 'model_file_path', type: 'varchar', length: 500, nullable: true })
modelFilePath?: string;
@Column({ name: 'training_data_path', type: 'varchar', length: 500, nullable: true })
trainingDataPath?: string;
// Estado
@Column({ type: 'enum', enum: ModelStatus, default: ModelStatus.TRAINING })
status: ModelStatus;
@Column({ name: 'is_active', type: 'boolean', default: false })
@Index()
isActive: boolean;
// Metadata
@Column({ name: 'trained_by', type: 'uuid', nullable: true })
trainedBy?: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'trained_by' })
trainer?: User;
@Column({ name: 'trained_at', type: 'timestamp', nullable: true })
trainedAt?: Date;
@Column({ name: 'last_retrained_at', type: 'timestamp', nullable: true })
lastRetrainedAt?: Date;
@Column({ name: 'deployed_at', type: 'timestamp', nullable: true })
deployedAt?: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
4.2 CostPrediction Entity
// src/modules/predictions/entities/cost-prediction.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
Index,
} from 'typeorm';
import { Project } from '../../projects/entities/project.entity';
import { MLModel } from './ml-model.entity';
@Entity('cost_predictions', { schema: 'predictions' })
export class CostPrediction {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'project_id', type: 'uuid' })
@Index()
projectId: string;
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project: Project;
@Column({ name: 'model_id', type: 'uuid' })
@Index()
modelId: string;
@ManyToOne(() => MLModel)
@JoinColumn({ name: 'model_id' })
model: MLModel;
// Predicción
@Column({ name: 'prediction_date', type: 'date' })
@Index()
predictionDate: Date;
@Column({ name: 'prediction_horizon', type: 'integer' })
predictionHorizon: number;
// Costos predichos
@Column({ name: 'predicted_total_cost', type: 'decimal', precision: 15, scale: 2 })
predictedTotalCost: number;
@Column({ name: 'predicted_cost_variance', type: 'decimal', precision: 15, scale: 2, nullable: true })
predictedCostVariance?: number;
@Column({ name: 'predicted_cost_variance_pct', type: 'decimal', precision: 5, scale: 2, nullable: true })
predictedCostVariancePct?: number;
// Intervalos de confianza
@Column({ name: 'confidence_level', type: 'decimal', precision: 5, scale: 2, default: 95.00 })
confidenceLevel: number;
@Column({ name: 'lower_bound', type: 'decimal', precision: 15, scale: 2, nullable: true })
lowerBound?: number;
@Column({ name: 'upper_bound', type: 'decimal', precision: 15, scale: 2, nullable: true })
upperBound?: number;
// Features
@Column({ name: 'input_features', type: 'jsonb', nullable: true })
inputFeatures?: any;
// Resultado real
@Column({ name: 'actual_cost', type: 'decimal', precision: 15, scale: 2, nullable: true })
actualCost?: number;
@Column({ name: 'prediction_error', type: 'decimal', precision: 15, scale: 2, nullable: true })
predictionError?: number;
@Column({ name: 'prediction_accuracy', type: 'decimal', precision: 5, scale: 2, nullable: true })
predictionAccuracy?: number;
// Metadata
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}
4.3 ScenarioSimulation Entity
// src/modules/predictions/entities/scenario-simulation.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
Index,
} from 'typeorm';
import { Project } from '../../projects/entities/project.entity';
import { User } from '../../auth/entities/user.entity';
export enum ScenarioType {
OPTIMISTIC = 'optimistic',
REALISTIC = 'realistic',
PESSIMISTIC = 'pessimistic',
CUSTOM = 'custom',
}
@Entity('scenario_simulations', { schema: 'predictions' })
export class ScenarioSimulation {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'project_id', type: 'uuid' })
@Index()
projectId: string;
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project: Project;
// Identificación
@Column({ name: 'simulation_name', type: 'varchar', length: 255 })
simulationName: string;
@Column({ type: 'text', nullable: true })
description?: string;
// Tipo
@Column({ name: 'scenario_type', type: 'enum', enum: ScenarioType })
@Index()
scenarioType: ScenarioType;
// Parámetros
@Column({ type: 'jsonb' })
parameters: any;
// Resultados
@Column({ type: 'jsonb', nullable: true })
results?: any;
// Comparación
@Column({ name: 'baseline_comparison', type: 'jsonb', nullable: true })
baselineComparison?: any;
// Metadata
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy?: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
creator?: User;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}
5. Services (Lógica de Negocio)
5.1 PredictionService
// src/modules/predictions/services/prediction.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CostPrediction } from '../entities/cost-prediction.entity';
import { SchedulePrediction } from '../entities/schedule-prediction.entity';
import { MLModel, ModelStatus } from '../entities/ml-model.entity';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { lastValueFrom } from 'rxjs';
@Injectable()
export class PredictionService {
private readonly logger = new Logger(PredictionService.name);
private readonly mlServiceUrl: string;
constructor(
@InjectRepository(CostPrediction)
private costPredictionRepo: Repository<CostPrediction>,
@InjectRepository(SchedulePrediction)
private schedulePredictionRepo: Repository<SchedulePrediction>,
@InjectRepository(MLModel)
private mlModelRepo: Repository<MLModel>,
private httpService: HttpService,
private configService: ConfigService,
) {
this.mlServiceUrl = this.configService.get('ML_SERVICE_URL') || 'http://localhost:5000';
}
/**
* Predecir costo final de un proyecto
*/
async predictProjectCost(
projectId: string,
predictionHorizon: number = 90,
): Promise<CostPrediction> {
// Obtener modelo activo para cost prediction
const model = await this.mlModelRepo.findOne({
where: {
predictionTarget: 'cost_overrun',
isActive: true,
status: ModelStatus.DEPLOYED,
},
});
if (!model) {
throw new Error('No active cost prediction model found');
}
// Preparar features
const features = await this.prepareCostFeatures(projectId);
// Llamar al servicio de ML (Python)
const prediction = await this.callMLService('predict/cost', {
modelId: model.id,
features,
});
// Guardar predicción
const costPrediction = this.costPredictionRepo.create({
projectId,
modelId: model.id,
predictionDate: new Date(),
predictionHorizon,
predictedTotalCost: prediction.predicted_cost,
predictedCostVariance: prediction.variance,
predictedCostVariancePct: prediction.variance_pct,
confidenceLevel: 95,
lowerBound: prediction.lower_bound,
upperBound: prediction.upper_bound,
inputFeatures: features,
});
return this.costPredictionRepo.save(costPrediction);
}
/**
* Predecir fecha de finalización de un proyecto
*/
async predictProjectSchedule(
projectId: string,
scheduleId: string,
): Promise<SchedulePrediction> {
const model = await this.mlModelRepo.findOne({
where: {
predictionTarget: 'schedule_delay',
isActive: true,
status: ModelStatus.DEPLOYED,
},
});
if (!model) {
throw new Error('No active schedule prediction model found');
}
const features = await this.prepareScheduleFeatures(projectId, scheduleId);
const prediction = await this.callMLService('predict/schedule', {
modelId: model.id,
features,
});
const schedulePrediction = this.schedulePredictionRepo.create({
projectId,
scheduleId,
modelId: model.id,
predictionDate: new Date(),
predictedCompletionDate: new Date(prediction.predicted_completion_date),
predictedDelayDays: prediction.delay_days,
baselineCompletionDate: features.baseline_completion_date,
onTimeProbability: prediction.on_time_probability,
delayProbability: prediction.delay_probability,
criticalActivitiesAtRisk: prediction.critical_activities_at_risk,
confidenceLevel: 95,
earliestCompletion: new Date(prediction.earliest_completion),
latestCompletion: new Date(prediction.latest_completion),
inputFeatures: features,
});
return this.schedulePredictionRepo.save(schedulePrediction);
}
/**
* Preparar features para predicción de costos
*/
private async prepareCostFeatures(projectId: string): Promise<any> {
const result = await this.costPredictionRepo.query(`
SELECT
p.total_budget,
p.contracted_value,
pm.spent_amount,
pm.budget_utilization_pct,
pm.physical_progress_pct,
pm.schedule_progress_pct,
pm.spi,
pm.cpi,
pm.gross_margin_pct,
EXTRACT(EPOCH FROM (p.end_date - CURRENT_DATE)) / 86400 AS remaining_days,
EXTRACT(EPOCH FROM (CURRENT_DATE - p.start_date)) / 86400 AS elapsed_days,
COUNT(DISTINCT sa.id) AS total_activities,
COUNT(DISTINCT sa.id) FILTER (WHERE sa.status = 'delayed') AS delayed_activities
FROM projects.projects p
LEFT JOIN analytics_reports.project_performance_metrics pm ON pm.project_id = p.id
AND pm.snapshot_date = (
SELECT MAX(snapshot_date)
FROM analytics_reports.project_performance_metrics
WHERE project_id = p.id
)
LEFT JOIN schedules.schedule_activities sa ON sa.schedule_id = (
SELECT id FROM schedules.schedules
WHERE project_id = p.id AND status = 'active'
LIMIT 1
)
WHERE p.id = $1
GROUP BY p.id, p.total_budget, p.contracted_value, pm.spent_amount,
pm.budget_utilization_pct, pm.physical_progress_pct, pm.schedule_progress_pct,
pm.spi, pm.cpi, pm.gross_margin_pct, p.end_date, p.start_date
`, [projectId]);
return result[0];
}
/**
* Preparar features para predicción de cronograma
*/
private async prepareScheduleFeatures(projectId: string, scheduleId: string): Promise<any> {
const result = await this.schedulePredictionRepo.query(`
SELECT
s.end_date AS baseline_completion_date,
AVG(sa.percent_complete) AS avg_progress,
COUNT(*) FILTER (WHERE sa.is_critical_path = true) AS critical_path_activities,
COUNT(*) FILTER (WHERE sa.status = 'delayed') AS delayed_activities,
AVG(sa.total_float) AS avg_total_float,
pm.spi,
pm.schedule_variance_pct
FROM schedules.schedules s
LEFT JOIN schedules.schedule_activities sa ON sa.schedule_id = s.id
LEFT JOIN analytics_reports.project_performance_metrics pm ON pm.project_id = s.project_id
AND pm.snapshot_date = (
SELECT MAX(snapshot_date)
FROM analytics_reports.project_performance_metrics
WHERE project_id = s.project_id
)
WHERE s.id = $1
GROUP BY s.id, s.end_date, pm.spi, pm.schedule_variance_pct
`, [scheduleId]);
return result[0];
}
/**
* Llamar al servicio de ML (Python Flask)
*/
private async callMLService(endpoint: string, data: any): Promise<any> {
try {
const response = await lastValueFrom(
this.httpService.post(`${this.mlServiceUrl}/${endpoint}`, data),
);
return response.data;
} catch (error) {
this.logger.error(`Error calling ML service: ${error.message}`, error.stack);
throw new Error('Failed to get prediction from ML service');
}
}
}
5.2 MLModelService
// src/modules/predictions/services/ml-model.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { MLModel, ModelStatus, ModelType } from '../entities/ml-model.entity';
import { TrainingData } from '../entities/training-data.entity';
import { Cron, CronExpression } from '@nestjs/schedule';
import { HttpService } from '@nestjs/axios';
import { lastValueFrom } from 'rxjs';
@Injectable()
export class MLModelService {
private readonly logger = new Logger(MLModelService.name);
constructor(
@InjectRepository(MLModel)
private mlModelRepo: Repository<MLModel>,
@InjectRepository(TrainingData)
private trainingDataRepo: Repository<TrainingData>,
private httpService: HttpService,
) {}
/**
* Entrenar nuevo modelo
*/
async trainModel(
modelCode: string,
modelType: ModelType,
predictionTarget: string,
trainingDataId: string,
hyperparameters: any,
userId: string,
): Promise<MLModel> {
// Crear registro del modelo
const model = this.mlModelRepo.create({
modelCode,
modelType,
predictionTarget,
algorithm: this.getAlgorithmForType(modelType),
hyperparameters,
status: ModelStatus.TRAINING,
trainedBy: userId,
});
const savedModel = await this.mlModelRepo.save(model);
// Disparar entrenamiento asíncrono
this.triggerTraining(savedModel.id, trainingDataId);
return savedModel;
}
/**
* Disparar entrenamiento (procesamiento asíncrono)
*/
private async triggerTraining(modelId: string, trainingDataId: string): Promise<void> {
try {
// Llamar al servicio de ML para entrenar
const response = await lastValueFrom(
this.httpService.post('http://localhost:5000/train', {
modelId,
trainingDataId,
}),
);
// Actualizar modelo con métricas
await this.mlModelRepo.update(modelId, {
status: ModelStatus.TRAINED,
trainedAt: new Date(),
trainingMetrics: response.data.metrics,
modelFilePath: response.data.model_path,
});
this.logger.log(`Model ${modelId} trained successfully`);
} catch (error) {
this.logger.error(`Error training model ${modelId}`, error.stack);
await this.mlModelRepo.update(modelId, {
status: ModelStatus.TRAINING,
});
}
}
/**
* Desplegar modelo
*/
async deployModel(modelId: string): Promise<MLModel> {
const model = await this.mlModelRepo.findOne({ where: { id: modelId } });
if (!model) {
throw new Error('Model not found');
}
if (model.status !== ModelStatus.VALIDATED) {
throw new Error('Model must be validated before deployment');
}
// Desactivar modelos anteriores del mismo tipo
await this.mlModelRepo.update(
{
predictionTarget: model.predictionTarget,
isActive: true,
},
{
isActive: false,
status: ModelStatus.DEPRECATED,
},
);
// Activar nuevo modelo
model.status = ModelStatus.DEPLOYED;
model.isActive = true;
model.deployedAt = new Date();
return this.mlModelRepo.save(model);
}
/**
* Re-entrenar modelos (CRON mensual)
*/
@Cron(CronExpression.EVERY_1ST_DAY_OF_MONTH_AT_MIDNIGHT)
async retrainModels(): Promise<void> {
this.logger.log('Starting monthly model retraining...');
const activeModels = await this.mlModelRepo.find({
where: { isActive: true },
});
for (const model of activeModels) {
try {
// Obtener dataset actualizado
const trainingData = await this.prepareTrainingData(model.predictionTarget);
// Re-entrenar
await this.triggerTraining(model.id, trainingData.id);
await this.mlModelRepo.update(model.id, {
lastRetrainedAt: new Date(),
});
this.logger.log(`Model ${model.modelCode} retrained successfully`);
} catch (error) {
this.logger.error(`Error retraining model ${model.modelCode}`, error.stack);
}
}
this.logger.log('Monthly model retraining completed');
}
/**
* Helpers
*/
private getAlgorithmForType(modelType: ModelType): string {
const algorithmMap = {
[ModelType.LINEAR_REGRESSION]: 'linear_regression',
[ModelType.RANDOM_FOREST]: 'random_forest_regressor',
[ModelType.ARIMA]: 'arima',
[ModelType.XGBOOST]: 'xgboost_regressor',
[ModelType.NEURAL_NETWORK]: 'mlp_regressor',
};
return algorithmMap[modelType];
}
private async prepareTrainingData(predictionTarget: string): Promise<TrainingData> {
// TODO: Implementar lógica de preparación de dataset
throw new Error('Not implemented');
}
}
5.3 ScenarioSimulationService
// src/modules/predictions/services/scenario-simulation.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ScenarioSimulation, ScenarioType } from '../entities/scenario-simulation.entity';
@Injectable()
export class ScenarioSimulationService {
constructor(
@InjectRepository(ScenarioSimulation)
private simulationRepo: Repository<ScenarioSimulation>,
) {}
/**
* Simular escenarios
*/
async simulateScenarios(projectId: string, userId: string): Promise<ScenarioSimulation[]> {
const scenarios: ScenarioSimulation[] = [];
// Escenario Optimista
const optimistic = await this.simulateScenario(
projectId,
ScenarioType.OPTIMISTIC,
{
costVarianceFactor: -0.05, // -5% de costos
scheduleVarianceFactor: -0.10, // -10% de tiempo
productivityFactor: 1.10, // +10% productividad
weatherImpact: false,
resourceAvailability: 1.0,
},
userId,
);
scenarios.push(optimistic);
// Escenario Realista
const realistic = await this.simulateScenario(
projectId,
ScenarioType.REALISTIC,
{
costVarianceFactor: 0.00,
scheduleVarianceFactor: 0.00,
productivityFactor: 1.00,
weatherImpact: true,
resourceAvailability: 0.95,
},
userId,
);
scenarios.push(realistic);
// Escenario Pesimista
const pessimistic = await this.simulateScenario(
projectId,
ScenarioType.PESSIMISTIC,
{
costVarianceFactor: 0.15, // +15% de costos
scheduleVarianceFactor: 0.20, // +20% de tiempo
productivityFactor: 0.85, // -15% productividad
weatherImpact: true,
resourceAvailability: 0.80,
},
userId,
);
scenarios.push(pessimistic);
return scenarios;
}
/**
* Simular un escenario específico
*/
private async simulateScenario(
projectId: string,
scenarioType: ScenarioType,
parameters: any,
userId: string,
): Promise<ScenarioSimulation> {
// Obtener datos base del proyecto
const baseData = await this.simulationRepo.query(`
SELECT
p.total_budget,
p.end_date,
pm.gross_margin,
pm.gross_margin_pct
FROM projects.projects p
LEFT JOIN analytics_reports.project_performance_metrics pm ON pm.project_id = p.id
AND pm.snapshot_date = (
SELECT MAX(snapshot_date)
FROM analytics_reports.project_performance_metrics
WHERE project_id = p.id
)
WHERE p.id = $1
`, [projectId]);
const base = baseData[0];
// Aplicar factores del escenario
const projectedCost = base.total_budget * (1 + parameters.costVarianceFactor);
const projectedMargin = base.gross_margin * (1 - Math.abs(parameters.costVarianceFactor));
const projectedMarginPct = (projectedMargin / projectedCost) * 100;
const daysToAdd = Math.floor(
((base.end_date - new Date()) / (1000 * 60 * 60 * 24)) * parameters.scheduleVarianceFactor,
);
const projectedCompletionDate = new Date(base.end_date);
projectedCompletionDate.setDate(projectedCompletionDate.getDate() + daysToAdd);
// Calcular risk score
const riskScore = this.calculateRiskScore(parameters, projectedMarginPct);
// Crear simulación
const simulation = this.simulationRepo.create({
projectId,
simulationName: `${scenarioType} Scenario`,
scenarioType,
parameters,
results: {
projectedCost,
projectedCompletionDate,
projectedMargin,
projectedMarginPct,
riskScore,
},
baselineComparison: {
costVariance: projectedCost - base.total_budget,
costVariancePct: parameters.costVarianceFactor * 100,
scheduleVarianceDays: daysToAdd,
marginImpact: projectedMargin - base.gross_margin,
},
createdBy: userId,
});
return this.simulationRepo.save(simulation);
}
/**
* Calcular risk score
*/
private calculateRiskScore(parameters: any, marginPct: number): number {
let score = 50; // Base
// Penalizar por cost variance
score += Math.abs(parameters.costVarianceFactor) * 100;
// Penalizar por schedule variance
score += Math.abs(parameters.scheduleVarianceFactor) * 80;
// Penalizar por baja productividad
score += (1 - parameters.productivityFactor) * 100;
// Penalizar por margen bajo
if (marginPct < 10) score += 20;
if (marginPct < 5) score += 30;
return Math.min(100, Math.max(0, score));
}
}
6. Python ML Service (Flask)
# ml_service/app.py
from flask import Flask, request, jsonify
import pandas as pd
import numpy as np
from sklearn.ensemble import RandomForestRegressor
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score
import joblib
import os
app = Flask(__name__)
MODELS_DIR = '/models'
DATA_DIR = '/data'
@app.route('/train', methods=['POST'])
def train_model():
"""
Entrenar modelo de ML
"""
data = request.json
model_id = data.get('model_id')
training_data_id = data.get('training_data_id')
# Cargar datos de entrenamiento
df = load_training_data(training_data_id)
# Preparar features y target
X = df.drop(['target'], axis=1)
y = df['target']
# Split
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42
)
# Entrenar Random Forest
model = RandomForestRegressor(
n_estimators=100,
max_depth=10,
random_state=42
)
model.fit(X_train, y_train)
# Evaluar
train_score = model.score(X_train, y_train)
test_score = model.score(X_test, y_test)
y_pred = model.predict(X_test)
rmse = np.sqrt(mean_squared_error(y_test, y_pred))
mae = mean_absolute_error(y_test, y_pred)
r2 = r2_score(y_test, y_pred)
# Cross-validation
cv_scores = cross_val_score(model, X, y, cv=5)
cv_score = cv_scores.mean()
# Guardar modelo
model_path = f'{MODELS_DIR}/{model_id}.joblib'
joblib.dump(model, model_path)
return jsonify({
'model_id': model_id,
'model_path': model_path,
'metrics': {
'train_score': float(train_score),
'test_score': float(test_score),
'cross_val_score': float(cv_score),
'rmse': float(rmse),
'mae': float(mae),
'r2_score': float(r2)
}
})
@app.route('/predict/cost', methods=['POST'])
def predict_cost():
"""
Predecir costo final del proyecto
"""
data = request.json
model_id = data.get('model_id')
features = data.get('features')
# Cargar modelo
model_path = f'{MODELS_DIR}/{model_id}.joblib'
model = joblib.load(model_path)
# Preparar features
X = pd.DataFrame([features])
# Predecir
prediction = model.predict(X)[0]
# Intervalos de confianza (aproximado)
# En producción, usar prediction intervals más sofisticados
variance = prediction * 0.10
lower_bound = prediction - (1.96 * variance)
upper_bound = prediction + (1.96 * variance)
return jsonify({
'predicted_cost': float(prediction),
'variance': float(prediction - features['total_budget']),
'variance_pct': float((prediction - features['total_budget']) / features['total_budget'] * 100),
'lower_bound': float(lower_bound),
'upper_bound': float(upper_bound)
})
@app.route('/predict/schedule', methods=['POST'])
def predict_schedule():
"""
Predecir fecha de finalización
"""
data = request.json
model_id = data.get('model_id')
features = data.get('features')
model_path = f'{MODELS_DIR}/{model_id}.joblib'
model = joblib.load(model_path)
X = pd.DataFrame([features])
delay_days = int(model.predict(X)[0])
baseline_date = pd.to_datetime(features['baseline_completion_date'])
predicted_date = baseline_date + pd.Timedelta(days=delay_days)
# Probabilidades (basado en distribución histórica)
on_time_prob = max(0, 100 - (abs(delay_days) * 5))
delay_prob = 100 - on_time_prob
return jsonify({
'predicted_completion_date': predicted_date.strftime('%Y-%m-%d'),
'delay_days': delay_days,
'on_time_probability': float(on_time_prob),
'delay_probability': float(delay_prob),
'critical_activities_at_risk': [], # TODO: Implementar lógica
'earliest_completion': (predicted_date - pd.Timedelta(days=7)).strftime('%Y-%m-%d'),
'latest_completion': (predicted_date + pd.Timedelta(days=14)).strftime('%Y-%m-%d')
})
def load_training_data(training_data_id):
"""
Cargar datos de entrenamiento desde CSV
"""
file_path = f'{DATA_DIR}/{training_data_id}.csv'
return pd.read_csv(file_path)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)
7. React Components
// src/pages/Predictions/PredictionDashboard.tsx
import React, { useEffect, useState } from 'react';
import { usePredictionStore } from '../../stores/predictionStore';
import { Card } from '../../components/ui/Card';
import { LineChart } from '../../components/charts/LineChart';
import { Button } from '../../components/ui/Button';
import { TrendingUp, AlertTriangle, Target } from 'lucide-react';
interface PredictionDashboardProps {
projectId: string;
}
export const PredictionDashboard: React.FC<PredictionDashboardProps> = ({ projectId }) => {
const { costPrediction, schedulePrediction, scenarios, loading, fetchPredictions, simulateScenarios } = usePredictionStore();
useEffect(() => {
fetchPredictions(projectId);
}, [projectId]);
const handleSimulateScenarios = async () => {
await simulateScenarios(projectId);
};
if (loading) {
return <div>Cargando predicciones...</div>;
}
return (
<div className="prediction-dashboard">
<div className="page-header mb-6">
<h1 className="text-2xl font-bold">Análisis Predictivo</h1>
<Button onClick={handleSimulateScenarios} variant="primary">
Simular Escenarios
</Button>
</div>
{/* Predicción de Costos */}
{costPrediction && (
<Card title="Predicción de Costo Final" className="mb-6">
<div className="grid grid-cols-3 gap-4">
<div className="metric">
<label className="text-sm text-gray-600">Costo Predicho</label>
<div className="text-2xl font-bold">
${costPrediction.predictedTotalCost.toLocaleString('es-MX')}
</div>
<div className="text-sm text-gray-500">
Confianza: {costPrediction.confidenceLevel}%
</div>
</div>
<div className="metric">
<label className="text-sm text-gray-600">Variación vs Presupuesto</label>
<div className={`text-2xl font-bold ${costPrediction.predictedCostVariancePct > 0 ? 'text-red-600' : 'text-green-600'}`}>
{costPrediction.predictedCostVariancePct > 0 ? '+' : ''}
{costPrediction.predictedCostVariancePct?.toFixed(2)}%
</div>
</div>
<div className="metric">
<label className="text-sm text-gray-600">Rango (95% confianza)</label>
<div className="text-sm">
${costPrediction.lowerBound?.toLocaleString('es-MX')} -
${costPrediction.upperBound?.toLocaleString('es-MX')}
</div>
</div>
</div>
</Card>
)}
{/* Predicción de Cronograma */}
{schedulePrediction && (
<Card title="Predicción de Finalización" className="mb-6">
<div className="grid grid-cols-3 gap-4">
<div className="metric">
<label className="text-sm text-gray-600">Fecha Predicha</label>
<div className="text-2xl font-bold">
{new Date(schedulePrediction.predictedCompletionDate).toLocaleDateString('es-MX')}
</div>
</div>
<div className="metric">
<label className="text-sm text-gray-600">Retraso Estimado</label>
<div className={`text-2xl font-bold ${schedulePrediction.predictedDelayDays > 0 ? 'text-red-600' : 'text-green-600'}`}>
{schedulePrediction.predictedDelayDays} días
</div>
</div>
<div className="metric">
<label className="text-sm text-gray-600">Probabilidad a Tiempo</label>
<div className="text-2xl font-bold">
{schedulePrediction.onTimeProbability?.toFixed(1)}%
</div>
</div>
</div>
</Card>
)}
{/* Simulación de Escenarios */}
{scenarios && scenarios.length > 0 && (
<Card title="Simulación de Escenarios" className="mb-6">
<div className="grid grid-cols-3 gap-4">
{scenarios.map((scenario) => (
<div key={scenario.id} className="scenario-card border rounded-lg p-4">
<h3 className="font-bold mb-2 capitalize">
{scenario.scenarioType}
</h3>
<div className="space-y-2">
<div>
<label className="text-sm text-gray-600">Costo Proyectado</label>
<div className="font-semibold">
${scenario.results.projectedCost.toLocaleString('es-MX')}
</div>
</div>
<div>
<label className="text-sm text-gray-600">Margen Proyectado</label>
<div className="font-semibold">
{scenario.results.projectedMarginPct.toFixed(2)}%
</div>
</div>
<div>
<label className="text-sm text-gray-600">Risk Score</label>
<div className={`font-semibold ${
scenario.results.riskScore > 70 ? 'text-red-600' :
scenario.results.riskScore > 50 ? 'text-yellow-600' : 'text-green-600'
}`}>
{scenario.results.riskScore.toFixed(1)}
</div>
</div>
</div>
</div>
))}
</div>
</Card>
)}
</div>
);
};
8. Testing
// src/modules/predictions/services/__tests__/prediction.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { PredictionService } from '../prediction.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { CostPrediction } from '../../entities/cost-prediction.entity';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { of } from 'rxjs';
describe('PredictionService', () => {
let service: PredictionService;
let httpService: HttpService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
PredictionService,
{
provide: getRepositoryToken(CostPrediction),
useValue: {
create: jest.fn(),
save: jest.fn(),
query: jest.fn(),
},
},
{
provide: HttpService,
useValue: {
post: jest.fn(),
},
},
{
provide: ConfigService,
useValue: {
get: jest.fn().mockReturnValue('http://localhost:5000'),
},
},
],
}).compile();
service = module.get<PredictionService>(PredictionService);
httpService = module.get<HttpService>(HttpService);
});
it('should predict project cost', async () => {
const mlResponse = {
predicted_cost: 10500000,
variance: 500000,
variance_pct: 5.0,
lower_bound: 10000000,
upper_bound: 11000000,
};
jest.spyOn(httpService, 'post').mockReturnValue(of({ data: mlResponse }) as any);
const result = await service.predictProjectCost('project-1', 90);
expect(result.predictedTotalCost).toBe(10500000);
expect(result.predictedCostVariancePct).toBe(5.0);
});
});
9. Criterios de Aceptación Técnicos
- Schema
predictionscreado con todas las tablas - Entities TypeORM con relaciones correctas
- Services para predicciones y ML
- Python Flask micro-service para ML
- Modelos de Random Forest, ARIMA implementados
- Simulación de escenarios (optimista, realista, pesimista)
- Detección de anomalías en costos
- CRON jobs para re-entrenamiento mensual
- Controllers con endpoints RESTful
- React components para visualización
- Triggers para cálculo de errores de predicción
- Tests unitarios con >75% coverage
Fecha: 2025-11-17 Preparado por: Equipo Técnico Versión: 1.0 Estado: ✅ Listo para Implementación