erp-construccion/docs/02-definicion-modulos/MAI-003-presupuestos-costos/especificaciones/ET-COST-001-backend.md

2240 lines
61 KiB
Markdown

# 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<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
```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<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
```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<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
```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<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
```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<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
```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>(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<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
```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