# 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 | - | - | [ ] |
