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

1723 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
```mermaid
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.
```sql
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.
```sql
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.
```sql
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.
```sql
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.
```sql
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.
```sql
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.
```sql
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 |
```sql
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:**
```json
[
{
"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:**
```json
[
{
"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 |
```sql
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 |
```sql
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 |
```sql
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:**
```json
{
"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 |
```sql
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:**
```json
{
"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 |
```sql
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 |
```sql
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 |
```sql
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).
```sql
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.
```sql
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.
```sql
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.
```sql
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.
```sql
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.
```sql
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.
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
-- 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 | - | - | [ ] |