# ET-COST-003: Implementación de Control de Costos Reales y Desviaciones **Épica:** MAI-003 - Presupuestos y Control de Costos **Versión:** 1.0 **Fecha:** 2025-11-17 --- ## 1. Schemas SQL ```sql -- Tabla: actual_costs (Costos reales) CREATE TABLE budgets.actual_costs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Multi-tenant discriminator (tenant = constructora) -- Actual costs tracking per constructora (see GLOSARIO.md) constructora_id UUID NOT NULL REFERENCES public.constructoras(id), project_id UUID NOT NULL REFERENCES projects.projects(id), stage_id UUID REFERENCES projects.stages(id), budget_item_id UUID NOT NULL REFERENCES budgets.budget_items(id), -- Origen del costo source_type VARCHAR(20) NOT NULL CHECK (source_type IN ('purchase', 'payroll', 'subcontract', 'equipment', 'other')), source_id UUID, -- ID de OC, nómina, subcontrato, etc. source_document_number VARCHAR(50), -- Montos amount DECIMAL(15,2) NOT NULL, currency VARCHAR(3) DEFAULT 'MXN', includes_vat BOOLEAN DEFAULT true, -- Fechas transaction_date DATE NOT NULL, accounting_period VARCHAR(7) NOT NULL, -- 2025-11 -- Clasificación cost_type VARCHAR(20) CHECK (cost_type IN ('material', 'labor', 'equipment', 'subcontract', 'indirect')), notes TEXT, created_by UUID NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_actual_costs_project ON budgets.actual_costs(project_id); CREATE INDEX idx_actual_costs_budget_item ON budgets.actual_costs(budget_item_id); CREATE INDEX idx_actual_costs_period ON budgets.actual_costs(accounting_period); -- Tabla: cost_variances (Desviaciones) CREATE TABLE budgets.cost_variances ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES projects.projects(id), budget_item_id UUID REFERENCES budgets.budget_items(id), analysis_date DATE NOT NULL, -- Montos budgeted_amount DECIMAL(15,2) NOT NULL, actual_amount DECIMAL(15,2) NOT NULL, variance DECIMAL(15,2) NOT NULL, -- Real - Presupuestado variance_percentage DECIMAL(6,2) NOT NULL, -- Descomposición price_variance DECIMAL(15,2), quantity_variance DECIMAL(15,2), mixed_variance DECIMAL(15,2), -- Clasificación status VARCHAR(20) CHECK (status IN ('within_tolerance', 'warning', 'critical')), -- Causa raíz y plan de acción root_cause TEXT, action_plan TEXT, responsible_user_id UUID, action_deadline DATE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_variances_project ON budgets.cost_variances(project_id); CREATE INDEX idx_variances_status ON budgets.cost_variances(status); -- Tabla: cost_projections (Proyecciones) CREATE TABLE budgets.cost_projections ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES projects.projects(id), projection_date DATE NOT NULL, physical_progress DECIMAL(5,2) NOT NULL, -- Datos base budgeted_total_cost DECIMAL(15,2) NOT NULL, actual_cost_to_date DECIMAL(15,2) NOT NULL, -- Proyecciones (3 métodos) linear_projection DECIMAL(15,2), cpi_based_projection DECIMAL(15,2), weighted_projection DECIMAL(15,2), recommended_eac DECIMAL(15,2), -- Estimate at Completion -- Índices de desempeño cpi DECIMAL(5,3), -- Cost Performance Index spi DECIMAL(5,3), -- Schedule Performance Index created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_projections_project ON budgets.cost_projections(project_id); ``` ## 2. Funciones y Triggers ```sql -- Función: Calcular desviaciones automáticamente CREATE OR REPLACE FUNCTION budgets.calculate_variances( p_project_id UUID, p_analysis_date DATE DEFAULT CURRENT_DATE ) RETURNS TABLE( budget_item_id UUID, budgeted_amount DECIMAL, actual_amount DECIMAL, variance DECIMAL, variance_percentage DECIMAL ) AS $$ BEGIN RETURN QUERY SELECT bi.id as budget_item_id, bi.amount as budgeted_amount, COALESCE(SUM(ac.amount), 0) as actual_amount, COALESCE(SUM(ac.amount), 0) - bi.amount as variance, CASE WHEN bi.amount > 0 THEN ((COALESCE(SUM(ac.amount), 0) - bi.amount) / bi.amount) * 100 ELSE 0 END as variance_percentage FROM budgets.budget_items bi INNER JOIN budgets.budgets b ON bi.budget_id = b.id LEFT JOIN budgets.actual_costs ac ON ac.budget_item_id = bi.id WHERE b.project_id = p_project_id AND bi.level = 3 -- Solo conceptos GROUP BY bi.id, bi.amount; END; $$ LANGUAGE plpgsql; -- Función: Calcular proyección EAC CREATE OR REPLACE FUNCTION budgets.calculate_eac( p_project_id UUID ) RETURNS TABLE( linear_eac DECIMAL, cpi_eac DECIMAL, weighted_eac DECIMAL ) AS $$ DECLARE v_budget DECIMAL; v_actual DECIMAL; v_progress DECIMAL; v_cpi DECIMAL; v_earned_value DECIMAL; BEGIN -- Obtener datos del proyecto SELECT b.total_cost, COALESCE(SUM(ac.amount), 0), p.physical_progress INTO v_budget, v_actual, v_progress FROM budgets.budgets b LEFT JOIN budgets.actual_costs ac ON ac.project_id = b.project_id INNER JOIN projects.projects p ON p.id = b.project_id WHERE b.project_id = p_project_id AND b.budget_type = 'project' GROUP BY b.total_cost, p.physical_progress; -- Valor ganado v_earned_value := v_budget * (v_progress / 100); -- CPI (Cost Performance Index) IF v_actual > 0 THEN v_cpi := v_earned_value / v_actual; ELSE v_cpi := 1.0; END IF; -- EAC Lineal: (Real / %Avance) * 100 linear_eac := CASE WHEN v_progress > 0 THEN (v_actual / v_progress) * 100 ELSE v_budget END; -- EAC basado en CPI: Presupuesto / CPI cpi_eac := CASE WHEN v_cpi > 0 THEN v_budget / v_cpi ELSE v_budget END; -- EAC Ponderado: Real + [(Presup - ValorGanado) / CPI] weighted_eac := v_actual + ((v_budget - v_earned_value) / v_cpi); RETURN QUERY SELECT linear_eac, cpi_eac, weighted_eac; END; $$ LANGUAGE plpgsql; -- Trigger: Generar alerta si desviación > 5% CREATE OR REPLACE FUNCTION budgets.check_variance_alert() RETURNS TRIGGER AS $$ BEGIN IF ABS(NEW.variance_percentage) > 5.0 THEN -- Actualizar status a critical NEW.status := 'critical'; -- Insertar notificación (asumiendo tabla de notificaciones) -- INSERT INTO notifications (...) VALUES (...); RAISE NOTICE 'Alerta: Desviación crítica en partida % (%% %%)', NEW.budget_item_id, NEW.variance_percentage, '%'; ELSIF ABS(NEW.variance_percentage) > 3.0 THEN NEW.status := 'warning'; ELSE NEW.status := 'within_tolerance'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_variance_alert BEFORE INSERT OR UPDATE ON budgets.cost_variances FOR EACH ROW EXECUTE FUNCTION budgets.check_variance_alert(); ``` ## 3. TypeORM Entities (Simplificado) ```typescript // src/budgets/entities/actual-cost.entity.ts @Entity('actual_costs', { schema: 'budgets' }) export class ActualCost { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'project_id' }) projectId: string; @Column({ name: 'budget_item_id' }) budgetItemId: string; @Column({ name: 'source_type' }) sourceType: 'purchase' | 'payroll' | 'subcontract' | 'equipment' | 'other'; @Column({ name: 'source_id', nullable: true }) sourceId: string; @Column({ type: 'decimal', precision: 15, scale: 2 }) amount: number; @Column({ name: 'transaction_date', type: 'date' }) transactionDate: Date; @Column({ name: 'accounting_period', length: 7 }) accountingPeriod: string; // 2025-11 } // src/budgets/entities/cost-variance.entity.ts @Entity('cost_variances', { schema: 'budgets' }) export class CostVariance { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'project_id' }) projectId: string; @Column({ name: 'budget_item_id', nullable: true }) budgetItemId: string; @Column({ name: 'budgeted_amount', type: 'decimal', precision: 15, scale: 2 }) budgetedAmount: number; @Column({ name: 'actual_amount', type: 'decimal', precision: 15, scale: 2 }) actualAmount: number; @Column({ type: 'decimal', precision: 15, scale: 2 }) variance: number; @Column({ name: 'variance_percentage', type: 'decimal', precision: 6, scale: 2 }) variancePercentage: number; @Column({ default: 'within_tolerance' }) status: 'within_tolerance' | 'warning' | 'critical'; @Column({ name: 'root_cause', type: 'text', nullable: true }) rootCause: string; @Column({ name: 'action_plan', type: 'text', nullable: true }) actionPlan: string; } ``` ## 4. Service (Métodos Clave) ```typescript // src/budgets/services/cost-control.service.ts @Injectable() export class CostControlService { async registerCost(dto: RegisterCostDto, constructoraId: string): Promise { // Registrar costo real const cost = this.actualCostRepo.create({ ...dto, constructoraId, accountingPeriod: this.getAccountingPeriod(dto.transactionDate), }); await this.actualCostRepo.save(cost); // Recalcular desviaciones await this.recalculateVariances(dto.projectId); return cost; } async recalculateVariances(projectId: string): Promise { // Llamar función SQL const variances = await this.varianceRepo.query( 'SELECT * FROM budgets.calculate_variances($1)', [projectId], ); // Actualizar o insertar desviaciones for (const v of variances) { await this.varianceRepo.upsert( { projectId, budgetItemId: v.budget_item_id, analysisDate: new Date(), budgetedAmount: v.budgeted_amount, actualAmount: v.actual_amount, variance: v.variance, variancePercentage: v.variance_percentage, }, ['projectId', 'budgetItemId'], ); } } async getProjectionEAC(projectId: string): Promise { const result = await this.projectionRepo.query( 'SELECT * FROM budgets.calculate_eac($1)', [projectId], ); const projection = { linearEAC: result[0].linear_eac, cpiEAC: result[0].cpi_eac, weightedEAC: result[0].weighted_eac, recommendedEAC: result[0].weighted_eac, // Usar ponderado como recomendado }; // Guardar proyección await this.projectionRepo.save({ projectId, projectionDate: new Date(), ...projection, }); return projection; } async getCostControlDashboard(projectId: string): Promise { // Obtener presupuesto const budget = await this.budgetRepo.findOne({ where: { projectId, budgetType: 'project' }, }); // Obtener costos reales acumulados const actualCostsResult = await this.actualCostRepo .createQueryBuilder('ac') .select('SUM(ac.amount)', 'total') .where('ac.project_id = :projectId', { projectId }) .getRawOne(); const actualCosts = actualCostsResult.total || 0; // Obtener proyecto con avance físico const project = await this.projectRepo.findOne({ where: { id: projectId } }); // Calcular proyección const projection = await this.getProjectionEAC(projectId); // Obtener desviaciones críticas const criticalVariances = await this.varianceRepo.find({ where: { projectId, status: 'critical' }, order: { variancePercentage: 'DESC' }, take: 5, }); return { budget: { total: budget.totalCost, direct: budget.directCost, }, actual: { total: actualCosts, percentage: (actualCosts / budget.totalCost) * 100, }, progress: { physical: project.physicalProgress, financial: (actualCosts / budget.totalCost) * 100, }, variance: { amount: actualCosts - (budget.totalCost * (project.physicalProgress / 100)), percentage: ((actualCosts - (budget.totalCost * (project.physicalProgress / 100))) / budget.totalCost) * 100, }, projection, criticalVariances, }; } private getAccountingPeriod(date: Date): string { const year = date.getFullYear(); const month = (date.getMonth() + 1).toString().padStart(2, '0'); return `${year}-${month}`; } } ``` ## 5. React Components ```typescript // src/pages/CostControl/CostControlDashboard.tsx export function CostControlDashboard({ projectId }: { projectId: string }) { const [dashboard, setDashboard] = useState(null); useEffect(() => { costControlApi.getDashboard(projectId).then(setDashboard); }, [projectId]); if (!dashboard) return ; return (
); } // Componente de Curva S export function CurveS({ data }: { data: any }) { const chartData = useMemo(() => ({ labels: ['Sep', 'Oct', 'Nov', 'Dic', 'Ene', 'Feb', 'Mar'], datasets: [ { label: 'Presupuestado', data: [0, 15, 30, 45, 65, 85, 100], borderColor: 'blue', }, { label: 'Real', data: [0, 16, 32, data.progress.financial, null, null, null], borderColor: 'green', }, { label: 'Proyectado', data: [null, null, null, data.progress.financial, 70, 90, 105], borderColor: 'red', borderDash: [5, 5], }, ], }), [data]); return (

Curva S - Avance Financiero

); } ``` ## 6. Cron Job para Análisis Diario ```typescript // src/budgets/tasks/cost-analysis.task.ts @Injectable() export class CostAnalysisTask { constructor(private costControlService: CostControlService) {} @Cron(CronExpression.EVERY_DAY_AT_6AM) async analyzeDailyCosts() { // Obtener proyectos activos const activeProjects = await this.projectRepo.find({ where: { status: 'execution' }, }); for (const project of activeProjects) { // Recalcular desviaciones await this.costControlService.recalculateVariances(project.id); // Actualizar proyección await this.costControlService.getProjectionEAC(project.id); // Generar alertas si es necesario const variances = await this.varianceRepo.find({ where: { projectId: project.id, status: 'critical' }, }); if (variances.length > 0) { await this.notificationService.sendAlert({ projectId: project.id, type: 'critical_variance', count: variances.length, }); } } } } ``` --- **Estado:** ✅ Ready for Implementation **Nota:** Para integración con módulos de Compras, Nómina y Subcontratos, referirse a MAI-004 y MAI-007.