1723 lines
57 KiB
Markdown
1723 lines
57 KiB
Markdown
# 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 | - | - | [ ] |
|