2240 lines
61 KiB
Markdown
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
|