# ET-COST-001-BACKEND: Implementación Backend - Presupuestos y Control de Costos **Épica:** MAI-003 - Presupuestos y Control de Costos **Versión:** 1.0 **Fecha:** 2025-12-06 **Stack:** NestJS 10+, TypeORM, PostgreSQL 15+, class-validator, Bull Queue, Node-cron --- ## 1. Arquitectura General ### 1.1 Estructura de Módulos ``` src/ ├── budgets/ │ ├── modules/ │ │ ├── concept-catalog/ # Catálogo de conceptos │ │ ├── budget/ # Presupuestos │ │ ├── cost-control/ # Control de costos │ │ └── profitability/ # Análisis de rentabilidad │ ├── entities/ │ ├── dto/ │ ├── services/ │ ├── controllers/ │ ├── jobs/ # Cron jobs │ └── budgets.module.ts ``` ### 1.2 Dependencias Principales ```json { "dependencies": { "@nestjs/common": "^10.3.0", "@nestjs/typeorm": "^10.0.1", "@nestjs/schedule": "^4.0.0", "@nestjs/bull": "^10.0.1", "typeorm": "^0.3.19", "pg": "^8.11.3", "class-validator": "^0.14.0", "class-transformer": "^0.5.1", "bull": "^4.12.0", "date-fns": "^3.0.6" } } ``` --- ## 2. Schema de Base de Datos ### 2.1 Schema Principal ```sql -- Schema para presupuestos y costos CREATE SCHEMA IF NOT EXISTS budgets; -- ============================================ -- CATÁLOGO DE CONCEPTOS -- ============================================ -- Tabla: regions (Regiones para precios regionalizados) CREATE TABLE budgets.regions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), constructora_id UUID NOT NULL REFERENCES public.constructoras(id) ON DELETE CASCADE, code VARCHAR(10) NOT NULL, name VARCHAR(100) NOT NULL, description TEXT, is_active BOOLEAN DEFAULT true, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT unique_region_code UNIQUE (constructora_id, code) ); CREATE INDEX idx_regions_constructora ON budgets.regions(constructora_id); -- Tabla: concept_catalog (Catálogo de conceptos y precios unitarios) CREATE TABLE budgets.concept_catalog ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Multi-tenant constructora_id UUID NOT NULL REFERENCES public.constructoras(id) ON DELETE CASCADE, -- Identificación code VARCHAR(20) NOT NULL, name VARCHAR(255) NOT NULL, description TEXT, -- Tipo y clasificación concept_type VARCHAR(20) NOT NULL CHECK (concept_type IN ('material', 'labor', 'equipment', 'composite')), category VARCHAR(100), subcategory VARCHAR(100), unit VARCHAR(20) NOT NULL, -- Precio base (para conceptos simples) base_price DECIMAL(12,2), includes_vat BOOLEAN DEFAULT false, currency VARCHAR(3) DEFAULT 'MXN' CHECK (currency IN ('MXN', 'USD')), -- Factores waste_factor DECIMAL(5,3) DEFAULT 1.000, -- Integración de conceptos compuestos (APU) components JSONB, labor_crew JSONB, -- Factores de costo indirectos indirect_percentage DECIMAL(5,2) DEFAULT 12.00, financing_percentage DECIMAL(5,2) DEFAULT 3.00, profit_percentage DECIMAL(5,2) DEFAULT 10.00, additional_charges DECIMAL(5,2) DEFAULT 2.00, -- Costos calculados direct_cost DECIMAL(12,2), unit_price DECIMAL(12,2), unit_price_with_vat DECIMAL(12,2), -- Regionalización region_id UUID REFERENCES budgets.regions(id), -- Proveedor preferido preferred_supplier_id UUID, -- Información técnica technical_specs TEXT, performance VARCHAR(255), -- Versión y estado version INTEGER DEFAULT 1, status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'deprecated')), -- Auditoría created_by UUID NOT NULL, updated_by UUID, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT unique_concept_code UNIQUE (constructora_id, code) ); CREATE INDEX idx_concept_catalog_constructora ON budgets.concept_catalog(constructora_id); CREATE INDEX idx_concept_catalog_type ON budgets.concept_catalog(concept_type); CREATE INDEX idx_concept_catalog_category ON budgets.concept_catalog(category); CREATE INDEX idx_concept_catalog_status ON budgets.concept_catalog(status); CREATE INDEX idx_concept_catalog_code ON budgets.concept_catalog(code); CREATE INDEX idx_concept_catalog_search ON budgets.concept_catalog USING GIN (to_tsvector('spanish', name || ' ' || COALESCE(description, ''))); -- Tabla: concept_price_history CREATE TABLE budgets.concept_price_history ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), concept_id UUID NOT NULL REFERENCES budgets.concept_catalog(id) ON DELETE CASCADE, price DECIMAL(12,2) NOT NULL, valid_from DATE NOT NULL, valid_until DATE, variation_percentage DECIMAL(6,2), reason VARCHAR(255), created_by UUID NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_price_history_concept ON budgets.concept_price_history(concept_id); CREATE INDEX idx_price_history_valid_from ON budgets.concept_price_history(valid_from DESC); -- ============================================ -- PRESUPUESTOS -- ============================================ -- Tabla: budgets (Presupuestos) CREATE TABLE budgets.budgets ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Multi-tenant constructora_id UUID NOT NULL REFERENCES public.constructoras(id) ON DELETE CASCADE, -- Relación con proyecto project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE, -- Identificación code VARCHAR(20) NOT NULL, name VARCHAR(255) NOT NULL, description TEXT, -- Tipo de presupuesto budget_type VARCHAR(20) NOT NULL CHECK (budget_type IN ('initial', 'revised', 'contracted', 'final')), -- Montos total_direct_cost DECIMAL(14,2) DEFAULT 0, total_indirect_cost DECIMAL(14,2) DEFAULT 0, total_cost DECIMAL(14,2) DEFAULT 0, total_price DECIMAL(14,2) DEFAULT 0, total_price_with_vat DECIMAL(14,2) DEFAULT 0, -- Factores globales indirect_percentage DECIMAL(5,2) DEFAULT 12.00, financing_percentage DECIMAL(5,2) DEFAULT 3.00, profit_percentage DECIMAL(5,2) DEFAULT 10.00, -- Estado status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'review', 'approved', 'contracted', 'closed')), -- Fechas budget_date DATE, valid_until DATE, approved_at TIMESTAMP, approved_by UUID, -- Auditoría created_by UUID NOT NULL, updated_by UUID, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT unique_budget_code UNIQUE (constructora_id, code) ); CREATE INDEX idx_budgets_constructora ON budgets.budgets(constructora_id); CREATE INDEX idx_budgets_project ON budgets.budgets(project_id); CREATE INDEX idx_budgets_status ON budgets.budgets(status); -- Tabla: budget_items (Partidas de presupuesto) CREATE TABLE budgets.budget_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), budget_id UUID NOT NULL REFERENCES budgets.budgets(id) ON DELETE CASCADE, concept_id UUID NOT NULL REFERENCES budgets.concept_catalog(id), parent_item_id UUID REFERENCES budgets.budget_items(id), -- Jerarquía WBS wbs_code VARCHAR(50), level INTEGER DEFAULT 0, sort_order INTEGER DEFAULT 0, -- Datos del concepto code VARCHAR(20), name VARCHAR(255) NOT NULL, description TEXT, unit VARCHAR(20) NOT NULL, -- Cantidad y precios quantity DECIMAL(12,3) NOT NULL, unit_price DECIMAL(12,2) NOT NULL, total_price DECIMAL(14,2) NOT NULL, -- Detalles de costo direct_cost DECIMAL(12,2), indirect_cost DECIMAL(12,2), -- Estado is_header BOOLEAN DEFAULT false, is_active BOOLEAN DEFAULT true, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_budget_items_budget ON budgets.budget_items(budget_id); CREATE INDEX idx_budget_items_concept ON budgets.budget_items(concept_id); CREATE INDEX idx_budget_items_parent ON budgets.budget_items(parent_item_id); CREATE INDEX idx_budget_items_wbs ON budgets.budget_items(wbs_code); -- Tabla: budget_explosions (Explosión de insumos) CREATE TABLE budgets.budget_explosions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), budget_id UUID NOT NULL REFERENCES budgets.budgets(id) ON DELETE CASCADE, budget_item_id UUID NOT NULL REFERENCES budgets.budget_items(id) ON DELETE CASCADE, concept_id UUID NOT NULL REFERENCES budgets.concept_catalog(id), -- Tipo de insumo resource_type VARCHAR(20) CHECK (resource_type IN ('material', 'labor', 'equipment')), -- Datos del insumo code VARCHAR(20), name VARCHAR(255), unit VARCHAR(20), -- Cantidad unit_quantity DECIMAL(12,4), total_quantity DECIMAL(14,4), -- Precio unit_price DECIMAL(12,2), total_price DECIMAL(14,2), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_budget_explosions_budget ON budgets.budget_explosions(budget_id); CREATE INDEX idx_budget_explosions_item ON budgets.budget_explosions(budget_item_id); CREATE INDEX idx_budget_explosions_resource_type ON budgets.budget_explosions(resource_type); -- ============================================ -- CONTROL DE COSTOS -- ============================================ -- Tabla: cost_tracking (Seguimiento de costos) CREATE TABLE budgets.cost_tracking ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), budget_id UUID NOT NULL REFERENCES budgets.budgets(id) ON DELETE CASCADE, budget_item_id UUID NOT NULL REFERENCES budgets.budget_items(id) ON DELETE CASCADE, -- Período tracking_date DATE NOT NULL, period_type VARCHAR(20) CHECK (period_type IN ('daily', 'weekly', 'monthly')), -- Avance físico physical_progress_percentage DECIMAL(5,2), quantity_executed DECIMAL(12,3), -- Costos budgeted_cost DECIMAL(14,2), actual_cost DECIMAL(14,2), variance_cost DECIMAL(14,2), variance_percentage DECIMAL(6,2), -- EVM (Earned Value Management) planned_value DECIMAL(14,2), earned_value DECIMAL(14,2), actual_cost_ev DECIMAL(14,2), -- KPIs cpi DECIMAL(5,3), -- Cost Performance Index spi DECIMAL(5,3), -- Schedule Performance Index eac DECIMAL(14,2), -- Estimate At Completion etc DECIMAL(14,2), -- Estimate To Complete vac DECIMAL(14,2), -- Variance At Completion -- Notas notes TEXT, created_by UUID NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_cost_tracking_budget ON budgets.cost_tracking(budget_id); CREATE INDEX idx_cost_tracking_item ON budgets.cost_tracking(budget_item_id); CREATE INDEX idx_cost_tracking_date ON budgets.cost_tracking(tracking_date DESC); -- Tabla: s_curves (Curva S de avance) CREATE TABLE budgets.s_curves ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), budget_id UUID NOT NULL REFERENCES budgets.budgets(id) ON DELETE CASCADE, -- Período period_date DATE NOT NULL, -- Avances acumulados planned_progress_percentage DECIMAL(5,2), actual_progress_percentage DECIMAL(5,2), -- Costos acumulados planned_cost_cumulative DECIMAL(14,2), actual_cost_cumulative DECIMAL(14,2), earned_value_cumulative DECIMAL(14,2), -- Varianzas schedule_variance DECIMAL(14,2), cost_variance DECIMAL(14,2), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT unique_s_curve_period UNIQUE (budget_id, period_date) ); CREATE INDEX idx_s_curves_budget ON budgets.s_curves(budget_id); CREATE INDEX idx_s_curves_date ON budgets.s_curves(period_date); -- ============================================ -- ANÁLISIS DE RENTABILIDAD -- ============================================ -- Tabla: profitability_analysis CREATE TABLE budgets.profitability_analysis ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), budget_id UUID NOT NULL REFERENCES budgets.budgets(id) ON DELETE CASCADE, project_id UUID NOT NULL REFERENCES projects.projects(id), -- Período de análisis analysis_date DATE NOT NULL, analysis_type VARCHAR(20) CHECK (analysis_type IN ('snapshot', 'forecast', 'final')), -- Ingresos contracted_amount DECIMAL(14,2), billed_amount DECIMAL(14,2), collected_amount DECIMAL(14,2), -- Costos budgeted_cost DECIMAL(14,2), actual_cost DECIMAL(14,2), committed_cost DECIMAL(14,2), forecast_cost DECIMAL(14,2), -- Rentabilidad gross_margin DECIMAL(14,2), gross_margin_percentage DECIMAL(6,2), net_margin DECIMAL(14,2), net_margin_percentage DECIMAL(6,2), -- ROI roi_percentage DECIMAL(6,2), -- Indicadores cost_overrun DECIMAL(14,2), cost_overrun_percentage DECIMAL(6,2), -- Proyección a la finalización projected_revenue DECIMAL(14,2), projected_cost DECIMAL(14,2), projected_margin DECIMAL(14,2), projected_margin_percentage DECIMAL(6,2), -- Notas notes TEXT, created_by UUID NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT unique_analysis_period UNIQUE (budget_id, analysis_date, analysis_type) ); CREATE INDEX idx_profitability_budget ON budgets.profitability_analysis(budget_id); CREATE INDEX idx_profitability_project ON budgets.profitability_analysis(project_id); CREATE INDEX idx_profitability_date ON budgets.profitability_analysis(analysis_date DESC); ``` ### 2.2 Funciones y Triggers ```sql -- Trigger: Actualizar updated_at CREATE OR REPLACE FUNCTION budgets.update_timestamp() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = CURRENT_TIMESTAMP; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_concept_updated_at BEFORE UPDATE ON budgets.concept_catalog FOR EACH ROW EXECUTE FUNCTION budgets.update_timestamp(); CREATE TRIGGER trigger_budget_updated_at BEFORE UPDATE ON budgets.budgets FOR EACH ROW EXECUTE FUNCTION budgets.update_timestamp(); CREATE TRIGGER trigger_budget_item_updated_at BEFORE UPDATE ON budgets.budget_items FOR EACH ROW EXECUTE FUNCTION budgets.update_timestamp(); -- Trigger: Crear historial de precios CREATE OR REPLACE FUNCTION budgets.create_price_history() RETURNS TRIGGER AS $$ DECLARE v_variation DECIMAL(6,2); BEGIN IF (NEW.base_price IS DISTINCT FROM OLD.base_price) THEN IF OLD.base_price IS NOT NULL AND OLD.base_price > 0 THEN v_variation := ((NEW.base_price - OLD.base_price) / OLD.base_price) * 100; ELSE v_variation := NULL; END IF; UPDATE budgets.concept_price_history SET valid_until = CURRENT_DATE - INTERVAL '1 day' WHERE concept_id = NEW.id AND valid_until IS NULL; INSERT INTO budgets.concept_price_history ( concept_id, price, valid_from, variation_percentage, created_by ) VALUES ( NEW.id, NEW.base_price, CURRENT_DATE, v_variation, NEW.updated_by ); END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_price_history AFTER UPDATE ON budgets.concept_catalog FOR EACH ROW EXECUTE FUNCTION budgets.create_price_history(); -- Función: Calcular precio de concepto compuesto (APU) CREATE OR REPLACE FUNCTION budgets.calculate_composite_price( p_concept_id UUID ) RETURNS DECIMAL AS $$ DECLARE v_concept RECORD; v_component JSONB; v_labor JSONB; v_direct_cost DECIMAL := 0; v_labor_cost DECIMAL := 0; v_total_cost DECIMAL; BEGIN SELECT * INTO v_concept FROM budgets.concept_catalog WHERE id = p_concept_id; IF v_concept.concept_type != 'composite' THEN RETURN v_concept.base_price; END IF; -- Costo de materiales/equipos IF v_concept.components IS NOT NULL THEN FOR v_component IN SELECT * FROM jsonb_array_elements(v_concept.components) LOOP v_direct_cost := v_direct_cost + ( (v_component->>'quantity')::DECIMAL * (SELECT COALESCE(base_price, unit_price, 0) FROM budgets.concept_catalog WHERE id = (v_component->>'conceptId')::UUID) ); END LOOP; END IF; -- Costo de mano de obra IF v_concept.labor_crew IS NOT NULL THEN FOR v_labor IN SELECT * FROM jsonb_array_elements(v_concept.labor_crew) LOOP v_labor_cost := v_labor_cost + ( (v_labor->>'quantity')::DECIMAL * (v_labor->>'dailyWage')::DECIMAL * (v_labor->>'fsr')::DECIMAL ); END LOOP; END IF; v_direct_cost := v_direct_cost + v_labor_cost; -- Aplicar factores v_total_cost := v_direct_cost; v_total_cost := v_total_cost * (1 + v_concept.indirect_percentage / 100); v_total_cost := v_total_cost * (1 + v_concept.financing_percentage / 100); v_total_cost := v_total_cost * (1 + v_concept.profit_percentage / 100); v_total_cost := v_total_cost * (1 + v_concept.additional_charges / 100); UPDATE budgets.concept_catalog SET direct_cost = v_direct_cost, unit_price = v_total_cost, unit_price_with_vat = v_total_cost * 1.16 WHERE id = p_concept_id; RETURN v_total_cost; END; $$ LANGUAGE plpgsql; -- Función: Explosión de insumos de presupuesto CREATE OR REPLACE FUNCTION budgets.explode_budget_materials( p_budget_id UUID ) RETURNS TABLE ( resource_type VARCHAR, code VARCHAR, name VARCHAR, unit VARCHAR, total_quantity DECIMAL, unit_price DECIMAL, total_price DECIMAL ) AS $$ BEGIN RETURN QUERY WITH RECURSIVE explosion AS ( -- Items base del presupuesto SELECT bi.id, bi.concept_id, bi.quantity, cc.concept_type, cc.code, cc.name, cc.unit, cc.base_price, cc.components, 1::INTEGER AS level FROM budgets.budget_items bi JOIN budgets.concept_catalog cc ON bi.concept_id = cc.id WHERE bi.budget_id = p_budget_id UNION ALL -- Explosión recursiva de componentes SELECT e.id, (comp->>'conceptId')::UUID, e.quantity * (comp->>'quantity')::DECIMAL, cc.concept_type, cc.code, cc.name, cc.unit, cc.base_price, cc.components, e.level + 1 FROM explosion e CROSS JOIN jsonb_array_elements(e.components) AS comp JOIN budgets.concept_catalog cc ON cc.id = (comp->>'conceptId')::UUID WHERE e.components IS NOT NULL AND e.level < 10 ) SELECT e.concept_type::VARCHAR, e.code::VARCHAR, e.name::VARCHAR, e.unit::VARCHAR, SUM(e.quantity)::DECIMAL, MAX(e.base_price)::DECIMAL, (SUM(e.quantity) * MAX(e.base_price))::DECIMAL FROM explosion e WHERE e.concept_type IN ('material', 'labor', 'equipment') GROUP BY e.concept_type, e.code, e.name, e.unit ORDER BY e.concept_type, e.code; END; $$ LANGUAGE plpgsql; -- Función: Calcular KPIs de EVM CREATE OR REPLACE FUNCTION budgets.calculate_evm_kpis( p_budget_id UUID, p_date DATE DEFAULT CURRENT_DATE ) RETURNS TABLE ( planned_value DECIMAL, earned_value DECIMAL, actual_cost DECIMAL, cpi DECIMAL, spi DECIMAL, eac DECIMAL, etc DECIMAL, vac DECIMAL ) AS $$ DECLARE v_budget_total DECIMAL; v_pv DECIMAL; v_ev DECIMAL; v_ac DECIMAL; v_cpi DECIMAL; v_spi DECIMAL; v_eac DECIMAL; v_etc DECIMAL; v_vac DECIMAL; BEGIN -- Obtener total del presupuesto SELECT total_price INTO v_budget_total FROM budgets.budgets WHERE id = p_budget_id; -- Calcular PV (Planned Value) - basado en programación SELECT SUM(planned_value) INTO v_pv FROM budgets.cost_tracking WHERE budget_id = p_budget_id AND tracking_date <= p_date; -- Calcular EV (Earned Value) - basado en avance físico SELECT SUM(earned_value) INTO v_ev FROM budgets.cost_tracking WHERE budget_id = p_budget_id AND tracking_date <= p_date; -- Calcular AC (Actual Cost) - costo real SELECT SUM(actual_cost_ev) INTO v_ac FROM budgets.cost_tracking WHERE budget_id = p_budget_id AND tracking_date <= p_date; -- KPIs v_cpi := CASE WHEN v_ac > 0 THEN v_ev / v_ac ELSE NULL END; v_spi := CASE WHEN v_pv > 0 THEN v_ev / v_pv ELSE NULL END; v_eac := CASE WHEN v_cpi > 0 THEN v_budget_total / v_cpi ELSE NULL END; v_etc := v_eac - v_ac; v_vac := v_budget_total - v_eac; RETURN QUERY SELECT v_pv, v_ev, v_ac, v_cpi, v_spi, v_eac, v_etc, v_vac; END; $$ LANGUAGE plpgsql; ``` --- ## 3. Backend - NestJS ### 3.1 Module Structure ```typescript // src/budgets/budgets.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ScheduleModule } from '@nestjs/schedule'; import { BullModule } from '@nestjs/bull'; // Entities import { ConceptCatalog } from './entities/concept-catalog.entity'; import { ConceptPriceHistory } from './entities/concept-price-history.entity'; import { Region } from './entities/region.entity'; import { Budget } from './entities/budget.entity'; import { BudgetItem } from './entities/budget-item.entity'; import { BudgetExplosion } from './entities/budget-explosion.entity'; import { CostTracking } from './entities/cost-tracking.entity'; import { SCurve } from './entities/s-curve.entity'; import { ProfitabilityAnalysis } from './entities/profitability-analysis.entity'; // Modules import { ConceptCatalogModule } from './modules/concept-catalog/concept-catalog.module'; import { BudgetModule } from './modules/budget/budget.module'; import { CostControlModule } from './modules/cost-control/cost-control.module'; import { ProfitabilityModule } from './modules/profitability/profitability.module'; // Jobs import { CostAnalysisJob } from './jobs/cost-analysis.job'; @Module({ imports: [ TypeOrmModule.forFeature([ ConceptCatalog, ConceptPriceHistory, Region, Budget, BudgetItem, BudgetExplosion, CostTracking, SCurve, ProfitabilityAnalysis, ]), ScheduleModule.forRoot(), BullModule.registerQueue({ name: 'budget-calculations', }), ConceptCatalogModule, BudgetModule, CostControlModule, ProfitabilityModule, ], providers: [CostAnalysisJob], }) export class BudgetsModule {} ``` --- ## 4. Módulo: ConceptCatalogModule ### 4.1 Entity ```typescript // src/budgets/entities/concept-catalog.entity.ts import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn, Index, } from 'typeorm'; export enum ConceptType { MATERIAL = 'material', LABOR = 'labor', EQUIPMENT = 'equipment', COMPOSITE = 'composite', } export enum ConceptStatus { ACTIVE = 'active', DEPRECATED = 'deprecated', } export interface ComponentItem { conceptId: string; quantity: number; unit: string; name?: string; } export interface LaborCrewItem { category: string; quantity: number; dailyWage: number; fsr: number; // Factor de Salario Real } @Entity('concept_catalog', { schema: 'budgets' }) @Index(['constructoraId', 'code'], { unique: true }) export class ConceptCatalog { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'constructora_id', type: 'uuid' }) @Index() constructoraId: string; @Column({ type: 'varchar', length: 20 }) @Index() code: string; @Column({ type: 'varchar', length: 255 }) name: string; @Column({ type: 'text', nullable: true }) description: string; @Column({ name: 'concept_type', type: 'enum', enum: ConceptType }) @Index() conceptType: ConceptType; @Column({ type: 'varchar', length: 100, nullable: true }) @Index() category: string; @Column({ type: 'varchar', length: 100, nullable: true }) subcategory: string; @Column({ type: 'varchar', length: 20 }) unit: string; @Column({ name: 'base_price', type: 'decimal', precision: 12, scale: 2, nullable: true }) basePrice: number; @Column({ name: 'includes_vat', type: 'boolean', default: false }) includesVAT: boolean; @Column({ type: 'varchar', length: 3, default: 'MXN' }) currency: string; @Column({ name: 'waste_factor', type: 'decimal', precision: 5, scale: 3, default: 1.000 }) wasteFactor: number; @Column({ type: 'jsonb', nullable: true }) components: ComponentItem[]; @Column({ name: 'labor_crew', type: 'jsonb', nullable: true }) laborCrew: LaborCrewItem[]; @Column({ name: 'indirect_percentage', type: 'decimal', precision: 5, scale: 2, default: 12.00 }) indirectPercentage: number; @Column({ name: 'financing_percentage', type: 'decimal', precision: 5, scale: 2, default: 3.00 }) financingPercentage: number; @Column({ name: 'profit_percentage', type: 'decimal', precision: 5, scale: 2, default: 10.00 }) profitPercentage: number; @Column({ name: 'additional_charges', type: 'decimal', precision: 5, scale: 2, default: 2.00 }) additionalCharges: number; @Column({ name: 'direct_cost', type: 'decimal', precision: 12, scale: 2, nullable: true }) directCost: number; @Column({ name: 'unit_price', type: 'decimal', precision: 12, scale: 2, nullable: true }) unitPrice: number; @Column({ name: 'unit_price_with_vat', type: 'decimal', precision: 12, scale: 2, nullable: true }) unitPriceWithVAT: number; @Column({ name: 'region_id', type: 'uuid', nullable: true }) regionId: string; @Column({ name: 'preferred_supplier_id', type: 'uuid', nullable: true }) preferredSupplierId: string; @Column({ name: 'technical_specs', type: 'text', nullable: true }) technicalSpecs: string; @Column({ type: 'varchar', length: 255, nullable: true }) performance: string; @Column({ type: 'integer', default: 1 }) version: number; @Column({ type: 'enum', enum: ConceptStatus, default: ConceptStatus.ACTIVE }) @Index() status: ConceptStatus; @Column({ name: 'created_by', type: 'uuid' }) createdBy: string; @Column({ name: 'updated_by', type: 'uuid', nullable: true }) updatedBy: string; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; } ``` ### 4.2 DTOs ```typescript // src/budgets/modules/concept-catalog/dto/create-concept.dto.ts import { IsString, IsEnum, IsNumber, IsOptional, IsBoolean, IsArray, ValidateNested, Min, Max, } from 'class-validator'; import { Type } from 'class-transformer'; import { ConceptType } from '../../../entities/concept-catalog.entity'; export class ComponentItemDto { @IsString() conceptId: string; @IsNumber() @Min(0) quantity: number; @IsString() unit: string; @IsString() @IsOptional() name?: string; } export class LaborCrewItemDto { @IsString() category: string; @IsNumber() @Min(0) quantity: number; @IsNumber() @Min(0) dailyWage: number; @IsNumber() @Min(1) @Max(2) fsr: number; } export class CreateConceptDto { @IsString() @IsOptional() code?: string; @IsString() name: string; @IsString() @IsOptional() description?: string; @IsEnum(ConceptType) conceptType: ConceptType; @IsString() @IsOptional() category?: string; @IsString() @IsOptional() subcategory?: string; @IsString() unit: string; @IsNumber() @IsOptional() @Min(0) basePrice?: number; @IsBoolean() @IsOptional() includesVAT?: boolean; @IsString() @IsOptional() currency?: string; @IsNumber() @IsOptional() @Min(1) @Max(2) wasteFactor?: number; @IsArray() @IsOptional() @ValidateNested({ each: true }) @Type(() => ComponentItemDto) components?: ComponentItemDto[]; @IsArray() @IsOptional() @ValidateNested({ each: true }) @Type(() => LaborCrewItemDto) laborCrew?: LaborCrewItemDto[]; @IsNumber() @IsOptional() @Min(0) @Max(50) indirectPercentage?: number; @IsNumber() @IsOptional() @Min(0) @Max(20) financingPercentage?: number; @IsNumber() @IsOptional() @Min(0) @Max(50) profitPercentage?: number; @IsNumber() @IsOptional() @Min(0) @Max(10) additionalCharges?: number; @IsString() @IsOptional() regionId?: string; @IsString() @IsOptional() preferredSupplierId?: string; @IsString() @IsOptional() technicalSpecs?: string; @IsString() @IsOptional() performance?: string; } // src/budgets/modules/concept-catalog/dto/bulk-update-prices.dto.ts export class BulkUpdatePricesDto { @IsArray() conceptIds: string[]; @IsEnum(['percentage', 'fixed']) adjustmentType: 'percentage' | 'fixed'; @IsNumber() adjustmentValue: number; @IsString() reason: string; @IsString() @IsOptional() validFrom?: string; } ``` ### 4.3 Service ```typescript // src/budgets/modules/concept-catalog/concept-catalog.service.ts import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; import { ConceptCatalog, ConceptType, ConceptStatus } from '../../entities/concept-catalog.entity'; import { CreateConceptDto } from './dto/create-concept.dto'; import { UpdateConceptDto } from './dto/update-concept.dto'; import { BulkUpdatePricesDto } from './dto/bulk-update-prices.dto'; import { EventEmitter2 } from '@nestjs/event-emitter'; @Injectable() export class ConceptCatalogService { constructor( @InjectRepository(ConceptCatalog) private conceptRepo: Repository, private eventEmitter: EventEmitter2, ) {} async create(dto: CreateConceptDto, constructoraId: string, userId: string): Promise { if (!dto.code) { dto.code = await this.generateCode(dto.conceptType, constructoraId); } const exists = await this.conceptRepo.findOne({ where: { constructoraId, code: dto.code }, }); if (exists) { throw new BadRequestException(`El código ${dto.code} ya existe`); } const concept = this.conceptRepo.create({ ...dto, constructoraId, createdBy: userId, }); await this.conceptRepo.save(concept); if (concept.conceptType === ConceptType.COMPOSITE) { await this.calculateCompositePrice(concept.id); } return concept; } async findAll( constructoraId: string, filters?: { type?: ConceptType; category?: string; status?: ConceptStatus; search?: string; }, ): Promise { const query = this.conceptRepo .createQueryBuilder('concept') .where('concept.constructora_id = :constructoraId', { constructoraId }); if (filters?.type) { query.andWhere('concept.concept_type = :type', { type: filters.type }); } if (filters?.category) { query.andWhere('concept.category = :category', { category: filters.category }); } if (filters?.status) { query.andWhere('concept.status = :status', { status: filters.status }); } if (filters?.search) { query.andWhere( `to_tsvector('spanish', concept.name || ' ' || COALESCE(concept.description, '')) @@ plainto_tsquery('spanish', :search)`, { search: filters.search }, ); } return await query .orderBy('concept.category', 'ASC') .addOrderBy('concept.code', 'ASC') .getMany(); } async findOne(id: string, constructoraId: string): Promise { const concept = await this.conceptRepo.findOne({ where: { id, constructoraId }, }); if (!concept) { throw new NotFoundException('Concepto no encontrado'); } return concept; } async update( id: string, dto: UpdateConceptDto, constructoraId: string, userId: string, ): Promise { const concept = await this.findOne(id, constructoraId); Object.assign(concept, dto); concept.updatedBy = userId; await this.conceptRepo.save(concept); if (concept.conceptType === ConceptType.COMPOSITE) { await this.calculateCompositePrice(concept.id); } return concept; } async bulkUpdatePrices(dto: BulkUpdatePricesDto, constructoraId: string, userId: string): Promise { const concepts = await this.conceptRepo.find({ where: { id: In(dto.conceptIds), constructoraId, }, }); for (const concept of concepts) { if (dto.adjustmentType === 'percentage') { concept.basePrice = concept.basePrice * (1 + dto.adjustmentValue / 100); } else { concept.basePrice = dto.adjustmentValue; } concept.updatedBy = userId; } await this.conceptRepo.save(concepts); this.eventEmitter.emit('concepts.prices_updated', { conceptIds: dto.conceptIds, reason: dto.reason, adjustmentValue: dto.adjustmentValue, }); } async calculateCompositePrice(conceptId: string): Promise { const result = await this.conceptRepo.query( 'SELECT budgets.calculate_composite_price($1) as unit_price', [conceptId], ); return result[0].unit_price; } private async generateCode(type: ConceptType, constructoraId: string): Promise { const prefix = { [ConceptType.MATERIAL]: 'MAT', [ConceptType.LABOR]: 'MO', [ConceptType.EQUIPMENT]: 'MAQ', [ConceptType.COMPOSITE]: 'CC', }[type]; const year = new Date().getFullYear(); const lastConcept = await this.conceptRepo .createQueryBuilder('c') .where('c.constructora_id = :constructoraId', { constructoraId }) .andWhere(`c.code LIKE :pattern`, { pattern: `${prefix}-${year}-%` }) .orderBy(`c.code`, 'DESC') .getOne(); let nextNumber = 1; if (lastConcept) { const parts = lastConcept.code.split('-'); nextNumber = parseInt(parts[2]) + 1; } return `${prefix}-${year}-${nextNumber.toString().padStart(3, '0')}`; } } ``` ### 4.4 Controller ```typescript // src/budgets/modules/concept-catalog/concept-catalog.controller.ts import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, } from '@nestjs/common'; import { ConceptCatalogService } from './concept-catalog.service'; import { CreateConceptDto } from './dto/create-concept.dto'; import { UpdateConceptDto } from './dto/update-concept.dto'; import { BulkUpdatePricesDto } from './dto/bulk-update-prices.dto'; import { JwtAuthGuard } from '../../../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../../../auth/guards/roles.guard'; import { Roles } from '../../../auth/decorators/roles.decorator'; import { CurrentUser } from '../../../auth/decorators/current-user.decorator'; import { ConceptType, ConceptStatus } from '../../entities/concept-catalog.entity'; @Controller('api/concept-catalog') @UseGuards(JwtAuthGuard, RolesGuard) export class ConceptCatalogController { constructor(private conceptCatalogService: ConceptCatalogService) {} @Post() @Roles('admin', 'director', 'engineer') async create(@Body() dto: CreateConceptDto, @CurrentUser() user: any) { return await this.conceptCatalogService.create(dto, user.constructoraId, user.sub); } @Get() async findAll( @CurrentUser() user: any, @Query('type') type?: ConceptType, @Query('category') category?: string, @Query('status') status?: ConceptStatus, @Query('search') search?: string, ) { return await this.conceptCatalogService.findAll(user.constructoraId, { type, category, status, search, }); } @Get(':id') async findOne(@Param('id') id: string, @CurrentUser() user: any) { return await this.conceptCatalogService.findOne(id, user.constructoraId); } @Put(':id') @Roles('admin', 'director', 'engineer') async update(@Param('id') id: string, @Body() dto: UpdateConceptDto, @CurrentUser() user: any) { return await this.conceptCatalogService.update(id, dto, user.constructoraId, user.sub); } @Post('bulk-update-prices') @Roles('admin', 'director') async bulkUpdatePrices(@Body() dto: BulkUpdatePricesDto, @CurrentUser() user: any) { await this.conceptCatalogService.bulkUpdatePrices(dto, user.constructoraId, user.sub); return { message: `Precios actualizados para ${dto.conceptIds.length} conceptos` }; } @Post(':id/calculate-price') @Roles('admin', 'director', 'engineer') async calculatePrice(@Param('id') id: string, @CurrentUser() user: any) { const price = await this.conceptCatalogService.calculateCompositePrice(id); return { unitPrice: price }; } } ``` --- ## 5. Módulo: BudgetModule ### 5.1 Entity ```typescript // src/budgets/entities/budget.entity.ts import { Entity, Column, PrimaryGeneratedColumn, OneToMany, CreateDateColumn, UpdateDateColumn, Index, } from 'typeorm'; import { BudgetItem } from './budget-item.entity'; export enum BudgetType { INITIAL = 'initial', REVISED = 'revised', CONTRACTED = 'contracted', FINAL = 'final', } export enum BudgetStatus { DRAFT = 'draft', REVIEW = 'review', APPROVED = 'approved', CONTRACTED = 'contracted', CLOSED = 'closed', } @Entity('budgets', { schema: 'budgets' }) @Index(['constructoraId', 'code'], { unique: true }) export class Budget { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'constructora_id', type: 'uuid' }) @Index() constructoraId: string; @Column({ name: 'project_id', type: 'uuid' }) @Index() projectId: string; @Column({ type: 'varchar', length: 20 }) code: string; @Column({ type: 'varchar', length: 255 }) name: string; @Column({ type: 'text', nullable: true }) description: string; @Column({ name: 'budget_type', type: 'enum', enum: BudgetType }) budgetType: BudgetType; @Column({ name: 'total_direct_cost', type: 'decimal', precision: 14, scale: 2, default: 0 }) totalDirectCost: number; @Column({ name: 'total_indirect_cost', type: 'decimal', precision: 14, scale: 2, default: 0 }) totalIndirectCost: number; @Column({ name: 'total_cost', type: 'decimal', precision: 14, scale: 2, default: 0 }) totalCost: number; @Column({ name: 'total_price', type: 'decimal', precision: 14, scale: 2, default: 0 }) totalPrice: number; @Column({ name: 'total_price_with_vat', type: 'decimal', precision: 14, scale: 2, default: 0 }) totalPriceWithVAT: number; @Column({ name: 'indirect_percentage', type: 'decimal', precision: 5, scale: 2, default: 12.00 }) indirectPercentage: number; @Column({ name: 'financing_percentage', type: 'decimal', precision: 5, scale: 2, default: 3.00 }) financingPercentage: number; @Column({ name: 'profit_percentage', type: 'decimal', precision: 5, scale: 2, default: 10.00 }) profitPercentage: number; @Column({ type: 'enum', enum: BudgetStatus, default: BudgetStatus.DRAFT }) @Index() status: BudgetStatus; @Column({ name: 'budget_date', type: 'date', nullable: true }) budgetDate: Date; @Column({ name: 'valid_until', type: 'date', nullable: true }) validUntil: Date; @Column({ name: 'approved_at', type: 'timestamp', nullable: true }) approvedAt: Date; @Column({ name: 'approved_by', type: 'uuid', nullable: true }) approvedBy: string; @Column({ name: 'created_by', type: 'uuid' }) createdBy: string; @Column({ name: 'updated_by', type: 'uuid', nullable: true }) updatedBy: string; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; @OneToMany(() => BudgetItem, (item) => item.budget) items: BudgetItem[]; } // src/budgets/entities/budget-item.entity.ts @Entity('budget_items', { schema: 'budgets' }) export class BudgetItem { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'budget_id', type: 'uuid' }) @Index() budgetId: string; @ManyToOne(() => Budget, (budget) => budget.items) @JoinColumn({ name: 'budget_id' }) budget: Budget; @Column({ name: 'concept_id', type: 'uuid' }) @Index() conceptId: string; @Column({ name: 'parent_item_id', type: 'uuid', nullable: true }) @Index() parentItemId: string; @Column({ name: 'wbs_code', type: 'varchar', length: 50, nullable: true }) @Index() wbsCode: string; @Column({ type: 'integer', default: 0 }) level: number; @Column({ name: 'sort_order', type: 'integer', default: 0 }) sortOrder: number; @Column({ type: 'varchar', length: 20 }) code: string; @Column({ type: 'varchar', length: 255 }) name: string; @Column({ type: 'text', nullable: true }) description: string; @Column({ type: 'varchar', length: 20 }) unit: string; @Column({ type: 'decimal', precision: 12, scale: 3 }) quantity: number; @Column({ name: 'unit_price', type: 'decimal', precision: 12, scale: 2 }) unitPrice: number; @Column({ name: 'total_price', type: 'decimal', precision: 14, scale: 2 }) totalPrice: number; @Column({ name: 'direct_cost', type: 'decimal', precision: 12, scale: 2, nullable: true }) directCost: number; @Column({ name: 'indirect_cost', type: 'decimal', precision: 12, scale: 2, nullable: true }) indirectCost: number; @Column({ name: 'is_header', type: 'boolean', default: false }) isHeader: boolean; @Column({ name: 'is_active', type: 'boolean', default: true }) isActive: boolean; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; } ``` ### 5.2 Service ```typescript // src/budgets/modules/budget/budget.service.ts import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; import { Budget, BudgetStatus } from '../../entities/budget.entity'; import { BudgetItem } from '../../entities/budget-item.entity'; import { BudgetExplosion } from '../../entities/budget-explosion.entity'; import { CreateBudgetDto } from './dto/create-budget.dto'; import { UpdateBudgetDto } from './dto/update-budget.dto'; @Injectable() export class BudgetService { constructor( @InjectRepository(Budget) private budgetRepo: Repository, @InjectRepository(BudgetItem) private budgetItemRepo: Repository, @InjectRepository(BudgetExplosion) private explosionRepo: Repository, private dataSource: DataSource, ) {} async create(dto: CreateBudgetDto, constructoraId: string, userId: string): Promise { const code = await this.generateCode(constructoraId); const budget = this.budgetRepo.create({ ...dto, code, constructoraId, createdBy: userId, }); return await this.budgetRepo.save(budget); } async findAll(constructoraId: string, projectId?: string): Promise { const where: any = { constructoraId }; if (projectId) { where.projectId = projectId; } return await this.budgetRepo.find({ where, order: { createdAt: 'DESC' }, }); } async findOne(id: string, constructoraId: string): Promise { const budget = await this.budgetRepo.findOne({ where: { id, constructoraId }, relations: ['items'], }); if (!budget) { throw new NotFoundException('Presupuesto no encontrado'); } return budget; } async addItems(budgetId: string, items: any[], constructoraId: string): Promise { const budget = await this.findOne(budgetId, constructoraId); const budgetItems = items.map((item) => this.budgetItemRepo.create({ ...item, budgetId: budget.id, }), ); await this.budgetItemRepo.save(budgetItems); await this.recalculateTotals(budget.id); } async recalculateTotals(budgetId: string): Promise { const items = await this.budgetItemRepo.find({ where: { budgetId, isActive: true }, }); const totalDirectCost = items.reduce((sum, item) => sum + Number(item.directCost || 0), 0); const totalPrice = items.reduce((sum, item) => sum + Number(item.totalPrice), 0); await this.budgetRepo.update(budgetId, { totalDirectCost, totalPrice, totalPriceWithVAT: totalPrice * 1.16, }); } async explodeMaterials(budgetId: string, constructoraId: string): Promise { const budget = await this.findOne(budgetId, constructoraId); const result = await this.dataSource.query( 'SELECT * FROM budgets.explode_budget_materials($1)', [budget.id], ); // Guardar explosión en tabla await this.explosionRepo.delete({ budgetId: budget.id }); const explosions = result.map((row: any) => this.explosionRepo.create({ budgetId: budget.id, resourceType: row.resource_type, code: row.code, name: row.name, unit: row.unit, totalQuantity: row.total_quantity, unitPrice: row.unit_price, totalPrice: row.total_price, }), ); await this.explosionRepo.save(explosions); return result; } private async generateCode(constructoraId: string): Promise { const year = new Date().getFullYear(); const lastBudget = await this.budgetRepo .createQueryBuilder('b') .where('b.constructora_id = :constructoraId', { constructoraId }) .andWhere(`b.code LIKE :pattern`, { pattern: `PRES-${year}-%` }) .orderBy('b.code', 'DESC') .getOne(); let nextNumber = 1; if (lastBudget) { const parts = lastBudget.code.split('-'); nextNumber = parseInt(parts[2]) + 1; } return `PRES-${year}-${nextNumber.toString().padStart(4, '0')}`; } } ``` --- ## 6. Módulo: CostControlModule ### 6.1 Service ```typescript // src/budgets/modules/cost-control/cost-control.service.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource, Between } from 'typeorm'; import { CostTracking } from '../../entities/cost-tracking.entity'; import { SCurve } from '../../entities/s-curve.entity'; import { CreateCostTrackingDto } from './dto/create-cost-tracking.dto'; import { startOfDay, endOfDay } from 'date-fns'; @Injectable() export class CostControlService { constructor( @InjectRepository(CostTracking) private costTrackingRepo: Repository, @InjectRepository(SCurve) private sCurveRepo: Repository, private dataSource: DataSource, ) {} async createTracking(dto: CreateCostTrackingDto, userId: string): Promise { // Calcular varianzas const varianceCost = dto.budgetedCost - dto.actualCost; const variancePercentage = (varianceCost / dto.budgetedCost) * 100; const tracking = this.costTrackingRepo.create({ ...dto, varianceCost, variancePercentage, createdBy: userId, }); await this.costTrackingRepo.save(tracking); // Calcular KPIs de EVM await this.calculateEVMKPIs(dto.budgetId, dto.trackingDate); // Actualizar curva S await this.updateSCurve(dto.budgetId, dto.trackingDate); return tracking; } async calculateEVMKPIs(budgetId: string, date: Date = new Date()): Promise { const result = await this.dataSource.query( 'SELECT * FROM budgets.calculate_evm_kpis($1, $2)', [budgetId, date], ); const kpis = result[0]; // Actualizar registros de tracking con KPIs await this.costTrackingRepo .createQueryBuilder() .update() .set({ plannedValue: kpis.planned_value, earnedValue: kpis.earned_value, actualCostEv: kpis.actual_cost, cpi: kpis.cpi, spi: kpis.spi, eac: kpis.eac, etc: kpis.etc, vac: kpis.vac, }) .where('budget_id = :budgetId', { budgetId }) .andWhere('tracking_date = :date', { date }) .execute(); return kpis; } async updateSCurve(budgetId: string, date: Date): Promise { const trackings = await this.costTrackingRepo.find({ where: { budgetId, trackingDate: Between(startOfDay(new Date(0)), endOfDay(date)), }, order: { trackingDate: 'ASC' }, }); let plannedCumulative = 0; let actualCumulative = 0; let earnedCumulative = 0; const sCurveData = trackings.reduce((acc, tracking) => { plannedCumulative += Number(tracking.plannedValue || 0); actualCumulative += Number(tracking.actualCostEv || 0); earnedCumulative += Number(tracking.earnedValue || 0); const existing = acc.find( (item) => item.periodDate.getTime() === tracking.trackingDate.getTime(), ); if (existing) { existing.plannedCostCumulative = plannedCumulative; existing.actualCostCumulative = actualCumulative; existing.earnedValueCumulative = earnedCumulative; } else { acc.push({ budgetId, periodDate: tracking.trackingDate, plannedCostCumulative, actualCostCumulative, earnedValueCumulative, scheduleVariance: earnedCumulative - plannedCumulative, costVariance: earnedCumulative - actualCumulative, }); } return acc; }, []); // Guardar o actualizar curva S for (const data of sCurveData) { await this.sCurveRepo.upsert(data, ['budgetId', 'periodDate']); } } async getSCurve(budgetId: string): Promise { return await this.sCurveRepo.find({ where: { budgetId }, order: { periodDate: 'ASC' }, }); } async getEVMSummary(budgetId: string, date: Date = new Date()): Promise { const kpis = await this.calculateEVMKPIs(budgetId, date); return { budgetId, date, kpis: { plannedValue: kpis.planned_value, earnedValue: kpis.earned_value, actualCost: kpis.actual_cost, cpi: kpis.cpi, spi: kpis.spi, eac: kpis.eac, etc: kpis.etc, vac: kpis.vac, }, status: { costPerformance: kpis.cpi >= 1 ? 'on-budget' : 'over-budget', schedulePerformance: kpis.spi >= 1 ? 'on-schedule' : 'behind-schedule', }, }; } } ``` ### 6.2 Controller ```typescript // src/budgets/modules/cost-control/cost-control.controller.ts import { Controller, Get, Post, Body, Param, Query, UseGuards } from '@nestjs/common'; import { CostControlService } from './cost-control.service'; import { CreateCostTrackingDto } from './dto/create-cost-tracking.dto'; import { JwtAuthGuard } from '../../../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../../../auth/guards/roles.guard'; import { Roles } from '../../../auth/decorators/roles.decorator'; import { CurrentUser } from '../../../auth/decorators/current-user.decorator'; @Controller('api/cost-control') @UseGuards(JwtAuthGuard, RolesGuard) export class CostControlController { constructor(private costControlService: CostControlService) {} @Post('tracking') @Roles('admin', 'director', 'engineer') async createTracking(@Body() dto: CreateCostTrackingDto, @CurrentUser() user: any) { return await this.costControlService.createTracking(dto, user.sub); } @Get('evm/:budgetId') async getEVMSummary( @Param('budgetId') budgetId: string, @Query('date') date?: string, ) { const analysisDate = date ? new Date(date) : new Date(); return await this.costControlService.getEVMSummary(budgetId, analysisDate); } @Get('s-curve/:budgetId') async getSCurve(@Param('budgetId') budgetId: string) { return await this.costControlService.getSCurve(budgetId); } @Post('evm/:budgetId/calculate') @Roles('admin', 'director', 'engineer') async calculateEVMKPIs( @Param('budgetId') budgetId: string, @Query('date') date?: string, ) { const analysisDate = date ? new Date(date) : new Date(); return await this.costControlService.calculateEVMKPIs(budgetId, analysisDate); } } ``` --- ## 7. Módulo: ProfitabilityModule ### 7.1 Service ```typescript // src/budgets/modules/profitability/profitability.service.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ProfitabilityAnalysis } from '../../entities/profitability-analysis.entity'; @Injectable() export class ProfitabilityService { constructor( @InjectRepository(ProfitabilityAnalysis) private profitabilityRepo: Repository, ) {} async analyzeProject( budgetId: string, projectId: string, data: { contractedAmount: number; billedAmount: number; collectedAmount: number; budgetedCost: number; actualCost: number; committedCost: number; forecastCost: number; }, userId: string, ): Promise { const grossMargin = data.contractedAmount - data.forecastCost; const grossMarginPercentage = (grossMargin / data.contractedAmount) * 100; const netMargin = data.collectedAmount - data.actualCost; const netMarginPercentage = (netMargin / data.collectedAmount) * 100; const costOverrun = data.actualCost - data.budgetedCost; const costOverrunPercentage = (costOverrun / data.budgetedCost) * 100; const roi = ((data.contractedAmount - data.forecastCost) / data.forecastCost) * 100; const analysis = this.profitabilityRepo.create({ budgetId, projectId, analysisDate: new Date(), analysisType: 'snapshot', ...data, grossMargin, grossMarginPercentage, netMargin, netMarginPercentage, roiPercentage: roi, costOverrun, costOverrunPercentage, projectedRevenue: data.contractedAmount, projectedCost: data.forecastCost, projectedMargin: grossMargin, projectedMarginPercentage: grossMarginPercentage, createdBy: userId, }); return await this.profitabilityRepo.save(analysis); } async getLatestAnalysis(budgetId: string): Promise { return await this.profitabilityRepo.findOne({ where: { budgetId }, order: { analysisDate: 'DESC' }, }); } async getHistoricalAnalysis(budgetId: string): Promise { return await this.profitabilityRepo.find({ where: { budgetId }, order: { analysisDate: 'ASC' }, }); } } ``` --- ## 8. Cron Job - Análisis Diario de Costos ```typescript // src/budgets/jobs/cost-analysis.job.ts import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Budget, BudgetStatus } from '../entities/budget.entity'; import { CostControlService } from '../modules/cost-control/cost-control.service'; import { ProfitabilityService } from '../modules/profitability/profitability.service'; @Injectable() export class CostAnalysisJob { private readonly logger = new Logger(CostAnalysisJob.name); constructor( @InjectRepository(Budget) private budgetRepo: Repository, private costControlService: CostControlService, private profitabilityService: ProfitabilityService, ) {} @Cron(CronExpression.EVERY_DAY_AT_2AM) async handleDailyCostAnalysis() { this.logger.log('Iniciando análisis diario de costos...'); try { // Obtener presupuestos activos const activeBudgets = await this.budgetRepo.find({ where: { status: BudgetStatus.CONTRACTED, }, }); this.logger.log(`Procesando ${activeBudgets.length} presupuestos activos`); for (const budget of activeBudgets) { try { // Calcular KPIs de EVM await this.costControlService.calculateEVMKPIs(budget.id); // Actualizar curva S await this.costControlService.updateSCurve(budget.id, new Date()); this.logger.log(`Análisis completado para presupuesto ${budget.code}`); } catch (error) { this.logger.error( `Error analizando presupuesto ${budget.code}: ${error.message}`, ); } } this.logger.log('Análisis diario de costos completado'); } catch (error) { this.logger.error(`Error en análisis diario de costos: ${error.message}`); } } @Cron(CronExpression.EVERY_1ST_DAY_OF_MONTH_AT_MIDNIGHT) async handleMonthlyProfitabilityAnalysis() { this.logger.log('Iniciando análisis mensual de rentabilidad...'); try { const activeBudgets = await this.budgetRepo.find({ where: { status: BudgetStatus.CONTRACTED, }, }); for (const budget of activeBudgets) { try { // Aquí se obtendría data real de facturación y costos // Por ahora es un placeholder const analysisData = { contractedAmount: Number(budget.totalPriceWithVAT), billedAmount: 0, collectedAmount: 0, budgetedCost: Number(budget.totalCost), actualCost: 0, committedCost: 0, forecastCost: Number(budget.totalCost), }; await this.profitabilityService.analyzeProject( budget.id, budget.projectId, analysisData, 'system', ); this.logger.log(`Análisis de rentabilidad completado para ${budget.code}`); } catch (error) { this.logger.error( `Error en análisis de rentabilidad para ${budget.code}: ${error.message}`, ); } } this.logger.log('Análisis mensual de rentabilidad completado'); } catch (error) { this.logger.error(`Error en análisis mensual: ${error.message}`); } } } ``` --- ## 9. Testing ### 9.1 Unit Tests ```typescript // src/budgets/modules/concept-catalog/concept-catalog.service.spec.ts import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { ConceptCatalogService } from './concept-catalog.service'; import { ConceptCatalog, ConceptType } from '../../entities/concept-catalog.entity'; import { EventEmitter2 } from '@nestjs/event-emitter'; describe('ConceptCatalogService', () => { let service: ConceptCatalogService; let mockRepo: any; beforeEach(async () => { mockRepo = { create: jest.fn(), save: jest.fn(), findOne: jest.fn(), find: jest.fn(), createQueryBuilder: jest.fn(() => ({ where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), addOrderBy: jest.fn().mockReturnThis(), getOne: jest.fn(), getMany: jest.fn(), })), query: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ providers: [ ConceptCatalogService, { provide: getRepositoryToken(ConceptCatalog), useValue: mockRepo, }, { provide: EventEmitter2, useValue: { emit: jest.fn() }, }, ], }).compile(); service = module.get(ConceptCatalogService); }); describe('create', () => { it('debe crear un concepto simple', async () => { const dto = { name: 'Cemento CPC 30R', conceptType: ConceptType.MATERIAL, unit: 'ton', basePrice: 4300, }; mockRepo.findOne.mockResolvedValue(null); mockRepo.createQueryBuilder().getOne.mockResolvedValue(null); mockRepo.create.mockReturnValue({ ...dto, id: 'uuid-1' }); mockRepo.save.mockResolvedValue({ ...dto, id: 'uuid-1' }); const result = await service.create(dto, 'constructora-1', 'user-1'); expect(result.name).toBe(dto.name); expect(mockRepo.save).toHaveBeenCalled(); }); it('debe calcular precio de concepto compuesto', async () => { const dto = { name: 'Cimentación de concreto', conceptType: ConceptType.COMPOSITE, unit: 'm3', components: [ { conceptId: 'uuid-mat-1', quantity: 1, unit: 'm3' }, { conceptId: 'uuid-mat-2', quantity: 80, unit: 'kg' }, ], }; mockRepo.findOne.mockResolvedValue(null); mockRepo.createQueryBuilder().getOne.mockResolvedValue(null); mockRepo.create.mockReturnValue({ ...dto, id: 'uuid-2' }); mockRepo.save.mockResolvedValue({ ...dto, id: 'uuid-2' }); mockRepo.query.mockResolvedValue([{ unit_price: 2500 }]); const result = await service.create(dto, 'constructora-1', 'user-1'); expect(mockRepo.query).toHaveBeenCalledWith( 'SELECT budgets.calculate_composite_price($1) as unit_price', ['uuid-2'], ); }); }); describe('bulkUpdatePrices', () => { it('debe actualizar precios masivamente por porcentaje', async () => { const dto = { conceptIds: ['uuid-1', 'uuid-2'], adjustmentType: 'percentage' as const, adjustmentValue: 4.5, reason: 'Ajuste INPC', }; const concepts = [ { id: 'uuid-1', basePrice: 100, updatedBy: null }, { id: 'uuid-2', basePrice: 200, updatedBy: null }, ]; mockRepo.find.mockResolvedValue(concepts); mockRepo.save.mockResolvedValue(concepts); await service.bulkUpdatePrices(dto, 'constructora-1', 'user-1'); expect(concepts[0].basePrice).toBe(104.5); expect(concepts[1].basePrice).toBe(209); expect(mockRepo.save).toHaveBeenCalled(); }); }); }); ``` --- ## 10. Performance y Optimizaciones ### 10.1 Índices de Base de Datos - Full-text search en catálogo de conceptos - Índices compuestos para consultas multi-tenant - Índices en fechas para análisis histórico ### 10.2 Caching ```typescript // Redis cache para conceptos más usados @Injectable() export class ConceptCatalogService { @Cacheable({ ttl: 3600, // 1 hora key: (args) => `concept:${args[0]}`, }) async findOne(id: string, constructoraId: string): Promise { // ... } } ``` ### 10.3 Query Optimization - Uso de `select` específico para evitar sobre-fetch - Lazy loading de relaciones - Paginación en endpoints de listado --- ## 11. Seguridad ### 11.1 Validación de Datos - DTOs con class-validator para todas las entradas - Sanitización de inputs en queries SQL - Validación de permisos multi-tenant ### 11.2 Auditoría - Campos `created_by`, `updated_by` en todas las tablas - Historial de cambios de precios - Logs de cálculos de EVM --- ## 12. Deployment ### 12.1 Variables de Entorno ```bash # .env DATABASE_HOST=localhost DATABASE_PORT=5432 DATABASE_NAME=erp_construccion DATABASE_USER=postgres DATABASE_PASSWORD=*** REDIS_HOST=localhost REDIS_PORT=6379 CRON_COST_ANALYSIS_ENABLED=true ``` ### 12.2 Migraciones ```bash npm run typeorm migration:generate -- -n CreateBudgetsTables npm run typeorm migration:run ``` --- **Estado:** Ready for Implementation **Última actualización:** 2025-12-06