workspace-v1/projects/erp-construccion/docs/02-definicion-modulos/MAI-003-presupuestos-costos/especificaciones/ET-COST-001-backend.md
rckrdmrd 66161b1566 feat: Workspace-v1 complete migration with NEXUS v3.4
Sistema NEXUS v3.4 migrado con:

Estructura principal:
- core/orchestration: Sistema SIMCO + CAPVED (27 directivas, 28 perfiles)
- core/catalog: Catalogo de funcionalidades reutilizables
- shared/knowledge-base: Base de conocimiento compartida
- devtools/scripts: Herramientas de desarrollo
- control-plane/registries: Control de servicios y CI/CD
- orchestration/: Configuracion de orquestacion de agentes

Proyectos incluidos (11):
- gamilit (submodule -> GitHub)
- trading-platform (OrbiquanTIA)
- erp-suite con 5 verticales:
  - erp-core, construccion, vidrio-templado
  - mecanicas-diesel, retail, clinicas
- betting-analytics
- inmobiliaria-analytics
- platform_marketing_content
- pos-micro, erp-basico

Configuracion:
- .gitignore completo para Node.js/Python/Docker
- gamilit como submodule (git@github.com:rckrdmrd/gamilit-workspace.git)
- Sistema de puertos estandarizado (3005-3199)

Generated with NEXUS v3.4 Migration System
EPIC-010: Configuracion Git y Repositorios
2026-01-04 03:37:42 -06:00

61 KiB

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

{
  "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

-- 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

-- 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

// 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

// 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

// 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

// 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<ConceptCatalog>,
    private eventEmitter: EventEmitter2,
  ) {}

  async create(dto: CreateConceptDto, constructoraId: string, userId: string): Promise<ConceptCatalog> {
    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<ConceptCatalog[]> {
    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<ConceptCatalog> {
    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<ConceptCatalog> {
    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<void> {
    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<number> {
    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<string> {
    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

// 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

// 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

// 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<Budget>,
    @InjectRepository(BudgetItem)
    private budgetItemRepo: Repository<BudgetItem>,
    @InjectRepository(BudgetExplosion)
    private explosionRepo: Repository<BudgetExplosion>,
    private dataSource: DataSource,
  ) {}

  async create(dto: CreateBudgetDto, constructoraId: string, userId: string): Promise<Budget> {
    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<Budget[]> {
    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<Budget> {
    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<void> {
    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<void> {
    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<any[]> {
    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<string> {
    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

// 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<CostTracking>,
    @InjectRepository(SCurve)
    private sCurveRepo: Repository<SCurve>,
    private dataSource: DataSource,
  ) {}

  async createTracking(dto: CreateCostTrackingDto, userId: string): Promise<CostTracking> {
    // 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<any> {
    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<void> {
    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<SCurve[]> {
    return await this.sCurveRepo.find({
      where: { budgetId },
      order: { periodDate: 'ASC' },
    });
  }

  async getEVMSummary(budgetId: string, date: Date = new Date()): Promise<any> {
    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

// 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

// 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<ProfitabilityAnalysis>,
  ) {}

  async analyzeProject(
    budgetId: string,
    projectId: string,
    data: {
      contractedAmount: number;
      billedAmount: number;
      collectedAmount: number;
      budgetedCost: number;
      actualCost: number;
      committedCost: number;
      forecastCost: number;
    },
    userId: string,
  ): Promise<ProfitabilityAnalysis> {
    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<ProfitabilityAnalysis> {
    return await this.profitabilityRepo.findOne({
      where: { budgetId },
      order: { analysisDate: 'DESC' },
    });
  }

  async getHistoricalAnalysis(budgetId: string): Promise<ProfitabilityAnalysis[]> {
    return await this.profitabilityRepo.find({
      where: { budgetId },
      order: { analysisDate: 'ASC' },
    });
  }
}

8. Cron Job - Análisis Diario de Costos

// 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<Budget>,
    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

// 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>(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

// 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<ConceptCatalog> {
    // ...
  }
}

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

# .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

npm run typeorm migration:generate -- -n CreateBudgetsTables
npm run typeorm migration:run

Estado: Ready for Implementation Última actualización: 2025-12-06