57 KiB
DDL-SPEC: Schema budgets
Identificacion
| Campo | Valor |
|---|---|
| Schema | budgets |
| Modulo | MAI-003 |
| Vertical | Construccion |
| Version | 1.0 |
| Estado | En Diseno |
| Autor | Requirements-Analyst |
| Fecha | 2025-12-06 |
Descripcion General
El schema budgets implementa el sistema completo de presupuestos y control de costos para construccion: catalogo de conceptos con APU (Analisis de Precios Unitarios), presupuestos multinivel (obra, etapa, prototipo), control de costos reales vs presupuestados, analisis de desviaciones, y calculo de rentabilidad. Incluye explosion de insumos, versionado de presupuestos, y curva S para control financiero.
Alcance
- Catalogo maestro de conceptos reutilizables (Material, Mano de Obra, Maquinaria, Compuestos)
- APU detallados con explosion de insumos y cargos (indirectos, financiamiento, utilidad)
- Presupuestos jerarquicos: Obra > Etapa > Partidas
- Versionado completo con baseline y control de cambios
- Registro de costos reales desde Compras, Nomina y Subcontratos
- Calculo de desviaciones (precio, cantidad, mixta)
- Analisis de rentabilidad con proyecciones (EAC, VAC, CPI, SPI)
- Vistas materializadas para explosion de insumos
RF Cubiertos
| RF | Titulo | Tablas |
|---|---|---|
| RF-COST-001 | Catalogo de Conceptos | concept_catalog, concept_price_history |
| RF-COST-002 | Presupuestos Maestros | budgets, budget_items, budget_versions |
| RF-COST-003 | Control de Costos | actual_costs, cost_variances |
| RF-COST-004 | Analisis de Rentabilidad | profitability_analysis |
Diagrama ER
erDiagram
concept_catalog {
uuid id PK
uuid tenant_id FK
varchar code UK
varchar name
varchar concept_type
varchar category
varchar unit
decimal base_price
jsonb components
jsonb labor_crew
decimal indirect_percentage
decimal financing_percentage
decimal profit_percentage
decimal direct_cost
decimal unit_price
int version
varchar status
}
concept_price_history {
uuid id PK
uuid concept_id FK
decimal price
date valid_from
date valid_until
decimal variation_percentage
varchar reason
}
budgets {
uuid id PK
uuid tenant_id FK
uuid project_id FK
varchar budget_number
varchar budget_type
varchar name
date budget_date
decimal total_amount
decimal total_cost
decimal margin_percentage
varchar status
uuid baseline_version_id FK
}
budget_versions {
uuid id PK
uuid budget_id FK
int version_number
varchar version_type
varchar description
date effective_date
decimal total_amount
jsonb changes_summary
boolean is_current
}
budget_items {
uuid id PK
uuid budget_id FK
uuid budget_version_id FK
uuid parent_item_id FK
uuid concept_id FK
varchar code
varchar name
varchar item_type
int level
ltree path
decimal quantity
varchar unit
decimal unit_price
decimal total_price
decimal total_cost
int sort_order
}
actual_costs {
uuid id PK
uuid tenant_id FK
uuid project_id FK
uuid budget_item_id FK
uuid concept_id FK
varchar source_module
uuid source_document_id
date cost_date
decimal quantity
decimal unit_price
decimal total_cost
varchar cost_type
}
cost_variances {
uuid id PK
uuid tenant_id FK
uuid project_id FK
uuid budget_item_id FK
date analysis_date
decimal budgeted_quantity
decimal actual_quantity
decimal budgeted_unit_price
decimal actual_unit_price
decimal budgeted_total
decimal actual_total
decimal price_variance
decimal quantity_variance
decimal mixed_variance
decimal total_variance
decimal variance_percentage
}
profitability_analysis {
uuid id PK
uuid tenant_id FK
uuid project_id FK
date analysis_date
decimal budget_at_completion
decimal actual_cost_to_date
decimal earned_value
decimal estimate_at_completion
decimal estimate_to_complete
decimal variance_at_completion
decimal cost_performance_index
decimal schedule_performance_index
decimal percent_complete
decimal projected_margin
}
concept_catalog ||--o{ concept_price_history : "historial"
concept_catalog ||--o{ budget_items : "usado_en"
concept_catalog ||--o{ actual_costs : "costo_real"
budgets ||--o{ budget_versions : "versiones"
budgets ||--o{ budget_items : "partidas"
budgets ||--o{ actual_costs : "costos"
budgets ||--o{ profitability_analysis : "analisis"
budget_versions ||--o{ budget_items : "partidas_version"
budget_items ||--o{ budget_items : "jerarquia"
budget_items ||--o{ actual_costs : "costo_real"
budget_items ||--o{ cost_variances : "desviaciones"
ENUMs y Tipos
concept_type_enum
Tipos de conceptos en catalogo.
CREATE TYPE budgets.concept_type_enum AS ENUM (
'material', -- Materiales (concreto, acero, block)
'labor', -- Mano de obra (albanil, fierrero, carpintero)
'equipment', -- Maquinaria y equipo (vibrador, revolvedora)
'composite' -- Conceptos compuestos (APU completo)
);
concept_status_enum
Estados del concepto.
CREATE TYPE budgets.concept_status_enum AS ENUM (
'active', -- Activo y disponible
'deprecated', -- Obsoleto (mantener para historico)
'draft' -- En construccion
);
budget_type_enum
Tipos de presupuestos.
CREATE TYPE budgets.budget_type_enum AS ENUM (
'master', -- Presupuesto maestro de obra
'stage', -- Presupuesto por etapa
'prototype', -- Presupuesto por prototipo/modelo
'change_order' -- Orden de cambio
);
budget_status_enum
Estados del presupuesto.
CREATE TYPE budgets.budget_status_enum AS ENUM (
'draft', -- En elaboracion
'pending', -- Pendiente aprobacion
'approved', -- Aprobado (baseline)
'active', -- En ejecucion
'completed', -- Completado
'cancelled' -- Cancelado
);
version_type_enum
Tipos de versiones.
CREATE TYPE budgets.version_type_enum AS ENUM (
'baseline', -- Version base inicial
'revision', -- Revision sin cambio de alcance
'change_order', -- Orden de cambio (cambio alcance)
'adjustment' -- Ajuste de precios
);
cost_type_enum
Tipos de costos reales.
CREATE TYPE budgets.cost_type_enum AS ENUM (
'material', -- Desde modulo Compras
'labor', -- Desde modulo Nomina
'equipment', -- Renta de maquinaria
'subcontract', -- Desde modulo Subcontratos
'other' -- Otros costos
);
item_type_enum
Tipos de partidas en presupuesto.
CREATE TYPE budgets.item_type_enum AS ENUM (
'chapter', -- Capitulo (agrupador)
'group', -- Grupo (sub-agrupador)
'item', -- Partida ejecutable
'subtotal' -- Subtotal calculado
);
Tablas
1. concept_catalog
Catalogo maestro de conceptos reutilizables con APU.
| Columna | Tipo | Nullable | Default | Descripcion |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() | PK |
tenant_id |
UUID | NOT NULL | - | FK a tenants |
code |
VARCHAR(20) | NOT NULL | - | Codigo unico concepto |
name |
VARCHAR(255) | NOT NULL | - | Nombre del concepto |
description |
TEXT | NULL | - | Descripcion tecnica |
concept_type |
concept_type_enum | NOT NULL | - | Tipo de concepto |
category |
VARCHAR(100) | NULL | - | Division CMIC |
subcategory |
VARCHAR(100) | NULL | - | Grupo CMIC |
unit |
VARCHAR(20) | NOT NULL | - | Unidad medida |
base_price |
DECIMAL(12,2) | NULL | - | Precio base (simples) |
includes_vat |
BOOLEAN | NOT NULL | false | Precio incluye IVA |
currency_code |
VARCHAR(3) | NOT NULL | 'MXN' | Codigo moneda |
waste_factor |
DECIMAL(5,3) | NOT NULL | 1.000 | Factor desperdicio |
components |
JSONB | NULL | - | Componentes (compuestos) |
labor_crew |
JSONB | NULL | - | Cuadrilla tipo |
indirect_percentage |
DECIMAL(5,2) | NOT NULL | 12.00 | % Indirectos |
financing_percentage |
DECIMAL(5,2) | NOT NULL | 3.00 | % Financiamiento |
profit_percentage |
DECIMAL(5,2) | NOT NULL | 10.00 | % Utilidad |
additional_charges |
DECIMAL(5,2) | NOT NULL | 2.00 | % Cargos adicionales |
direct_cost |
DECIMAL(12,2) | NULL | - | Costo directo calculado |
unit_price |
DECIMAL(12,2) | NULL | - | PU sin IVA |
unit_price_with_vat |
DECIMAL(12,2) | NULL | - | PU con IVA |
region_id |
UUID | NULL | - | Region de precios |
preferred_supplier_id |
UUID | NULL | - | Proveedor preferido |
technical_specs |
TEXT | NULL | - | Especificaciones tecnicas |
performance |
VARCHAR(255) | NULL | - | Rendimiento |
version |
INTEGER | NOT NULL | 1 | Version del concepto |
status |
concept_status_enum | NOT NULL | 'active' | Estado |
created_by |
UUID | NOT NULL | - | Usuario creador |
created_at |
TIMESTAMPTZ | NOT NULL | NOW() | Fecha creacion |
updated_at |
TIMESTAMPTZ | NOT NULL | NOW() | Fecha actualizacion |
updated_by |
UUID | NULL | - | Usuario modificador |
CREATE TABLE budgets.concept_catalog (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
code VARCHAR(20) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
concept_type budgets.concept_type_enum NOT NULL,
category VARCHAR(100),
subcategory VARCHAR(100),
unit VARCHAR(20) NOT NULL,
base_price DECIMAL(12,2),
includes_vat BOOLEAN NOT NULL DEFAULT false,
currency_code VARCHAR(3) NOT NULL DEFAULT 'MXN',
waste_factor DECIMAL(5,3) NOT NULL DEFAULT 1.000,
components JSONB,
labor_crew JSONB,
indirect_percentage DECIMAL(5,2) NOT NULL DEFAULT 12.00,
financing_percentage DECIMAL(5,2) NOT NULL DEFAULT 3.00,
profit_percentage DECIMAL(5,2) NOT NULL DEFAULT 10.00,
additional_charges DECIMAL(5,2) NOT NULL DEFAULT 2.00,
direct_cost DECIMAL(12,2),
unit_price DECIMAL(12,2),
unit_price_with_vat DECIMAL(12,2),
region_id UUID,
preferred_supplier_id UUID,
technical_specs TEXT,
performance VARCHAR(255),
version INTEGER NOT NULL DEFAULT 1,
status budgets.concept_status_enum NOT NULL DEFAULT 'active',
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID,
CONSTRAINT uk_concept_catalog_tenant_code UNIQUE (tenant_id, code),
CONSTRAINT chk_concept_base_price CHECK (base_price IS NULL OR base_price >= 0),
CONSTRAINT chk_concept_waste_factor CHECK (waste_factor >= 1.000),
CONSTRAINT chk_concept_currency CHECK (currency_code IN ('MXN', 'USD'))
);
CREATE INDEX idx_concept_catalog_tenant ON budgets.concept_catalog(tenant_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);
-- Indice full-text para busqueda
CREATE INDEX idx_concept_catalog_search ON budgets.concept_catalog
USING GIN (to_tsvector('spanish', name || ' ' || COALESCE(description, '')));
-- RLS
ALTER TABLE budgets.concept_catalog ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON budgets.concept_catalog
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
Ejemplo de components JSONB:
[
{
"conceptId": "uuid-concreto",
"quantity": 1.05,
"unit": "m³",
"name": "Concreto premezclado f'c=200",
"unitPrice": 1850.00
},
{
"conceptId": "uuid-acero",
"quantity": 25,
"unit": "kg",
"name": "Acero de refuerzo 3/8",
"unitPrice": 18.00
}
]
Ejemplo de labor_crew JSONB:
[
{
"category": "oficial",
"quantity": 0.8,
"dailyWage": 450.00,
"fsr": 1.65
},
{
"category": "ayudante",
"quantity": 1.6,
"dailyWage": 280.00,
"fsr": 1.65
}
]
2. concept_price_history
Historial de precios por concepto.
| Columna | Tipo | Nullable | Default | Descripcion |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() | PK |
concept_id |
UUID | NOT NULL | - | FK a concept_catalog |
price |
DECIMAL(12,2) | NOT NULL | - | Precio historico |
valid_from |
DATE | NOT NULL | - | Fecha inicio vigencia |
valid_until |
DATE | NULL | - | Fecha fin vigencia |
variation_percentage |
DECIMAL(6,2) | NULL | - | % Variacion vs anterior |
reason |
VARCHAR(255) | NULL | - | Razon del cambio |
created_by |
UUID | NOT NULL | - | Usuario creador |
created_at |
TIMESTAMPTZ | NOT NULL | NOW() | Fecha creacion |
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 TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_price_history_dates CHECK (valid_until IS NULL OR valid_until >= valid_from),
CONSTRAINT chk_price_history_price CHECK (price >= 0)
);
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);
CREATE INDEX idx_price_history_lookup ON budgets.concept_price_history(
concept_id, valid_from DESC
) WHERE valid_until IS NULL;
3. budgets
Presupuestos maestros (obra, etapa, prototipo).
| Columna | Tipo | Nullable | Default | Descripcion |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() | PK |
tenant_id |
UUID | NOT NULL | - | FK a tenants |
project_id |
UUID | NOT NULL | - | FK a projects |
budget_number |
VARCHAR(20) | NOT NULL | - | Numero secuencial |
budget_type |
budget_type_enum | NOT NULL | - | Tipo presupuesto |
name |
VARCHAR(255) | NOT NULL | - | Nombre presupuesto |
description |
TEXT | NULL | - | Descripcion |
budget_date |
DATE | NOT NULL | - | Fecha presupuesto |
total_amount |
DECIMAL(15,2) | NOT NULL | 0 | Monto total (venta) |
total_cost |
DECIMAL(15,2) | NOT NULL | 0 | Costo total |
margin_amount |
DECIMAL(15,2) | NOT NULL | 0 | Margen ($) |
margin_percentage |
DECIMAL(5,2) | NOT NULL | 0 | Margen (%) |
currency_code |
VARCHAR(3) | NOT NULL | 'MXN' | Codigo moneda |
exchange_rate |
DECIMAL(10,4) | NOT NULL | 1.0000 | Tipo cambio |
status |
budget_status_enum | NOT NULL | 'draft' | Estado |
baseline_version_id |
UUID | NULL | - | Version baseline |
approved_by |
UUID | NULL | - | Usuario aprobador |
approved_at |
TIMESTAMPTZ | NULL | - | Fecha aprobacion |
notes |
TEXT | NULL | - | Notas |
created_by |
UUID | NOT NULL | - | Usuario creador |
created_at |
TIMESTAMPTZ | NOT NULL | NOW() | Fecha creacion |
updated_at |
TIMESTAMPTZ | NOT NULL | NOW() | Fecha actualizacion |
CREATE TABLE budgets.budgets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
project_id UUID NOT NULL,
budget_number VARCHAR(20) NOT NULL,
budget_type budgets.budget_type_enum NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
budget_date DATE NOT NULL,
total_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
total_cost DECIMAL(15,2) NOT NULL DEFAULT 0,
margin_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
margin_percentage DECIMAL(5,2) NOT NULL DEFAULT 0,
currency_code VARCHAR(3) NOT NULL DEFAULT 'MXN',
exchange_rate DECIMAL(10,4) NOT NULL DEFAULT 1.0000,
status budgets.budget_status_enum NOT NULL DEFAULT 'draft',
baseline_version_id UUID,
approved_by UUID,
approved_at TIMESTAMPTZ,
notes TEXT,
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uk_budgets_tenant_number UNIQUE (tenant_id, budget_number),
CONSTRAINT chk_budgets_amounts CHECK (
total_amount >= 0 AND total_cost >= 0 AND margin_amount >= 0
),
CONSTRAINT chk_budgets_currency CHECK (currency_code IN ('MXN', 'USD'))
);
CREATE INDEX idx_budgets_tenant ON budgets.budgets(tenant_id);
CREATE INDEX idx_budgets_project ON budgets.budgets(project_id);
CREATE INDEX idx_budgets_type ON budgets.budgets(budget_type);
CREATE INDEX idx_budgets_status ON budgets.budgets(status);
CREATE INDEX idx_budgets_date ON budgets.budgets(budget_date DESC);
-- RLS
ALTER TABLE budgets.budgets ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON budgets.budgets
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
4. budget_versions
Versiones del presupuesto con control de cambios.
| Columna | Tipo | Nullable | Default | Descripcion |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() | PK |
budget_id |
UUID | NOT NULL | - | FK a budgets |
version_number |
INTEGER | NOT NULL | - | Numero version |
version_type |
version_type_enum | NOT NULL | - | Tipo version |
description |
TEXT | NOT NULL | - | Descripcion cambios |
effective_date |
DATE | NOT NULL | - | Fecha efectiva |
total_amount |
DECIMAL(15,2) | NOT NULL | - | Monto total version |
total_cost |
DECIMAL(15,2) | NOT NULL | - | Costo total version |
changes_summary |
JSONB | NULL | - | Resumen de cambios |
is_current |
BOOLEAN | NOT NULL | false | Version actual |
created_by |
UUID | NOT NULL | - | Usuario creador |
created_at |
TIMESTAMPTZ | NOT NULL | NOW() | Fecha creacion |
CREATE TABLE budgets.budget_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
budget_id UUID NOT NULL REFERENCES budgets.budgets(id) ON DELETE CASCADE,
version_number INTEGER NOT NULL,
version_type budgets.version_type_enum NOT NULL,
description TEXT NOT NULL,
effective_date DATE NOT NULL,
total_amount DECIMAL(15,2) NOT NULL,
total_cost DECIMAL(15,2) NOT NULL,
changes_summary JSONB,
is_current BOOLEAN NOT NULL DEFAULT false,
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uk_budget_versions_number UNIQUE (budget_id, version_number),
CONSTRAINT chk_budget_versions_amounts CHECK (total_amount >= 0 AND total_cost >= 0)
);
CREATE INDEX idx_budget_versions_budget ON budgets.budget_versions(budget_id);
CREATE INDEX idx_budget_versions_number ON budgets.budget_versions(version_number DESC);
CREATE INDEX idx_budget_versions_current ON budgets.budget_versions(budget_id, is_current)
WHERE is_current = true;
-- Solo una version actual por presupuesto
CREATE UNIQUE INDEX uk_budget_versions_current ON budgets.budget_versions(budget_id)
WHERE is_current = true;
Ejemplo de changes_summary JSONB:
{
"itemsAdded": 5,
"itemsModified": 12,
"itemsDeleted": 2,
"amountIncrease": 125000.00,
"reason": "Ampliacion alcance: Muro perimetral adicional"
}
5. budget_items
Partidas del presupuesto (jerarquicas).
| Columna | Tipo | Nullable | Default | Descripcion |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() | PK |
budget_id |
UUID | NOT NULL | - | FK a budgets |
budget_version_id |
UUID | NOT NULL | - | FK a budget_versions |
parent_item_id |
UUID | NULL | - | Partida padre |
concept_id |
UUID | NULL | - | FK a concept_catalog |
code |
VARCHAR(30) | NOT NULL | - | Codigo partida |
name |
VARCHAR(255) | NOT NULL | - | Nombre partida |
description |
TEXT | NULL | - | Descripcion |
item_type |
item_type_enum | NOT NULL | - | Tipo partida |
level |
INTEGER | NOT NULL | 1 | Nivel jerarquico |
path |
LTREE | NULL | - | Path materializado |
quantity |
DECIMAL(12,4) | NOT NULL | 0 | Cantidad |
unit |
VARCHAR(20) | NULL | - | Unidad medida |
unit_price |
DECIMAL(12,2) | NOT NULL | 0 | PU sin IVA |
total_price |
DECIMAL(15,2) | NOT NULL | 0 | Total sin IVA |
unit_cost |
DECIMAL(12,2) | NOT NULL | 0 | Costo unitario |
total_cost |
DECIMAL(15,2) | NOT NULL | 0 | Costo total |
margin_percentage |
DECIMAL(5,2) | NOT NULL | 0 | Margen (%) |
formula |
TEXT | NULL | - | Formula calculo cantidad |
formula_params |
JSONB | NULL | - | Parametros formula |
sort_order |
INTEGER | NOT NULL | 0 | Orden visualizacion |
notes |
TEXT | NULL | - | Notas |
created_at |
TIMESTAMPTZ | NOT NULL | NOW() | Fecha creacion |
updated_at |
TIMESTAMPTZ | NOT NULL | NOW() | Fecha actualizacion |
CREATE EXTENSION IF NOT EXISTS ltree;
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,
budget_version_id UUID NOT NULL REFERENCES budgets.budget_versions(id) ON DELETE CASCADE,
parent_item_id UUID REFERENCES budgets.budget_items(id),
concept_id UUID REFERENCES budgets.concept_catalog(id),
code VARCHAR(30) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
item_type budgets.item_type_enum NOT NULL,
level INTEGER NOT NULL DEFAULT 1,
path LTREE,
quantity DECIMAL(12,4) NOT NULL DEFAULT 0,
unit VARCHAR(20),
unit_price DECIMAL(12,2) NOT NULL DEFAULT 0,
total_price DECIMAL(15,2) NOT NULL DEFAULT 0,
unit_cost DECIMAL(12,2) NOT NULL DEFAULT 0,
total_cost DECIMAL(15,2) NOT NULL DEFAULT 0,
margin_percentage DECIMAL(5,2) NOT NULL DEFAULT 0,
formula TEXT,
formula_params JSONB,
sort_order INTEGER NOT NULL DEFAULT 0,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uk_budget_items_version_code UNIQUE (budget_version_id, code),
CONSTRAINT chk_budget_items_level CHECK (level >= 1 AND level <= 10),
CONSTRAINT chk_budget_items_amounts CHECK (
quantity >= 0 AND unit_price >= 0 AND total_price >= 0 AND
unit_cost >= 0 AND total_cost >= 0
),
CONSTRAINT chk_budget_items_item_has_concept CHECK (
item_type != 'item' OR concept_id IS NOT NULL
)
);
CREATE INDEX idx_budget_items_budget ON budgets.budget_items(budget_id);
CREATE INDEX idx_budget_items_version ON budgets.budget_items(budget_version_id);
CREATE INDEX idx_budget_items_parent ON budgets.budget_items(parent_item_id);
CREATE INDEX idx_budget_items_concept ON budgets.budget_items(concept_id);
CREATE INDEX idx_budget_items_path ON budgets.budget_items USING GIST (path);
CREATE INDEX idx_budget_items_type ON budgets.budget_items(item_type);
CREATE INDEX idx_budget_items_sort ON budgets.budget_items(budget_version_id, sort_order);
Ejemplo de formula_params JSONB:
{
"buildingPerimeter": 30.0,
"desplantDepth": 0.80,
"widthFactor": 0.60,
"wasteFactor": 1.15,
"calculatedQuantity": 16.56
}
6. actual_costs
Costos reales registrados desde otros modulos.
| Columna | Tipo | Nullable | Default | Descripcion |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() | PK |
tenant_id |
UUID | NOT NULL | - | FK a tenants |
project_id |
UUID | NOT NULL | - | FK a projects |
budget_item_id |
UUID | NULL | - | FK a budget_items |
concept_id |
UUID | NULL | - | FK a concept_catalog |
source_module |
VARCHAR(50) | NOT NULL | - | Modulo origen |
source_document_id |
UUID | NOT NULL | - | Documento origen |
source_document_number |
VARCHAR(50) | NULL | - | Folio documento |
cost_date |
DATE | NOT NULL | - | Fecha del costo |
cost_type |
cost_type_enum | NOT NULL | - | Tipo de costo |
quantity |
DECIMAL(12,4) | NOT NULL | - | Cantidad |
unit |
VARCHAR(20) | NOT NULL | - | Unidad medida |
unit_price |
DECIMAL(12,2) | NOT NULL | - | Precio unitario |
total_cost |
DECIMAL(15,2) | NOT NULL | - | Costo total |
currency_code |
VARCHAR(3) | NOT NULL | 'MXN' | Codigo moneda |
exchange_rate |
DECIMAL(10,4) | NOT NULL | 1.0000 | Tipo cambio |
total_cost_base |
DECIMAL(15,2) | NOT NULL | - | Costo en moneda base |
notes |
TEXT | NULL | - | Notas |
created_at |
TIMESTAMPTZ | NOT NULL | NOW() | Fecha registro |
CREATE TABLE budgets.actual_costs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
project_id UUID NOT NULL,
budget_item_id UUID REFERENCES budgets.budget_items(id),
concept_id UUID REFERENCES budgets.concept_catalog(id),
source_module VARCHAR(50) NOT NULL,
source_document_id UUID NOT NULL,
source_document_number VARCHAR(50),
cost_date DATE NOT NULL,
cost_type budgets.cost_type_enum NOT NULL,
quantity DECIMAL(12,4) NOT NULL,
unit VARCHAR(20) NOT NULL,
unit_price DECIMAL(12,2) NOT NULL,
total_cost DECIMAL(15,2) NOT NULL,
currency_code VARCHAR(3) NOT NULL DEFAULT 'MXN',
exchange_rate DECIMAL(10,4) NOT NULL DEFAULT 1.0000,
total_cost_base DECIMAL(15,2) NOT NULL,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_actual_costs_amounts CHECK (
quantity >= 0 AND unit_price >= 0 AND total_cost >= 0 AND total_cost_base >= 0
),
CONSTRAINT chk_actual_costs_currency CHECK (currency_code IN ('MXN', 'USD')),
CONSTRAINT chk_actual_costs_source CHECK (source_module IN (
'purchases', 'payroll', 'subcontracts', 'manual'
))
);
CREATE INDEX idx_actual_costs_tenant ON budgets.actual_costs(tenant_id);
CREATE INDEX idx_actual_costs_project ON budgets.actual_costs(project_id);
CREATE INDEX idx_actual_costs_budget_item ON budgets.actual_costs(budget_item_id);
CREATE INDEX idx_actual_costs_concept ON budgets.actual_costs(concept_id);
CREATE INDEX idx_actual_costs_date ON budgets.actual_costs(cost_date);
CREATE INDEX idx_actual_costs_source ON budgets.actual_costs(source_module, source_document_id);
CREATE INDEX idx_actual_costs_type ON budgets.actual_costs(cost_type);
-- RLS
ALTER TABLE budgets.actual_costs ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON budgets.actual_costs
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
7. cost_variances
Analisis de desviaciones presupuestales.
| Columna | Tipo | Nullable | Default | Descripcion |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() | PK |
tenant_id |
UUID | NOT NULL | - | FK a tenants |
project_id |
UUID | NOT NULL | - | FK a projects |
budget_item_id |
UUID | NOT NULL | - | FK a budget_items |
analysis_date |
DATE | NOT NULL | - | Fecha analisis |
budgeted_quantity |
DECIMAL(12,4) | NOT NULL | - | Cantidad presupuestada |
actual_quantity |
DECIMAL(12,4) | NOT NULL | - | Cantidad real |
budgeted_unit_price |
DECIMAL(12,2) | NOT NULL | - | PU presupuestado |
actual_unit_price |
DECIMAL(12,2) | NOT NULL | - | PU real |
budgeted_total |
DECIMAL(15,2) | NOT NULL | - | Total presupuestado |
actual_total |
DECIMAL(15,2) | NOT NULL | - | Total real |
price_variance |
DECIMAL(15,2) | NOT NULL | - | Desviacion precio |
quantity_variance |
DECIMAL(15,2) | NOT NULL | - | Desviacion cantidad |
mixed_variance |
DECIMAL(15,2) | NOT NULL | - | Desviacion mixta |
total_variance |
DECIMAL(15,2) | NOT NULL | - | Desviacion total |
variance_percentage |
DECIMAL(6,2) | NOT NULL | - | % Desviacion |
variance_category |
VARCHAR(20) | NOT NULL | - | Categoria desviacion |
notes |
TEXT | NULL | - | Notas analisis |
created_at |
TIMESTAMPTZ | NOT NULL | NOW() | Fecha calculo |
CREATE TABLE budgets.cost_variances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
project_id UUID NOT NULL,
budget_item_id UUID NOT NULL REFERENCES budgets.budget_items(id) ON DELETE CASCADE,
analysis_date DATE NOT NULL,
budgeted_quantity DECIMAL(12,4) NOT NULL,
actual_quantity DECIMAL(12,4) NOT NULL,
budgeted_unit_price DECIMAL(12,2) NOT NULL,
actual_unit_price DECIMAL(12,2) NOT NULL,
budgeted_total DECIMAL(15,2) NOT NULL,
actual_total DECIMAL(15,2) NOT NULL,
price_variance DECIMAL(15,2) NOT NULL,
quantity_variance DECIMAL(15,2) NOT NULL,
mixed_variance DECIMAL(15,2) NOT NULL,
total_variance DECIMAL(15,2) NOT NULL,
variance_percentage DECIMAL(6,2) NOT NULL,
variance_category VARCHAR(20) NOT NULL,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uk_cost_variances_item_date UNIQUE (budget_item_id, analysis_date),
CONSTRAINT chk_cost_variances_amounts CHECK (
budgeted_quantity >= 0 AND actual_quantity >= 0 AND
budgeted_unit_price >= 0 AND actual_unit_price >= 0 AND
budgeted_total >= 0 AND actual_total >= 0
),
CONSTRAINT chk_cost_variances_category CHECK (variance_category IN (
'within_tolerance', -- Dentro tolerancia
'minor', -- Desviacion menor (< 5%)
'moderate', -- Desviacion moderada (5-15%)
'major', -- Desviacion mayor (> 15%)
'critical' -- Desviacion critica (> 25%)
))
);
CREATE INDEX idx_cost_variances_tenant ON budgets.cost_variances(tenant_id);
CREATE INDEX idx_cost_variances_project ON budgets.cost_variances(project_id);
CREATE INDEX idx_cost_variances_budget_item ON budgets.cost_variances(budget_item_id);
CREATE INDEX idx_cost_variances_date ON budgets.cost_variances(analysis_date DESC);
CREATE INDEX idx_cost_variances_category ON budgets.cost_variances(variance_category);
-- RLS
ALTER TABLE budgets.cost_variances ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON budgets.cost_variances
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
8. profitability_analysis
Analisis de rentabilidad y proyecciones (EVM - Earned Value Management).
| Columna | Tipo | Nullable | Default | Descripcion |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() | PK |
tenant_id |
UUID | NOT NULL | - | FK a tenants |
project_id |
UUID | NOT NULL | - | FK a projects |
budget_id |
UUID | NOT NULL | - | FK a budgets |
analysis_date |
DATE | NOT NULL | - | Fecha analisis |
budget_at_completion |
DECIMAL(15,2) | NOT NULL | - | BAC - Presupuesto total |
actual_cost_to_date |
DECIMAL(15,2) | NOT NULL | - | AC - Costo real |
earned_value |
DECIMAL(15,2) | NOT NULL | - | EV - Valor ganado |
planned_value |
DECIMAL(15,2) | NOT NULL | - | PV - Valor planeado |
estimate_at_completion |
DECIMAL(15,2) | NOT NULL | - | EAC - Estimado al termino |
estimate_to_complete |
DECIMAL(15,2) | NOT NULL | - | ETC - Estimado para completar |
variance_at_completion |
DECIMAL(15,2) | NOT NULL | - | VAC - Variacion al termino |
cost_performance_index |
DECIMAL(6,4) | NOT NULL | - | CPI - Indice desempeno costo |
schedule_performance_index |
DECIMAL(6,4) | NOT NULL | - | SPI - Indice desempeno tiempo |
to_complete_performance_index |
DECIMAL(6,4) | NOT NULL | - | TCPI - Indice para completar |
percent_complete_planned |
DECIMAL(5,2) | NOT NULL | - | % Avance planeado |
percent_complete_actual |
DECIMAL(5,2) | NOT NULL | - | % Avance real |
projected_margin_amount |
DECIMAL(15,2) | NOT NULL | - | Margen proyectado ($) |
projected_margin_percentage |
DECIMAL(5,2) | NOT NULL | - | Margen proyectado (%) |
health_status |
VARCHAR(20) | NOT NULL | - | Estado salud proyecto |
notes |
TEXT | NULL | - | Notas analisis |
created_at |
TIMESTAMPTZ | NOT NULL | NOW() | Fecha calculo |
CREATE TABLE budgets.profitability_analysis (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
project_id UUID NOT NULL,
budget_id UUID NOT NULL REFERENCES budgets.budgets(id) ON DELETE CASCADE,
analysis_date DATE NOT NULL,
budget_at_completion DECIMAL(15,2) NOT NULL,
actual_cost_to_date DECIMAL(15,2) NOT NULL,
earned_value DECIMAL(15,2) NOT NULL,
planned_value DECIMAL(15,2) NOT NULL,
estimate_at_completion DECIMAL(15,2) NOT NULL,
estimate_to_complete DECIMAL(15,2) NOT NULL,
variance_at_completion DECIMAL(15,2) NOT NULL,
cost_performance_index DECIMAL(6,4) NOT NULL,
schedule_performance_index DECIMAL(6,4) NOT NULL,
to_complete_performance_index DECIMAL(6,4) NOT NULL,
percent_complete_planned DECIMAL(5,2) NOT NULL,
percent_complete_actual DECIMAL(5,2) NOT NULL,
projected_margin_amount DECIMAL(15,2) NOT NULL,
projected_margin_percentage DECIMAL(5,2) NOT NULL,
health_status VARCHAR(20) NOT NULL,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uk_profitability_analysis_project_date UNIQUE (project_id, analysis_date),
CONSTRAINT chk_profitability_amounts CHECK (
budget_at_completion >= 0 AND actual_cost_to_date >= 0 AND
earned_value >= 0 AND planned_value >= 0 AND
estimate_at_completion >= 0 AND estimate_to_complete >= 0
),
CONSTRAINT chk_profitability_percentages CHECK (
percent_complete_planned >= 0 AND percent_complete_planned <= 100 AND
percent_complete_actual >= 0 AND percent_complete_actual <= 100
),
CONSTRAINT chk_profitability_health CHECK (health_status IN (
'excellent', -- CPI > 1.1, SPI > 1.05
'good', -- CPI 1.0-1.1, SPI 0.95-1.05
'warning', -- CPI 0.9-1.0, SPI 0.85-0.95
'critical', -- CPI < 0.9, SPI < 0.85
'completed' -- Proyecto terminado
))
);
CREATE INDEX idx_profitability_analysis_tenant ON budgets.profitability_analysis(tenant_id);
CREATE INDEX idx_profitability_analysis_project ON budgets.profitability_analysis(project_id);
CREATE INDEX idx_profitability_analysis_budget ON budgets.profitability_analysis(budget_id);
CREATE INDEX idx_profitability_analysis_date ON budgets.profitability_analysis(analysis_date DESC);
CREATE INDEX idx_profitability_analysis_health ON budgets.profitability_analysis(health_status);
-- RLS
ALTER TABLE budgets.profitability_analysis ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON budgets.profitability_analysis
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
Vistas Materializadas
mv_concept_explosion
Explosion de insumos por concepto compuesto (BOM - Bill of Materials).
CREATE MATERIALIZED VIEW budgets.mv_concept_explosion AS
WITH RECURSIVE explosion AS (
-- Nivel 0: Conceptos raiz
SELECT
cc.id as root_concept_id,
cc.id as concept_id,
cc.code,
cc.name,
cc.concept_type,
cc.unit,
cc.unit_price,
1.0::DECIMAL as quantity_multiplier,
0 as level,
ARRAY[cc.id] as path
FROM budgets.concept_catalog cc
WHERE cc.concept_type = 'composite'
AND cc.status = 'active'
UNION ALL
-- Niveles siguientes: Explotar components
SELECT
e.root_concept_id,
comp_concept.id as concept_id,
comp_concept.code,
comp_concept.name,
comp_concept.concept_type,
comp_concept.unit,
comp_concept.unit_price,
e.quantity_multiplier * (component->>'quantity')::DECIMAL as quantity_multiplier,
e.level + 1,
e.path || comp_concept.id
FROM explosion e
CROSS JOIN LATERAL jsonb_array_elements(
(SELECT components FROM budgets.concept_catalog WHERE id = e.concept_id)
) as component
JOIN budgets.concept_catalog comp_concept ON comp_concept.id = (component->>'conceptId')::UUID
WHERE e.level < 10 -- Prevenir ciclos infinitos
AND NOT comp_concept.id = ANY(e.path) -- Prevenir ciclos
)
SELECT
root_concept_id,
concept_id,
code,
name,
concept_type,
unit,
unit_price,
SUM(quantity_multiplier) as total_quantity,
SUM(quantity_multiplier * unit_price) as total_cost,
level,
COUNT(*) as occurrences
FROM explosion
WHERE level > 0 -- Solo componentes, no raiz
GROUP BY root_concept_id, concept_id, code, name, concept_type, unit, unit_price, level
ORDER BY root_concept_id, level, code;
CREATE UNIQUE INDEX idx_mv_concept_explosion_pk ON budgets.mv_concept_explosion(
root_concept_id, concept_id, level
);
CREATE INDEX idx_mv_concept_explosion_root ON budgets.mv_concept_explosion(root_concept_id);
CREATE INDEX idx_mv_concept_explosion_type ON budgets.mv_concept_explosion(concept_type);
-- Refrescar periodicamente
CREATE OR REPLACE FUNCTION budgets.refresh_concept_explosion()
RETURNS void AS $$
BEGIN
REFRESH MATERIALIZED VIEW CONCURRENTLY budgets.mv_concept_explosion;
END;
$$ LANGUAGE plpgsql;
mv_budget_summary
Resumen de presupuestos por proyecto.
CREATE MATERIALIZED VIEW budgets.mv_budget_summary AS
SELECT
b.id as budget_id,
b.tenant_id,
b.project_id,
b.budget_number,
b.budget_type,
b.name,
b.status,
b.total_amount,
b.total_cost,
b.margin_percentage,
-- Costos reales
COALESCE(SUM(ac.total_cost_base), 0) as actual_cost_total,
COALESCE(COUNT(DISTINCT ac.id), 0) as actual_cost_records,
-- Desviaciones
b.total_cost - COALESCE(SUM(ac.total_cost_base), 0) as cost_variance,
CASE
WHEN b.total_cost > 0 THEN
((b.total_cost - COALESCE(SUM(ac.total_cost_base), 0)) / b.total_cost * 100)
ELSE 0
END as cost_variance_percentage,
-- Rentabilidad proyectada
b.total_amount - COALESCE(SUM(ac.total_cost_base), 0) as projected_margin,
CASE
WHEN b.total_amount > 0 THEN
((b.total_amount - COALESCE(SUM(ac.total_cost_base), 0)) / b.total_amount * 100)
ELSE 0
END as projected_margin_percentage,
-- Metadata
b.created_at,
b.updated_at,
NOW() as refreshed_at
FROM budgets.budgets b
LEFT JOIN budgets.actual_costs ac ON ac.project_id = b.project_id
WHERE b.status IN ('approved', 'active', 'completed')
GROUP BY b.id, b.tenant_id, b.project_id, b.budget_number, b.budget_type,
b.name, b.status, b.total_amount, b.total_cost, b.margin_percentage,
b.created_at, b.updated_at;
CREATE UNIQUE INDEX idx_mv_budget_summary_pk ON budgets.mv_budget_summary(budget_id);
CREATE INDEX idx_mv_budget_summary_tenant ON budgets.mv_budget_summary(tenant_id);
CREATE INDEX idx_mv_budget_summary_project ON budgets.mv_budget_summary(project_id);
CREATE INDEX idx_mv_budget_summary_status ON budgets.mv_budget_summary(status);
Funciones de Utilidad
1. Calcular APU (Analisis de Precio Unitario)
Calcula el precio unitario de un concepto compuesto.
CREATE OR REPLACE FUNCTION budgets.calculate_apu(
p_concept_id UUID
) RETURNS TABLE (
direct_cost DECIMAL(12,2),
indirect_cost DECIMAL(12,2),
financing_cost DECIMAL(12,2),
profit_cost DECIMAL(12,2),
additional_cost DECIMAL(12,2),
unit_price DECIMAL(12,2),
unit_price_with_vat DECIMAL(12,2)
) AS $$
DECLARE
v_concept RECORD;
v_material_cost DECIMAL(12,2) := 0;
v_labor_cost DECIMAL(12,2) := 0;
v_equipment_cost DECIMAL(12,2) := 0;
v_direct DECIMAL(12,2);
v_indirect DECIMAL(12,2);
v_financing DECIMAL(12,2);
v_profit DECIMAL(12,2);
v_additional DECIMAL(12,2);
v_unit_price DECIMAL(12,2);
v_component JSONB;
v_crew JSONB;
BEGIN
-- Obtener concepto
SELECT * INTO v_concept
FROM budgets.concept_catalog
WHERE id = p_concept_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Concept not found: %', p_concept_id;
END IF;
IF v_concept.concept_type != 'composite' THEN
-- Concepto simple, retornar precio base
RETURN QUERY SELECT
v_concept.base_price,
0::DECIMAL(12,2),
0::DECIMAL(12,2),
0::DECIMAL(12,2),
0::DECIMAL(12,2),
v_concept.base_price,
v_concept.base_price * 1.16;
RETURN;
END IF;
-- Calcular costo de materiales y equipos
IF v_concept.components IS NOT NULL THEN
FOR v_component IN SELECT * FROM jsonb_array_elements(v_concept.components)
LOOP
DECLARE
v_comp_price DECIMAL(12,2);
v_comp_type VARCHAR(20);
BEGIN
SELECT unit_price, concept_type INTO v_comp_price, v_comp_type
FROM budgets.concept_catalog
WHERE id = (v_component->>'conceptId')::UUID;
IF v_comp_type = 'material' THEN
v_material_cost := v_material_cost +
(v_component->>'quantity')::DECIMAL * COALESCE(v_comp_price, 0);
ELSIF v_comp_type = 'equipment' THEN
v_equipment_cost := v_equipment_cost +
(v_component->>'quantity')::DECIMAL * COALESCE(v_comp_price, 0);
END IF;
END;
END LOOP;
END IF;
-- Calcular costo de mano de obra con FSR
IF v_concept.labor_crew IS NOT NULL THEN
FOR v_crew IN SELECT * FROM jsonb_array_elements(v_concept.labor_crew)
LOOP
v_labor_cost := v_labor_cost +
(v_crew->>'quantity')::DECIMAL *
(v_crew->>'dailyWage')::DECIMAL *
(v_crew->>'fsr')::DECIMAL;
END LOOP;
END IF;
-- Costo directo
v_direct := v_material_cost + v_labor_cost + v_equipment_cost;
-- Cargos
v_indirect := v_direct * (v_concept.indirect_percentage / 100);
v_financing := v_direct * (v_concept.financing_percentage / 100);
v_profit := v_direct * (v_concept.profit_percentage / 100);
v_additional := v_direct * (v_concept.additional_charges / 100);
-- Precio unitario sin IVA
v_unit_price := v_direct + v_indirect + v_financing + v_profit + v_additional;
RETURN QUERY SELECT
v_direct,
v_indirect,
v_financing,
v_profit,
v_additional,
v_unit_price,
v_unit_price * 1.16;
END;
$$ LANGUAGE plpgsql STABLE;
2. Calcular Desviaciones
Calcula desviaciones de costo para una partida presupuestal.
CREATE OR REPLACE FUNCTION budgets.calculate_variances(
p_budget_item_id UUID,
p_analysis_date DATE DEFAULT CURRENT_DATE
) RETURNS UUID AS $$
DECLARE
v_budget_item RECORD;
v_actual_qty DECIMAL(12,4);
v_actual_price DECIMAL(12,2);
v_actual_total DECIMAL(15,2);
v_price_var DECIMAL(15,2);
v_qty_var DECIMAL(15,2);
v_mixed_var DECIMAL(15,2);
v_total_var DECIMAL(15,2);
v_var_pct DECIMAL(6,2);
v_category VARCHAR(20);
v_variance_id UUID;
BEGIN
-- Obtener partida presupuestal
SELECT * INTO v_budget_item
FROM budgets.budget_items
WHERE id = p_budget_item_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Budget item not found: %', p_budget_item_id;
END IF;
-- Obtener costos reales acumulados
SELECT
COALESCE(SUM(quantity), 0),
CASE WHEN SUM(quantity) > 0
THEN SUM(total_cost) / SUM(quantity)
ELSE 0
END,
COALESCE(SUM(total_cost_base), 0)
INTO v_actual_qty, v_actual_price, v_actual_total
FROM budgets.actual_costs
WHERE budget_item_id = p_budget_item_id
AND cost_date <= p_analysis_date;
-- Calcular desviaciones
-- Desviacion en precio: (Pr - Pp) × Qr
v_price_var := (v_actual_price - v_budget_item.unit_cost) * v_actual_qty;
-- Desviacion en cantidad: (Qr - Qp) × Pp
v_qty_var := (v_actual_qty - v_budget_item.quantity) * v_budget_item.unit_cost;
-- Desviacion mixta: (Pr - Pp) × (Qr - Qp)
v_mixed_var := (v_actual_price - v_budget_item.unit_cost) *
(v_actual_qty - v_budget_item.quantity);
-- Desviacion total
v_total_var := v_actual_total - v_budget_item.total_cost;
-- Porcentaje de desviacion
IF v_budget_item.total_cost > 0 THEN
v_var_pct := (v_total_var / v_budget_item.total_cost) * 100;
ELSE
v_var_pct := 0;
END IF;
-- Categorizar desviacion
IF ABS(v_var_pct) < 3 THEN
v_category := 'within_tolerance';
ELSIF ABS(v_var_pct) < 5 THEN
v_category := 'minor';
ELSIF ABS(v_var_pct) < 15 THEN
v_category := 'moderate';
ELSIF ABS(v_var_pct) < 25 THEN
v_category := 'major';
ELSE
v_category := 'critical';
END IF;
-- Insertar o actualizar registro de desviacion
INSERT INTO budgets.cost_variances (
tenant_id,
project_id,
budget_item_id,
analysis_date,
budgeted_quantity,
actual_quantity,
budgeted_unit_price,
actual_unit_price,
budgeted_total,
actual_total,
price_variance,
quantity_variance,
mixed_variance,
total_variance,
variance_percentage,
variance_category
) VALUES (
v_budget_item.budget_id, -- Asumir mismo tenant que budget
(SELECT project_id FROM budgets.budgets WHERE id = (SELECT budget_id FROM budgets.budget_items WHERE id = p_budget_item_id)),
p_budget_item_id,
p_analysis_date,
v_budget_item.quantity,
v_actual_qty,
v_budget_item.unit_cost,
v_actual_price,
v_budget_item.total_cost,
v_actual_total,
v_price_var,
v_qty_var,
v_mixed_var,
v_total_var,
v_var_pct,
v_category
)
ON CONFLICT (budget_item_id, analysis_date) DO UPDATE SET
actual_quantity = EXCLUDED.actual_quantity,
actual_unit_price = EXCLUDED.actual_unit_price,
actual_total = EXCLUDED.actual_total,
price_variance = EXCLUDED.price_variance,
quantity_variance = EXCLUDED.quantity_variance,
mixed_variance = EXCLUDED.mixed_variance,
total_variance = EXCLUDED.total_variance,
variance_percentage = EXCLUDED.variance_percentage,
variance_category = EXCLUDED.variance_category
RETURNING id INTO v_variance_id;
RETURN v_variance_id;
END;
$$ LANGUAGE plpgsql;
3. Calcular EAC (Estimate At Completion)
Proyecta el costo final del proyecto usando Earned Value Management.
CREATE OR REPLACE FUNCTION budgets.calculate_eac(
p_project_id UUID,
p_budget_id UUID,
p_analysis_date DATE DEFAULT CURRENT_DATE
) RETURNS TABLE (
eac DECIMAL(15,2),
etc DECIMAL(15,2),
vac DECIMAL(15,2),
cpi DECIMAL(6,4),
spi DECIMAL(6,4),
tcpi DECIMAL(6,4)
) AS $$
DECLARE
v_bac DECIMAL(15,2); -- Budget At Completion
v_ac DECIMAL(15,2); -- Actual Cost
v_ev DECIMAL(15,2); -- Earned Value
v_pv DECIMAL(15,2); -- Planned Value
v_cpi DECIMAL(6,4); -- Cost Performance Index
v_spi DECIMAL(6,4); -- Schedule Performance Index
v_eac DECIMAL(15,2); -- Estimate At Completion
v_etc DECIMAL(15,2); -- Estimate To Complete
v_vac DECIMAL(15,2); -- Variance At Completion
v_tcpi DECIMAL(6,4); -- To Complete Performance Index
BEGIN
-- BAC: Presupuesto total aprobado
SELECT total_cost INTO v_bac
FROM budgets.budgets
WHERE id = p_budget_id;
IF NOT FOUND OR v_bac IS NULL THEN
RAISE EXCEPTION 'Budget not found or has no total cost';
END IF;
-- AC: Costo real acumulado
SELECT COALESCE(SUM(total_cost_base), 0) INTO v_ac
FROM budgets.actual_costs
WHERE project_id = p_project_id
AND cost_date <= p_analysis_date;
-- EV: Valor ganado (basado en % avance real de partidas completadas)
SELECT COALESCE(SUM(
bi.total_cost *
COALESCE((bi.notes::JSONB->>'percentComplete')::DECIMAL / 100, 0)
), 0) INTO v_ev
FROM budgets.budget_items bi
WHERE bi.budget_id = p_budget_id
AND bi.item_type = 'item';
-- PV: Valor planeado (basado en curva S o % tiempo transcurrido)
-- Simplificado: usar % tiempo transcurrido del proyecto
SELECT v_bac *
CASE
WHEN (SELECT end_date FROM projects WHERE id = p_project_id) > p_analysis_date
THEN EXTRACT(EPOCH FROM p_analysis_date - (SELECT start_date FROM projects WHERE id = p_project_id))::DECIMAL /
EXTRACT(EPOCH FROM (SELECT end_date - start_date FROM projects WHERE id = p_project_id))::DECIMAL
ELSE 1.0
END
INTO v_pv;
-- CPI: Cost Performance Index = EV / AC
IF v_ac > 0 THEN
v_cpi := v_ev / v_ac;
ELSE
v_cpi := 1.0;
END IF;
-- SPI: Schedule Performance Index = EV / PV
IF v_pv > 0 THEN
v_spi := v_ev / v_pv;
ELSE
v_spi := 1.0;
END IF;
-- EAC: Estimate At Completion
-- Metodo: EAC = BAC / CPI (asume tendencia actual continuara)
IF v_cpi > 0 THEN
v_eac := v_bac / v_cpi;
ELSE
v_eac := v_bac;
END IF;
-- ETC: Estimate To Complete = EAC - AC
v_etc := v_eac - v_ac;
-- VAC: Variance At Completion = BAC - EAC
v_vac := v_bac - v_eac;
-- TCPI: To Complete Performance Index
-- TCPI = (BAC - EV) / (BAC - AC)
IF (v_bac - v_ac) > 0 THEN
v_tcpi := (v_bac - v_ev) / (v_bac - v_ac);
ELSE
v_tcpi := 1.0;
END IF;
RETURN QUERY SELECT v_eac, v_etc, v_vac, v_cpi, v_spi, v_tcpi;
END;
$$ LANGUAGE plpgsql STABLE;
4. Actualizar Precio Unitario Concepto
Recalcula el precio unitario de un concepto compuesto.
CREATE OR REPLACE FUNCTION budgets.update_concept_unit_price(
p_concept_id UUID
) RETURNS BOOLEAN AS $$
DECLARE
v_result RECORD;
BEGIN
-- Calcular APU
SELECT * INTO v_result
FROM budgets.calculate_apu(p_concept_id);
-- Actualizar concepto
UPDATE budgets.concept_catalog SET
direct_cost = v_result.direct_cost,
unit_price = v_result.unit_price,
unit_price_with_vat = v_result.unit_price_with_vat,
updated_at = NOW()
WHERE id = p_concept_id;
RETURN FOUND;
END;
$$ LANGUAGE plpgsql;
5. Recalcular Totales Presupuesto
Recalcula los totales de un presupuesto sumando sus partidas.
CREATE OR REPLACE FUNCTION budgets.recalculate_budget_totals(
p_budget_id UUID,
p_version_id UUID DEFAULT NULL
) RETURNS BOOLEAN AS $$
DECLARE
v_version_id UUID;
v_total_price DECIMAL(15,2);
v_total_cost DECIMAL(15,2);
v_margin_amount DECIMAL(15,2);
v_margin_pct DECIMAL(5,2);
BEGIN
-- Si no se especifica version, usar la version actual
IF p_version_id IS NULL THEN
SELECT id INTO v_version_id
FROM budgets.budget_versions
WHERE budget_id = p_budget_id
AND is_current = true
LIMIT 1;
ELSE
v_version_id := p_version_id;
END IF;
IF v_version_id IS NULL THEN
RAISE EXCEPTION 'No current version found for budget %', p_budget_id;
END IF;
-- Sumar partidas de nivel 1 (items ejecutables)
SELECT
COALESCE(SUM(total_price), 0),
COALESCE(SUM(total_cost), 0)
INTO v_total_price, v_total_cost
FROM budgets.budget_items
WHERE budget_version_id = v_version_id
AND item_type = 'item';
-- Calcular margen
v_margin_amount := v_total_price - v_total_cost;
IF v_total_price > 0 THEN
v_margin_pct := (v_margin_amount / v_total_price) * 100;
ELSE
v_margin_pct := 0;
END IF;
-- Actualizar presupuesto
UPDATE budgets.budgets SET
total_amount = v_total_price,
total_cost = v_total_cost,
margin_amount = v_margin_amount,
margin_percentage = v_margin_pct,
updated_at = NOW()
WHERE id = p_budget_id;
-- Actualizar version
UPDATE budgets.budget_versions SET
total_amount = v_total_price,
total_cost = v_total_cost
WHERE id = v_version_id;
RETURN true;
END;
$$ LANGUAGE plpgsql;
Triggers
1. Actualizar Timestamp
CREATE OR REPLACE FUNCTION budgets.update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_concept_catalog_timestamp
BEFORE UPDATE ON budgets.concept_catalog
FOR EACH ROW EXECUTE FUNCTION budgets.update_timestamp();
CREATE TRIGGER trg_budgets_timestamp
BEFORE UPDATE ON budgets.budgets
FOR EACH ROW EXECUTE FUNCTION budgets.update_timestamp();
CREATE TRIGGER trg_budget_items_timestamp
BEFORE UPDATE ON budgets.budget_items
FOR EACH ROW EXECUTE FUNCTION budgets.update_timestamp();
2. Crear Historial de Precios
CREATE OR REPLACE FUNCTION budgets.create_price_history()
RETURNS TRIGGER AS $$
DECLARE
v_variation DECIMAL(6,2);
BEGIN
-- Solo si cambio el precio base
IF (NEW.base_price IS DISTINCT FROM OLD.base_price) THEN
-- Calcular variacion porcentual
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;
-- Cerrar registro anterior
UPDATE budgets.concept_price_history
SET valid_until = CURRENT_DATE - INTERVAL '1 day'
WHERE concept_id = NEW.id
AND valid_until IS NULL;
-- Crear nuevo registro
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 trg_concept_price_history
AFTER UPDATE ON budgets.concept_catalog
FOR EACH ROW
WHEN (OLD.base_price IS DISTINCT FROM NEW.base_price)
EXECUTE FUNCTION budgets.create_price_history();
3. Recalcular APU al Modificar Componentes
CREATE OR REPLACE FUNCTION budgets.recalculate_apu_on_change()
RETURNS TRIGGER AS $$
BEGIN
-- Si cambiaron los componentes, cuadrilla o porcentajes, recalcular
IF (NEW.components IS DISTINCT FROM OLD.components OR
NEW.labor_crew IS DISTINCT FROM OLD.labor_crew OR
NEW.indirect_percentage IS DISTINCT FROM OLD.indirect_percentage OR
NEW.financing_percentage IS DISTINCT FROM OLD.financing_percentage OR
NEW.profit_percentage IS DISTINCT FROM OLD.profit_percentage OR
NEW.additional_charges IS DISTINCT FROM OLD.additional_charges) AND
NEW.concept_type = 'composite' THEN
PERFORM budgets.update_concept_unit_price(NEW.id);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_concept_recalculate_apu
AFTER UPDATE ON budgets.concept_catalog
FOR EACH ROW
EXECUTE FUNCTION budgets.recalculate_apu_on_change();
4. Actualizar Path Jerarquico
CREATE OR REPLACE FUNCTION budgets.update_budget_item_path()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.parent_item_id IS NULL THEN
NEW.path := NEW.id::text::ltree;
ELSE
SELECT path || NEW.id::text
INTO NEW.path
FROM budgets.budget_items
WHERE id = NEW.parent_item_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_budget_item_path
BEFORE INSERT OR UPDATE OF parent_item_id ON budgets.budget_items
FOR EACH ROW
EXECUTE FUNCTION budgets.update_budget_item_path();
5. Calcular Total de Partida
CREATE OR REPLACE FUNCTION budgets.calculate_budget_item_total()
RETURNS TRIGGER AS $$
BEGIN
-- Calcular total precio
NEW.total_price := NEW.quantity * NEW.unit_price;
-- Calcular total costo
NEW.total_cost := NEW.quantity * NEW.unit_cost;
-- Calcular margen
IF NEW.total_price > 0 THEN
NEW.margin_percentage := ((NEW.total_price - NEW.total_cost) / NEW.total_price) * 100;
ELSE
NEW.margin_percentage := 0;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_budget_item_calculate_total
BEFORE INSERT OR UPDATE OF quantity, unit_price, unit_cost ON budgets.budget_items
FOR EACH ROW
EXECUTE FUNCTION budgets.calculate_budget_item_total();
Seed Data
Regiones de Mexico
-- Seed data para regiones (opcional, por tenant)
-- Este es un ejemplo que puede ejecutarse al crear un nuevo tenant
INSERT INTO budgets.regions (tenant_id, code, name, description) VALUES
(current_setting('app.current_tenant_id')::uuid, 'NORTE', 'Region Norte', 'Chihuahua, Sonora, Coahuila'),
(current_setting('app.current_tenant_id')::uuid, 'CENTRO', 'Region Centro', 'CDMX, EdoMex, Queretaro'),
(current_setting('app.current_tenant_id')::uuid, 'BAJIO', 'Region Bajio', 'Guanajuato, Aguascalientes, San Luis Potosi'),
(current_setting('app.current_tenant_id')::uuid, 'SUR', 'Region Sur', 'Oaxaca, Chiapas, Yucatan');
Historial
| Version | Fecha | Autor | Cambios |
|---|---|---|---|
| 1.0 | 2025-12-06 | Requirements-Analyst | Creacion inicial |
Aprobaciones
| Rol | Nombre | Fecha | Firma |
|---|---|---|---|
| DBA | - | - | [ ] |
| Tech Lead | - | - | [ ] |
| Architect | - | - | [ ] |