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

57 KiB
Raw Blame History

DDL-SPEC: Schema budgets

Identificacion

Campo Valor
Schema budgets
Modulo MAI-003
Vertical Construccion
Version 1.0
Estado En Diseno
Autor Requirements-Analyst
Fecha 2025-12-06

Descripcion General

El schema budgets implementa el sistema completo de presupuestos y control de costos para construccion: catalogo de conceptos con APU (Analisis de Precios Unitarios), presupuestos multinivel (obra, etapa, prototipo), control de costos reales vs presupuestados, analisis de desviaciones, y calculo de rentabilidad. Incluye explosion de insumos, versionado de presupuestos, y curva S para control financiero.

Alcance

  • Catalogo maestro de conceptos reutilizables (Material, Mano de Obra, Maquinaria, Compuestos)
  • APU detallados con explosion de insumos y cargos (indirectos, financiamiento, utilidad)
  • Presupuestos jerarquicos: Obra > Etapa > Partidas
  • Versionado completo con baseline y control de cambios
  • Registro de costos reales desde Compras, Nomina y Subcontratos
  • Calculo de desviaciones (precio, cantidad, mixta)
  • Analisis de rentabilidad con proyecciones (EAC, VAC, CPI, SPI)
  • Vistas materializadas para explosion de insumos

RF Cubiertos

RF Titulo Tablas
RF-COST-001 Catalogo de Conceptos concept_catalog, concept_price_history
RF-COST-002 Presupuestos Maestros budgets, budget_items, budget_versions
RF-COST-003 Control de Costos actual_costs, cost_variances
RF-COST-004 Analisis de Rentabilidad profitability_analysis

Diagrama ER

erDiagram
    concept_catalog {
        uuid id PK
        uuid tenant_id FK
        varchar code UK
        varchar name
        varchar concept_type
        varchar category
        varchar unit
        decimal base_price
        jsonb components
        jsonb labor_crew
        decimal indirect_percentage
        decimal financing_percentage
        decimal profit_percentage
        decimal direct_cost
        decimal unit_price
        int version
        varchar status
    }

    concept_price_history {
        uuid id PK
        uuid concept_id FK
        decimal price
        date valid_from
        date valid_until
        decimal variation_percentage
        varchar reason
    }

    budgets {
        uuid id PK
        uuid tenant_id FK
        uuid project_id FK
        varchar budget_number
        varchar budget_type
        varchar name
        date budget_date
        decimal total_amount
        decimal total_cost
        decimal margin_percentage
        varchar status
        uuid baseline_version_id FK
    }

    budget_versions {
        uuid id PK
        uuid budget_id FK
        int version_number
        varchar version_type
        varchar description
        date effective_date
        decimal total_amount
        jsonb changes_summary
        boolean is_current
    }

    budget_items {
        uuid id PK
        uuid budget_id FK
        uuid budget_version_id FK
        uuid parent_item_id FK
        uuid concept_id FK
        varchar code
        varchar name
        varchar item_type
        int level
        ltree path
        decimal quantity
        varchar unit
        decimal unit_price
        decimal total_price
        decimal total_cost
        int sort_order
    }

    actual_costs {
        uuid id PK
        uuid tenant_id FK
        uuid project_id FK
        uuid budget_item_id FK
        uuid concept_id FK
        varchar source_module
        uuid source_document_id
        date cost_date
        decimal quantity
        decimal unit_price
        decimal total_cost
        varchar cost_type
    }

    cost_variances {
        uuid id PK
        uuid tenant_id FK
        uuid project_id FK
        uuid budget_item_id FK
        date analysis_date
        decimal budgeted_quantity
        decimal actual_quantity
        decimal budgeted_unit_price
        decimal actual_unit_price
        decimal budgeted_total
        decimal actual_total
        decimal price_variance
        decimal quantity_variance
        decimal mixed_variance
        decimal total_variance
        decimal variance_percentage
    }

    profitability_analysis {
        uuid id PK
        uuid tenant_id FK
        uuid project_id FK
        date analysis_date
        decimal budget_at_completion
        decimal actual_cost_to_date
        decimal earned_value
        decimal estimate_at_completion
        decimal estimate_to_complete
        decimal variance_at_completion
        decimal cost_performance_index
        decimal schedule_performance_index
        decimal percent_complete
        decimal projected_margin
    }

    concept_catalog ||--o{ concept_price_history : "historial"
    concept_catalog ||--o{ budget_items : "usado_en"
    concept_catalog ||--o{ actual_costs : "costo_real"
    budgets ||--o{ budget_versions : "versiones"
    budgets ||--o{ budget_items : "partidas"
    budgets ||--o{ actual_costs : "costos"
    budgets ||--o{ profitability_analysis : "analisis"
    budget_versions ||--o{ budget_items : "partidas_version"
    budget_items ||--o{ budget_items : "jerarquia"
    budget_items ||--o{ actual_costs : "costo_real"
    budget_items ||--o{ cost_variances : "desviaciones"

ENUMs y Tipos

concept_type_enum

Tipos de conceptos en catalogo.

CREATE TYPE budgets.concept_type_enum AS ENUM (
    'material',      -- Materiales (concreto, acero, block)
    'labor',         -- Mano de obra (albanil, fierrero, carpintero)
    'equipment',     -- Maquinaria y equipo (vibrador, revolvedora)
    'composite'      -- Conceptos compuestos (APU completo)
);

concept_status_enum

Estados del concepto.

CREATE TYPE budgets.concept_status_enum AS ENUM (
    'active',        -- Activo y disponible
    'deprecated',    -- Obsoleto (mantener para historico)
    'draft'          -- En construccion
);

budget_type_enum

Tipos de presupuestos.

CREATE TYPE budgets.budget_type_enum AS ENUM (
    'master',        -- Presupuesto maestro de obra
    'stage',         -- Presupuesto por etapa
    'prototype',     -- Presupuesto por prototipo/modelo
    'change_order'   -- Orden de cambio
);

budget_status_enum

Estados del presupuesto.

CREATE TYPE budgets.budget_status_enum AS ENUM (
    'draft',         -- En elaboracion
    'pending',       -- Pendiente aprobacion
    'approved',      -- Aprobado (baseline)
    'active',        -- En ejecucion
    'completed',     -- Completado
    'cancelled'      -- Cancelado
);

version_type_enum

Tipos de versiones.

CREATE TYPE budgets.version_type_enum AS ENUM (
    'baseline',      -- Version base inicial
    'revision',      -- Revision sin cambio de alcance
    'change_order',  -- Orden de cambio (cambio alcance)
    'adjustment'     -- Ajuste de precios
);

cost_type_enum

Tipos de costos reales.

CREATE TYPE budgets.cost_type_enum AS ENUM (
    'material',      -- Desde modulo Compras
    'labor',         -- Desde modulo Nomina
    'equipment',     -- Renta de maquinaria
    'subcontract',   -- Desde modulo Subcontratos
    'other'          -- Otros costos
);

item_type_enum

Tipos de partidas en presupuesto.

CREATE TYPE budgets.item_type_enum AS ENUM (
    'chapter',       -- Capitulo (agrupador)
    'group',         -- Grupo (sub-agrupador)
    'item',          -- Partida ejecutable
    'subtotal'       -- Subtotal calculado
);

Tablas

1. concept_catalog

Catalogo maestro de conceptos reutilizables con APU.

Columna Tipo Nullable Default Descripcion
id UUID NOT NULL gen_random_uuid() PK
tenant_id UUID NOT NULL - FK a tenants
code VARCHAR(20) NOT NULL - Codigo unico concepto
name VARCHAR(255) NOT NULL - Nombre del concepto
description TEXT NULL - Descripcion tecnica
concept_type concept_type_enum NOT NULL - Tipo de concepto
category VARCHAR(100) NULL - Division CMIC
subcategory VARCHAR(100) NULL - Grupo CMIC
unit VARCHAR(20) NOT NULL - Unidad medida
base_price DECIMAL(12,2) NULL - Precio base (simples)
includes_vat BOOLEAN NOT NULL false Precio incluye IVA
currency_code VARCHAR(3) NOT NULL 'MXN' Codigo moneda
waste_factor DECIMAL(5,3) NOT NULL 1.000 Factor desperdicio
components JSONB NULL - Componentes (compuestos)
labor_crew JSONB NULL - Cuadrilla tipo
indirect_percentage DECIMAL(5,2) NOT NULL 12.00 % Indirectos
financing_percentage DECIMAL(5,2) NOT NULL 3.00 % Financiamiento
profit_percentage DECIMAL(5,2) NOT NULL 10.00 % Utilidad
additional_charges DECIMAL(5,2) NOT NULL 2.00 % Cargos adicionales
direct_cost DECIMAL(12,2) NULL - Costo directo calculado
unit_price DECIMAL(12,2) NULL - PU sin IVA
unit_price_with_vat DECIMAL(12,2) NULL - PU con IVA
region_id UUID NULL - Region de precios
preferred_supplier_id UUID NULL - Proveedor preferido
technical_specs TEXT NULL - Especificaciones tecnicas
performance VARCHAR(255) NULL - Rendimiento
version INTEGER NOT NULL 1 Version del concepto
status concept_status_enum NOT NULL 'active' Estado
created_by UUID NOT NULL - Usuario creador
created_at TIMESTAMPTZ NOT NULL NOW() Fecha creacion
updated_at TIMESTAMPTZ NOT NULL NOW() Fecha actualizacion
updated_by UUID NULL - Usuario modificador
CREATE TABLE budgets.concept_catalog (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL,
    code VARCHAR(20) NOT NULL,
    name VARCHAR(255) NOT NULL,
    description TEXT,
    concept_type budgets.concept_type_enum NOT NULL,
    category VARCHAR(100),
    subcategory VARCHAR(100),
    unit VARCHAR(20) NOT NULL,
    base_price DECIMAL(12,2),
    includes_vat BOOLEAN NOT NULL DEFAULT false,
    currency_code VARCHAR(3) NOT NULL DEFAULT 'MXN',
    waste_factor DECIMAL(5,3) NOT NULL DEFAULT 1.000,
    components JSONB,
    labor_crew JSONB,
    indirect_percentage DECIMAL(5,2) NOT NULL DEFAULT 12.00,
    financing_percentage DECIMAL(5,2) NOT NULL DEFAULT 3.00,
    profit_percentage DECIMAL(5,2) NOT NULL DEFAULT 10.00,
    additional_charges DECIMAL(5,2) NOT NULL DEFAULT 2.00,
    direct_cost DECIMAL(12,2),
    unit_price DECIMAL(12,2),
    unit_price_with_vat DECIMAL(12,2),
    region_id UUID,
    preferred_supplier_id UUID,
    technical_specs TEXT,
    performance VARCHAR(255),
    version INTEGER NOT NULL DEFAULT 1,
    status budgets.concept_status_enum NOT NULL DEFAULT 'active',
    created_by UUID NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_by UUID,

    CONSTRAINT uk_concept_catalog_tenant_code UNIQUE (tenant_id, code),
    CONSTRAINT chk_concept_base_price CHECK (base_price IS NULL OR base_price >= 0),
    CONSTRAINT chk_concept_waste_factor CHECK (waste_factor >= 1.000),
    CONSTRAINT chk_concept_currency CHECK (currency_code IN ('MXN', 'USD'))
);

CREATE INDEX idx_concept_catalog_tenant ON budgets.concept_catalog(tenant_id);
CREATE INDEX idx_concept_catalog_type ON budgets.concept_catalog(concept_type);
CREATE INDEX idx_concept_catalog_category ON budgets.concept_catalog(category);
CREATE INDEX idx_concept_catalog_status ON budgets.concept_catalog(status);
CREATE INDEX idx_concept_catalog_code ON budgets.concept_catalog(code);

-- Indice full-text para busqueda
CREATE INDEX idx_concept_catalog_search ON budgets.concept_catalog
    USING GIN (to_tsvector('spanish', name || ' ' || COALESCE(description, '')));

-- RLS
ALTER TABLE budgets.concept_catalog ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON budgets.concept_catalog
    FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

Ejemplo de components JSONB:

[
  {
    "conceptId": "uuid-concreto",
    "quantity": 1.05,
    "unit": "m³",
    "name": "Concreto premezclado f'c=200",
    "unitPrice": 1850.00
  },
  {
    "conceptId": "uuid-acero",
    "quantity": 25,
    "unit": "kg",
    "name": "Acero de refuerzo 3/8",
    "unitPrice": 18.00
  }
]

Ejemplo de labor_crew JSONB:

[
  {
    "category": "oficial",
    "quantity": 0.8,
    "dailyWage": 450.00,
    "fsr": 1.65
  },
  {
    "category": "ayudante",
    "quantity": 1.6,
    "dailyWage": 280.00,
    "fsr": 1.65
  }
]

2. concept_price_history

Historial de precios por concepto.

Columna Tipo Nullable Default Descripcion
id UUID NOT NULL gen_random_uuid() PK
concept_id UUID NOT NULL - FK a concept_catalog
price DECIMAL(12,2) NOT NULL - Precio historico
valid_from DATE NOT NULL - Fecha inicio vigencia
valid_until DATE NULL - Fecha fin vigencia
variation_percentage DECIMAL(6,2) NULL - % Variacion vs anterior
reason VARCHAR(255) NULL - Razon del cambio
created_by UUID NOT NULL - Usuario creador
created_at TIMESTAMPTZ NOT NULL NOW() Fecha creacion
CREATE TABLE budgets.concept_price_history (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    concept_id UUID NOT NULL REFERENCES budgets.concept_catalog(id) ON DELETE CASCADE,
    price DECIMAL(12,2) NOT NULL,
    valid_from DATE NOT NULL,
    valid_until DATE,
    variation_percentage DECIMAL(6,2),
    reason VARCHAR(255),
    created_by UUID NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT chk_price_history_dates CHECK (valid_until IS NULL OR valid_until >= valid_from),
    CONSTRAINT chk_price_history_price CHECK (price >= 0)
);

CREATE INDEX idx_price_history_concept ON budgets.concept_price_history(concept_id);
CREATE INDEX idx_price_history_valid_from ON budgets.concept_price_history(valid_from DESC);
CREATE INDEX idx_price_history_lookup ON budgets.concept_price_history(
    concept_id, valid_from DESC
) WHERE valid_until IS NULL;

3. budgets

Presupuestos maestros (obra, etapa, prototipo).

Columna Tipo Nullable Default Descripcion
id UUID NOT NULL gen_random_uuid() PK
tenant_id UUID NOT NULL - FK a tenants
project_id UUID NOT NULL - FK a projects
budget_number VARCHAR(20) NOT NULL - Numero secuencial
budget_type budget_type_enum NOT NULL - Tipo presupuesto
name VARCHAR(255) NOT NULL - Nombre presupuesto
description TEXT NULL - Descripcion
budget_date DATE NOT NULL - Fecha presupuesto
total_amount DECIMAL(15,2) NOT NULL 0 Monto total (venta)
total_cost DECIMAL(15,2) NOT NULL 0 Costo total
margin_amount DECIMAL(15,2) NOT NULL 0 Margen ($)
margin_percentage DECIMAL(5,2) NOT NULL 0 Margen (%)
currency_code VARCHAR(3) NOT NULL 'MXN' Codigo moneda
exchange_rate DECIMAL(10,4) NOT NULL 1.0000 Tipo cambio
status budget_status_enum NOT NULL 'draft' Estado
baseline_version_id UUID NULL - Version baseline
approved_by UUID NULL - Usuario aprobador
approved_at TIMESTAMPTZ NULL - Fecha aprobacion
notes TEXT NULL - Notas
created_by UUID NOT NULL - Usuario creador
created_at TIMESTAMPTZ NOT NULL NOW() Fecha creacion
updated_at TIMESTAMPTZ NOT NULL NOW() Fecha actualizacion
CREATE TABLE budgets.budgets (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL,
    project_id UUID NOT NULL,
    budget_number VARCHAR(20) NOT NULL,
    budget_type budgets.budget_type_enum NOT NULL,
    name VARCHAR(255) NOT NULL,
    description TEXT,
    budget_date DATE NOT NULL,
    total_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
    total_cost DECIMAL(15,2) NOT NULL DEFAULT 0,
    margin_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
    margin_percentage DECIMAL(5,2) NOT NULL DEFAULT 0,
    currency_code VARCHAR(3) NOT NULL DEFAULT 'MXN',
    exchange_rate DECIMAL(10,4) NOT NULL DEFAULT 1.0000,
    status budgets.budget_status_enum NOT NULL DEFAULT 'draft',
    baseline_version_id UUID,
    approved_by UUID,
    approved_at TIMESTAMPTZ,
    notes TEXT,
    created_by UUID NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT uk_budgets_tenant_number UNIQUE (tenant_id, budget_number),
    CONSTRAINT chk_budgets_amounts CHECK (
        total_amount >= 0 AND total_cost >= 0 AND margin_amount >= 0
    ),
    CONSTRAINT chk_budgets_currency CHECK (currency_code IN ('MXN', 'USD'))
);

CREATE INDEX idx_budgets_tenant ON budgets.budgets(tenant_id);
CREATE INDEX idx_budgets_project ON budgets.budgets(project_id);
CREATE INDEX idx_budgets_type ON budgets.budgets(budget_type);
CREATE INDEX idx_budgets_status ON budgets.budgets(status);
CREATE INDEX idx_budgets_date ON budgets.budgets(budget_date DESC);

-- RLS
ALTER TABLE budgets.budgets ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON budgets.budgets
    FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

4. budget_versions

Versiones del presupuesto con control de cambios.

Columna Tipo Nullable Default Descripcion
id UUID NOT NULL gen_random_uuid() PK
budget_id UUID NOT NULL - FK a budgets
version_number INTEGER NOT NULL - Numero version
version_type version_type_enum NOT NULL - Tipo version
description TEXT NOT NULL - Descripcion cambios
effective_date DATE NOT NULL - Fecha efectiva
total_amount DECIMAL(15,2) NOT NULL - Monto total version
total_cost DECIMAL(15,2) NOT NULL - Costo total version
changes_summary JSONB NULL - Resumen de cambios
is_current BOOLEAN NOT NULL false Version actual
created_by UUID NOT NULL - Usuario creador
created_at TIMESTAMPTZ NOT NULL NOW() Fecha creacion
CREATE TABLE budgets.budget_versions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    budget_id UUID NOT NULL REFERENCES budgets.budgets(id) ON DELETE CASCADE,
    version_number INTEGER NOT NULL,
    version_type budgets.version_type_enum NOT NULL,
    description TEXT NOT NULL,
    effective_date DATE NOT NULL,
    total_amount DECIMAL(15,2) NOT NULL,
    total_cost DECIMAL(15,2) NOT NULL,
    changes_summary JSONB,
    is_current BOOLEAN NOT NULL DEFAULT false,
    created_by UUID NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT uk_budget_versions_number UNIQUE (budget_id, version_number),
    CONSTRAINT chk_budget_versions_amounts CHECK (total_amount >= 0 AND total_cost >= 0)
);

CREATE INDEX idx_budget_versions_budget ON budgets.budget_versions(budget_id);
CREATE INDEX idx_budget_versions_number ON budgets.budget_versions(version_number DESC);
CREATE INDEX idx_budget_versions_current ON budgets.budget_versions(budget_id, is_current)
    WHERE is_current = true;

-- Solo una version actual por presupuesto
CREATE UNIQUE INDEX uk_budget_versions_current ON budgets.budget_versions(budget_id)
    WHERE is_current = true;

Ejemplo de changes_summary JSONB:

{
  "itemsAdded": 5,
  "itemsModified": 12,
  "itemsDeleted": 2,
  "amountIncrease": 125000.00,
  "reason": "Ampliacion alcance: Muro perimetral adicional"
}

5. budget_items

Partidas del presupuesto (jerarquicas).

Columna Tipo Nullable Default Descripcion
id UUID NOT NULL gen_random_uuid() PK
budget_id UUID NOT NULL - FK a budgets
budget_version_id UUID NOT NULL - FK a budget_versions
parent_item_id UUID NULL - Partida padre
concept_id UUID NULL - FK a concept_catalog
code VARCHAR(30) NOT NULL - Codigo partida
name VARCHAR(255) NOT NULL - Nombre partida
description TEXT NULL - Descripcion
item_type item_type_enum NOT NULL - Tipo partida
level INTEGER NOT NULL 1 Nivel jerarquico
path LTREE NULL - Path materializado
quantity DECIMAL(12,4) NOT NULL 0 Cantidad
unit VARCHAR(20) NULL - Unidad medida
unit_price DECIMAL(12,2) NOT NULL 0 PU sin IVA
total_price DECIMAL(15,2) NOT NULL 0 Total sin IVA
unit_cost DECIMAL(12,2) NOT NULL 0 Costo unitario
total_cost DECIMAL(15,2) NOT NULL 0 Costo total
margin_percentage DECIMAL(5,2) NOT NULL 0 Margen (%)
formula TEXT NULL - Formula calculo cantidad
formula_params JSONB NULL - Parametros formula
sort_order INTEGER NOT NULL 0 Orden visualizacion
notes TEXT NULL - Notas
created_at TIMESTAMPTZ NOT NULL NOW() Fecha creacion
updated_at TIMESTAMPTZ NOT NULL NOW() Fecha actualizacion
CREATE EXTENSION IF NOT EXISTS ltree;

CREATE TABLE budgets.budget_items (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    budget_id UUID NOT NULL REFERENCES budgets.budgets(id) ON DELETE CASCADE,
    budget_version_id UUID NOT NULL REFERENCES budgets.budget_versions(id) ON DELETE CASCADE,
    parent_item_id UUID REFERENCES budgets.budget_items(id),
    concept_id UUID REFERENCES budgets.concept_catalog(id),
    code VARCHAR(30) NOT NULL,
    name VARCHAR(255) NOT NULL,
    description TEXT,
    item_type budgets.item_type_enum NOT NULL,
    level INTEGER NOT NULL DEFAULT 1,
    path LTREE,
    quantity DECIMAL(12,4) NOT NULL DEFAULT 0,
    unit VARCHAR(20),
    unit_price DECIMAL(12,2) NOT NULL DEFAULT 0,
    total_price DECIMAL(15,2) NOT NULL DEFAULT 0,
    unit_cost DECIMAL(12,2) NOT NULL DEFAULT 0,
    total_cost DECIMAL(15,2) NOT NULL DEFAULT 0,
    margin_percentage DECIMAL(5,2) NOT NULL DEFAULT 0,
    formula TEXT,
    formula_params JSONB,
    sort_order INTEGER NOT NULL DEFAULT 0,
    notes TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT uk_budget_items_version_code UNIQUE (budget_version_id, code),
    CONSTRAINT chk_budget_items_level CHECK (level >= 1 AND level <= 10),
    CONSTRAINT chk_budget_items_amounts CHECK (
        quantity >= 0 AND unit_price >= 0 AND total_price >= 0 AND
        unit_cost >= 0 AND total_cost >= 0
    ),
    CONSTRAINT chk_budget_items_item_has_concept CHECK (
        item_type != 'item' OR concept_id IS NOT NULL
    )
);

CREATE INDEX idx_budget_items_budget ON budgets.budget_items(budget_id);
CREATE INDEX idx_budget_items_version ON budgets.budget_items(budget_version_id);
CREATE INDEX idx_budget_items_parent ON budgets.budget_items(parent_item_id);
CREATE INDEX idx_budget_items_concept ON budgets.budget_items(concept_id);
CREATE INDEX idx_budget_items_path ON budgets.budget_items USING GIST (path);
CREATE INDEX idx_budget_items_type ON budgets.budget_items(item_type);
CREATE INDEX idx_budget_items_sort ON budgets.budget_items(budget_version_id, sort_order);

Ejemplo de formula_params JSONB:

{
  "buildingPerimeter": 30.0,
  "desplantDepth": 0.80,
  "widthFactor": 0.60,
  "wasteFactor": 1.15,
  "calculatedQuantity": 16.56
}

6. actual_costs

Costos reales registrados desde otros modulos.

Columna Tipo Nullable Default Descripcion
id UUID NOT NULL gen_random_uuid() PK
tenant_id UUID NOT NULL - FK a tenants
project_id UUID NOT NULL - FK a projects
budget_item_id UUID NULL - FK a budget_items
concept_id UUID NULL - FK a concept_catalog
source_module VARCHAR(50) NOT NULL - Modulo origen
source_document_id UUID NOT NULL - Documento origen
source_document_number VARCHAR(50) NULL - Folio documento
cost_date DATE NOT NULL - Fecha del costo
cost_type cost_type_enum NOT NULL - Tipo de costo
quantity DECIMAL(12,4) NOT NULL - Cantidad
unit VARCHAR(20) NOT NULL - Unidad medida
unit_price DECIMAL(12,2) NOT NULL - Precio unitario
total_cost DECIMAL(15,2) NOT NULL - Costo total
currency_code VARCHAR(3) NOT NULL 'MXN' Codigo moneda
exchange_rate DECIMAL(10,4) NOT NULL 1.0000 Tipo cambio
total_cost_base DECIMAL(15,2) NOT NULL - Costo en moneda base
notes TEXT NULL - Notas
created_at TIMESTAMPTZ NOT NULL NOW() Fecha registro
CREATE TABLE budgets.actual_costs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL,
    project_id UUID NOT NULL,
    budget_item_id UUID REFERENCES budgets.budget_items(id),
    concept_id UUID REFERENCES budgets.concept_catalog(id),
    source_module VARCHAR(50) NOT NULL,
    source_document_id UUID NOT NULL,
    source_document_number VARCHAR(50),
    cost_date DATE NOT NULL,
    cost_type budgets.cost_type_enum NOT NULL,
    quantity DECIMAL(12,4) NOT NULL,
    unit VARCHAR(20) NOT NULL,
    unit_price DECIMAL(12,2) NOT NULL,
    total_cost DECIMAL(15,2) NOT NULL,
    currency_code VARCHAR(3) NOT NULL DEFAULT 'MXN',
    exchange_rate DECIMAL(10,4) NOT NULL DEFAULT 1.0000,
    total_cost_base DECIMAL(15,2) NOT NULL,
    notes TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT chk_actual_costs_amounts CHECK (
        quantity >= 0 AND unit_price >= 0 AND total_cost >= 0 AND total_cost_base >= 0
    ),
    CONSTRAINT chk_actual_costs_currency CHECK (currency_code IN ('MXN', 'USD')),
    CONSTRAINT chk_actual_costs_source CHECK (source_module IN (
        'purchases', 'payroll', 'subcontracts', 'manual'
    ))
);

CREATE INDEX idx_actual_costs_tenant ON budgets.actual_costs(tenant_id);
CREATE INDEX idx_actual_costs_project ON budgets.actual_costs(project_id);
CREATE INDEX idx_actual_costs_budget_item ON budgets.actual_costs(budget_item_id);
CREATE INDEX idx_actual_costs_concept ON budgets.actual_costs(concept_id);
CREATE INDEX idx_actual_costs_date ON budgets.actual_costs(cost_date);
CREATE INDEX idx_actual_costs_source ON budgets.actual_costs(source_module, source_document_id);
CREATE INDEX idx_actual_costs_type ON budgets.actual_costs(cost_type);

-- RLS
ALTER TABLE budgets.actual_costs ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON budgets.actual_costs
    FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

7. cost_variances

Analisis de desviaciones presupuestales.

Columna Tipo Nullable Default Descripcion
id UUID NOT NULL gen_random_uuid() PK
tenant_id UUID NOT NULL - FK a tenants
project_id UUID NOT NULL - FK a projects
budget_item_id UUID NOT NULL - FK a budget_items
analysis_date DATE NOT NULL - Fecha analisis
budgeted_quantity DECIMAL(12,4) NOT NULL - Cantidad presupuestada
actual_quantity DECIMAL(12,4) NOT NULL - Cantidad real
budgeted_unit_price DECIMAL(12,2) NOT NULL - PU presupuestado
actual_unit_price DECIMAL(12,2) NOT NULL - PU real
budgeted_total DECIMAL(15,2) NOT NULL - Total presupuestado
actual_total DECIMAL(15,2) NOT NULL - Total real
price_variance DECIMAL(15,2) NOT NULL - Desviacion precio
quantity_variance DECIMAL(15,2) NOT NULL - Desviacion cantidad
mixed_variance DECIMAL(15,2) NOT NULL - Desviacion mixta
total_variance DECIMAL(15,2) NOT NULL - Desviacion total
variance_percentage DECIMAL(6,2) NOT NULL - % Desviacion
variance_category VARCHAR(20) NOT NULL - Categoria desviacion
notes TEXT NULL - Notas analisis
created_at TIMESTAMPTZ NOT NULL NOW() Fecha calculo
CREATE TABLE budgets.cost_variances (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL,
    project_id UUID NOT NULL,
    budget_item_id UUID NOT NULL REFERENCES budgets.budget_items(id) ON DELETE CASCADE,
    analysis_date DATE NOT NULL,
    budgeted_quantity DECIMAL(12,4) NOT NULL,
    actual_quantity DECIMAL(12,4) NOT NULL,
    budgeted_unit_price DECIMAL(12,2) NOT NULL,
    actual_unit_price DECIMAL(12,2) NOT NULL,
    budgeted_total DECIMAL(15,2) NOT NULL,
    actual_total DECIMAL(15,2) NOT NULL,
    price_variance DECIMAL(15,2) NOT NULL,
    quantity_variance DECIMAL(15,2) NOT NULL,
    mixed_variance DECIMAL(15,2) NOT NULL,
    total_variance DECIMAL(15,2) NOT NULL,
    variance_percentage DECIMAL(6,2) NOT NULL,
    variance_category VARCHAR(20) NOT NULL,
    notes TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT uk_cost_variances_item_date UNIQUE (budget_item_id, analysis_date),
    CONSTRAINT chk_cost_variances_amounts CHECK (
        budgeted_quantity >= 0 AND actual_quantity >= 0 AND
        budgeted_unit_price >= 0 AND actual_unit_price >= 0 AND
        budgeted_total >= 0 AND actual_total >= 0
    ),
    CONSTRAINT chk_cost_variances_category CHECK (variance_category IN (
        'within_tolerance',  -- Dentro tolerancia
        'minor',            -- Desviacion menor (< 5%)
        'moderate',         -- Desviacion moderada (5-15%)
        'major',            -- Desviacion mayor (> 15%)
        'critical'          -- Desviacion critica (> 25%)
    ))
);

CREATE INDEX idx_cost_variances_tenant ON budgets.cost_variances(tenant_id);
CREATE INDEX idx_cost_variances_project ON budgets.cost_variances(project_id);
CREATE INDEX idx_cost_variances_budget_item ON budgets.cost_variances(budget_item_id);
CREATE INDEX idx_cost_variances_date ON budgets.cost_variances(analysis_date DESC);
CREATE INDEX idx_cost_variances_category ON budgets.cost_variances(variance_category);

-- RLS
ALTER TABLE budgets.cost_variances ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON budgets.cost_variances
    FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

8. profitability_analysis

Analisis de rentabilidad y proyecciones (EVM - Earned Value Management).

Columna Tipo Nullable Default Descripcion
id UUID NOT NULL gen_random_uuid() PK
tenant_id UUID NOT NULL - FK a tenants
project_id UUID NOT NULL - FK a projects
budget_id UUID NOT NULL - FK a budgets
analysis_date DATE NOT NULL - Fecha analisis
budget_at_completion DECIMAL(15,2) NOT NULL - BAC - Presupuesto total
actual_cost_to_date DECIMAL(15,2) NOT NULL - AC - Costo real
earned_value DECIMAL(15,2) NOT NULL - EV - Valor ganado
planned_value DECIMAL(15,2) NOT NULL - PV - Valor planeado
estimate_at_completion DECIMAL(15,2) NOT NULL - EAC - Estimado al termino
estimate_to_complete DECIMAL(15,2) NOT NULL - ETC - Estimado para completar
variance_at_completion DECIMAL(15,2) NOT NULL - VAC - Variacion al termino
cost_performance_index DECIMAL(6,4) NOT NULL - CPI - Indice desempeno costo
schedule_performance_index DECIMAL(6,4) NOT NULL - SPI - Indice desempeno tiempo
to_complete_performance_index DECIMAL(6,4) NOT NULL - TCPI - Indice para completar
percent_complete_planned DECIMAL(5,2) NOT NULL - % Avance planeado
percent_complete_actual DECIMAL(5,2) NOT NULL - % Avance real
projected_margin_amount DECIMAL(15,2) NOT NULL - Margen proyectado ($)
projected_margin_percentage DECIMAL(5,2) NOT NULL - Margen proyectado (%)
health_status VARCHAR(20) NOT NULL - Estado salud proyecto
notes TEXT NULL - Notas analisis
created_at TIMESTAMPTZ NOT NULL NOW() Fecha calculo
CREATE TABLE budgets.profitability_analysis (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL,
    project_id UUID NOT NULL,
    budget_id UUID NOT NULL REFERENCES budgets.budgets(id) ON DELETE CASCADE,
    analysis_date DATE NOT NULL,
    budget_at_completion DECIMAL(15,2) NOT NULL,
    actual_cost_to_date DECIMAL(15,2) NOT NULL,
    earned_value DECIMAL(15,2) NOT NULL,
    planned_value DECIMAL(15,2) NOT NULL,
    estimate_at_completion DECIMAL(15,2) NOT NULL,
    estimate_to_complete DECIMAL(15,2) NOT NULL,
    variance_at_completion DECIMAL(15,2) NOT NULL,
    cost_performance_index DECIMAL(6,4) NOT NULL,
    schedule_performance_index DECIMAL(6,4) NOT NULL,
    to_complete_performance_index DECIMAL(6,4) NOT NULL,
    percent_complete_planned DECIMAL(5,2) NOT NULL,
    percent_complete_actual DECIMAL(5,2) NOT NULL,
    projected_margin_amount DECIMAL(15,2) NOT NULL,
    projected_margin_percentage DECIMAL(5,2) NOT NULL,
    health_status VARCHAR(20) NOT NULL,
    notes TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT uk_profitability_analysis_project_date UNIQUE (project_id, analysis_date),
    CONSTRAINT chk_profitability_amounts CHECK (
        budget_at_completion >= 0 AND actual_cost_to_date >= 0 AND
        earned_value >= 0 AND planned_value >= 0 AND
        estimate_at_completion >= 0 AND estimate_to_complete >= 0
    ),
    CONSTRAINT chk_profitability_percentages CHECK (
        percent_complete_planned >= 0 AND percent_complete_planned <= 100 AND
        percent_complete_actual >= 0 AND percent_complete_actual <= 100
    ),
    CONSTRAINT chk_profitability_health CHECK (health_status IN (
        'excellent',    -- CPI > 1.1, SPI > 1.05
        'good',         -- CPI 1.0-1.1, SPI 0.95-1.05
        'warning',      -- CPI 0.9-1.0, SPI 0.85-0.95
        'critical',     -- CPI < 0.9, SPI < 0.85
        'completed'     -- Proyecto terminado
    ))
);

CREATE INDEX idx_profitability_analysis_tenant ON budgets.profitability_analysis(tenant_id);
CREATE INDEX idx_profitability_analysis_project ON budgets.profitability_analysis(project_id);
CREATE INDEX idx_profitability_analysis_budget ON budgets.profitability_analysis(budget_id);
CREATE INDEX idx_profitability_analysis_date ON budgets.profitability_analysis(analysis_date DESC);
CREATE INDEX idx_profitability_analysis_health ON budgets.profitability_analysis(health_status);

-- RLS
ALTER TABLE budgets.profitability_analysis ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON budgets.profitability_analysis
    FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

Vistas Materializadas

mv_concept_explosion

Explosion de insumos por concepto compuesto (BOM - Bill of Materials).

CREATE MATERIALIZED VIEW budgets.mv_concept_explosion AS
WITH RECURSIVE explosion AS (
    -- Nivel 0: Conceptos raiz
    SELECT
        cc.id as root_concept_id,
        cc.id as concept_id,
        cc.code,
        cc.name,
        cc.concept_type,
        cc.unit,
        cc.unit_price,
        1.0::DECIMAL as quantity_multiplier,
        0 as level,
        ARRAY[cc.id] as path
    FROM budgets.concept_catalog cc
    WHERE cc.concept_type = 'composite'
        AND cc.status = 'active'

    UNION ALL

    -- Niveles siguientes: Explotar components
    SELECT
        e.root_concept_id,
        comp_concept.id as concept_id,
        comp_concept.code,
        comp_concept.name,
        comp_concept.concept_type,
        comp_concept.unit,
        comp_concept.unit_price,
        e.quantity_multiplier * (component->>'quantity')::DECIMAL as quantity_multiplier,
        e.level + 1,
        e.path || comp_concept.id
    FROM explosion e
    CROSS JOIN LATERAL jsonb_array_elements(
        (SELECT components FROM budgets.concept_catalog WHERE id = e.concept_id)
    ) as component
    JOIN budgets.concept_catalog comp_concept ON comp_concept.id = (component->>'conceptId')::UUID
    WHERE e.level < 10  -- Prevenir ciclos infinitos
        AND NOT comp_concept.id = ANY(e.path)  -- Prevenir ciclos
)
SELECT
    root_concept_id,
    concept_id,
    code,
    name,
    concept_type,
    unit,
    unit_price,
    SUM(quantity_multiplier) as total_quantity,
    SUM(quantity_multiplier * unit_price) as total_cost,
    level,
    COUNT(*) as occurrences
FROM explosion
WHERE level > 0  -- Solo componentes, no raiz
GROUP BY root_concept_id, concept_id, code, name, concept_type, unit, unit_price, level
ORDER BY root_concept_id, level, code;

CREATE UNIQUE INDEX idx_mv_concept_explosion_pk ON budgets.mv_concept_explosion(
    root_concept_id, concept_id, level
);
CREATE INDEX idx_mv_concept_explosion_root ON budgets.mv_concept_explosion(root_concept_id);
CREATE INDEX idx_mv_concept_explosion_type ON budgets.mv_concept_explosion(concept_type);

-- Refrescar periodicamente
CREATE OR REPLACE FUNCTION budgets.refresh_concept_explosion()
RETURNS void AS $$
BEGIN
    REFRESH MATERIALIZED VIEW CONCURRENTLY budgets.mv_concept_explosion;
END;
$$ LANGUAGE plpgsql;

mv_budget_summary

Resumen de presupuestos por proyecto.

CREATE MATERIALIZED VIEW budgets.mv_budget_summary AS
SELECT
    b.id as budget_id,
    b.tenant_id,
    b.project_id,
    b.budget_number,
    b.budget_type,
    b.name,
    b.status,
    b.total_amount,
    b.total_cost,
    b.margin_percentage,

    -- Costos reales
    COALESCE(SUM(ac.total_cost_base), 0) as actual_cost_total,
    COALESCE(COUNT(DISTINCT ac.id), 0) as actual_cost_records,

    -- Desviaciones
    b.total_cost - COALESCE(SUM(ac.total_cost_base), 0) as cost_variance,
    CASE
        WHEN b.total_cost > 0 THEN
            ((b.total_cost - COALESCE(SUM(ac.total_cost_base), 0)) / b.total_cost * 100)
        ELSE 0
    END as cost_variance_percentage,

    -- Rentabilidad proyectada
    b.total_amount - COALESCE(SUM(ac.total_cost_base), 0) as projected_margin,
    CASE
        WHEN b.total_amount > 0 THEN
            ((b.total_amount - COALESCE(SUM(ac.total_cost_base), 0)) / b.total_amount * 100)
        ELSE 0
    END as projected_margin_percentage,

    -- Metadata
    b.created_at,
    b.updated_at,
    NOW() as refreshed_at

FROM budgets.budgets b
LEFT JOIN budgets.actual_costs ac ON ac.project_id = b.project_id
WHERE b.status IN ('approved', 'active', 'completed')
GROUP BY b.id, b.tenant_id, b.project_id, b.budget_number, b.budget_type,
         b.name, b.status, b.total_amount, b.total_cost, b.margin_percentage,
         b.created_at, b.updated_at;

CREATE UNIQUE INDEX idx_mv_budget_summary_pk ON budgets.mv_budget_summary(budget_id);
CREATE INDEX idx_mv_budget_summary_tenant ON budgets.mv_budget_summary(tenant_id);
CREATE INDEX idx_mv_budget_summary_project ON budgets.mv_budget_summary(project_id);
CREATE INDEX idx_mv_budget_summary_status ON budgets.mv_budget_summary(status);

Funciones de Utilidad

1. Calcular APU (Analisis de Precio Unitario)

Calcula el precio unitario de un concepto compuesto.

CREATE OR REPLACE FUNCTION budgets.calculate_apu(
    p_concept_id UUID
) RETURNS TABLE (
    direct_cost DECIMAL(12,2),
    indirect_cost DECIMAL(12,2),
    financing_cost DECIMAL(12,2),
    profit_cost DECIMAL(12,2),
    additional_cost DECIMAL(12,2),
    unit_price DECIMAL(12,2),
    unit_price_with_vat DECIMAL(12,2)
) AS $$
DECLARE
    v_concept RECORD;
    v_material_cost DECIMAL(12,2) := 0;
    v_labor_cost DECIMAL(12,2) := 0;
    v_equipment_cost DECIMAL(12,2) := 0;
    v_direct DECIMAL(12,2);
    v_indirect DECIMAL(12,2);
    v_financing DECIMAL(12,2);
    v_profit DECIMAL(12,2);
    v_additional DECIMAL(12,2);
    v_unit_price DECIMAL(12,2);
    v_component JSONB;
    v_crew JSONB;
BEGIN
    -- Obtener concepto
    SELECT * INTO v_concept
    FROM budgets.concept_catalog
    WHERE id = p_concept_id;

    IF NOT FOUND THEN
        RAISE EXCEPTION 'Concept not found: %', p_concept_id;
    END IF;

    IF v_concept.concept_type != 'composite' THEN
        -- Concepto simple, retornar precio base
        RETURN QUERY SELECT
            v_concept.base_price,
            0::DECIMAL(12,2),
            0::DECIMAL(12,2),
            0::DECIMAL(12,2),
            0::DECIMAL(12,2),
            v_concept.base_price,
            v_concept.base_price * 1.16;
        RETURN;
    END IF;

    -- Calcular costo de materiales y equipos
    IF v_concept.components IS NOT NULL THEN
        FOR v_component IN SELECT * FROM jsonb_array_elements(v_concept.components)
        LOOP
            DECLARE
                v_comp_price DECIMAL(12,2);
                v_comp_type VARCHAR(20);
            BEGIN
                SELECT unit_price, concept_type INTO v_comp_price, v_comp_type
                FROM budgets.concept_catalog
                WHERE id = (v_component->>'conceptId')::UUID;

                IF v_comp_type = 'material' THEN
                    v_material_cost := v_material_cost +
                        (v_component->>'quantity')::DECIMAL * COALESCE(v_comp_price, 0);
                ELSIF v_comp_type = 'equipment' THEN
                    v_equipment_cost := v_equipment_cost +
                        (v_component->>'quantity')::DECIMAL * COALESCE(v_comp_price, 0);
                END IF;
            END;
        END LOOP;
    END IF;

    -- Calcular costo de mano de obra con FSR
    IF v_concept.labor_crew IS NOT NULL THEN
        FOR v_crew IN SELECT * FROM jsonb_array_elements(v_concept.labor_crew)
        LOOP
            v_labor_cost := v_labor_cost +
                (v_crew->>'quantity')::DECIMAL *
                (v_crew->>'dailyWage')::DECIMAL *
                (v_crew->>'fsr')::DECIMAL;
        END LOOP;
    END IF;

    -- Costo directo
    v_direct := v_material_cost + v_labor_cost + v_equipment_cost;

    -- Cargos
    v_indirect := v_direct * (v_concept.indirect_percentage / 100);
    v_financing := v_direct * (v_concept.financing_percentage / 100);
    v_profit := v_direct * (v_concept.profit_percentage / 100);
    v_additional := v_direct * (v_concept.additional_charges / 100);

    -- Precio unitario sin IVA
    v_unit_price := v_direct + v_indirect + v_financing + v_profit + v_additional;

    RETURN QUERY SELECT
        v_direct,
        v_indirect,
        v_financing,
        v_profit,
        v_additional,
        v_unit_price,
        v_unit_price * 1.16;
END;
$$ LANGUAGE plpgsql STABLE;

2. Calcular Desviaciones

Calcula desviaciones de costo para una partida presupuestal.

CREATE OR REPLACE FUNCTION budgets.calculate_variances(
    p_budget_item_id UUID,
    p_analysis_date DATE DEFAULT CURRENT_DATE
) RETURNS UUID AS $$
DECLARE
    v_budget_item RECORD;
    v_actual_qty DECIMAL(12,4);
    v_actual_price DECIMAL(12,2);
    v_actual_total DECIMAL(15,2);
    v_price_var DECIMAL(15,2);
    v_qty_var DECIMAL(15,2);
    v_mixed_var DECIMAL(15,2);
    v_total_var DECIMAL(15,2);
    v_var_pct DECIMAL(6,2);
    v_category VARCHAR(20);
    v_variance_id UUID;
BEGIN
    -- Obtener partida presupuestal
    SELECT * INTO v_budget_item
    FROM budgets.budget_items
    WHERE id = p_budget_item_id;

    IF NOT FOUND THEN
        RAISE EXCEPTION 'Budget item not found: %', p_budget_item_id;
    END IF;

    -- Obtener costos reales acumulados
    SELECT
        COALESCE(SUM(quantity), 0),
        CASE WHEN SUM(quantity) > 0
            THEN SUM(total_cost) / SUM(quantity)
            ELSE 0
        END,
        COALESCE(SUM(total_cost_base), 0)
    INTO v_actual_qty, v_actual_price, v_actual_total
    FROM budgets.actual_costs
    WHERE budget_item_id = p_budget_item_id
        AND cost_date <= p_analysis_date;

    -- Calcular desviaciones
    -- Desviacion en precio: (Pr - Pp) × Qr
    v_price_var := (v_actual_price - v_budget_item.unit_cost) * v_actual_qty;

    -- Desviacion en cantidad: (Qr - Qp) × Pp
    v_qty_var := (v_actual_qty - v_budget_item.quantity) * v_budget_item.unit_cost;

    -- Desviacion mixta: (Pr - Pp) × (Qr - Qp)
    v_mixed_var := (v_actual_price - v_budget_item.unit_cost) *
                   (v_actual_qty - v_budget_item.quantity);

    -- Desviacion total
    v_total_var := v_actual_total - v_budget_item.total_cost;

    -- Porcentaje de desviacion
    IF v_budget_item.total_cost > 0 THEN
        v_var_pct := (v_total_var / v_budget_item.total_cost) * 100;
    ELSE
        v_var_pct := 0;
    END IF;

    -- Categorizar desviacion
    IF ABS(v_var_pct) < 3 THEN
        v_category := 'within_tolerance';
    ELSIF ABS(v_var_pct) < 5 THEN
        v_category := 'minor';
    ELSIF ABS(v_var_pct) < 15 THEN
        v_category := 'moderate';
    ELSIF ABS(v_var_pct) < 25 THEN
        v_category := 'major';
    ELSE
        v_category := 'critical';
    END IF;

    -- Insertar o actualizar registro de desviacion
    INSERT INTO budgets.cost_variances (
        tenant_id,
        project_id,
        budget_item_id,
        analysis_date,
        budgeted_quantity,
        actual_quantity,
        budgeted_unit_price,
        actual_unit_price,
        budgeted_total,
        actual_total,
        price_variance,
        quantity_variance,
        mixed_variance,
        total_variance,
        variance_percentage,
        variance_category
    ) VALUES (
        v_budget_item.budget_id,  -- Asumir mismo tenant que budget
        (SELECT project_id FROM budgets.budgets WHERE id = (SELECT budget_id FROM budgets.budget_items WHERE id = p_budget_item_id)),
        p_budget_item_id,
        p_analysis_date,
        v_budget_item.quantity,
        v_actual_qty,
        v_budget_item.unit_cost,
        v_actual_price,
        v_budget_item.total_cost,
        v_actual_total,
        v_price_var,
        v_qty_var,
        v_mixed_var,
        v_total_var,
        v_var_pct,
        v_category
    )
    ON CONFLICT (budget_item_id, analysis_date) DO UPDATE SET
        actual_quantity = EXCLUDED.actual_quantity,
        actual_unit_price = EXCLUDED.actual_unit_price,
        actual_total = EXCLUDED.actual_total,
        price_variance = EXCLUDED.price_variance,
        quantity_variance = EXCLUDED.quantity_variance,
        mixed_variance = EXCLUDED.mixed_variance,
        total_variance = EXCLUDED.total_variance,
        variance_percentage = EXCLUDED.variance_percentage,
        variance_category = EXCLUDED.variance_category
    RETURNING id INTO v_variance_id;

    RETURN v_variance_id;
END;
$$ LANGUAGE plpgsql;

3. Calcular EAC (Estimate At Completion)

Proyecta el costo final del proyecto usando Earned Value Management.

CREATE OR REPLACE FUNCTION budgets.calculate_eac(
    p_project_id UUID,
    p_budget_id UUID,
    p_analysis_date DATE DEFAULT CURRENT_DATE
) RETURNS TABLE (
    eac DECIMAL(15,2),
    etc DECIMAL(15,2),
    vac DECIMAL(15,2),
    cpi DECIMAL(6,4),
    spi DECIMAL(6,4),
    tcpi DECIMAL(6,4)
) AS $$
DECLARE
    v_bac DECIMAL(15,2);      -- Budget At Completion
    v_ac DECIMAL(15,2);       -- Actual Cost
    v_ev DECIMAL(15,2);       -- Earned Value
    v_pv DECIMAL(15,2);       -- Planned Value
    v_cpi DECIMAL(6,4);       -- Cost Performance Index
    v_spi DECIMAL(6,4);       -- Schedule Performance Index
    v_eac DECIMAL(15,2);      -- Estimate At Completion
    v_etc DECIMAL(15,2);      -- Estimate To Complete
    v_vac DECIMAL(15,2);      -- Variance At Completion
    v_tcpi DECIMAL(6,4);      -- To Complete Performance Index
BEGIN
    -- BAC: Presupuesto total aprobado
    SELECT total_cost INTO v_bac
    FROM budgets.budgets
    WHERE id = p_budget_id;

    IF NOT FOUND OR v_bac IS NULL THEN
        RAISE EXCEPTION 'Budget not found or has no total cost';
    END IF;

    -- AC: Costo real acumulado
    SELECT COALESCE(SUM(total_cost_base), 0) INTO v_ac
    FROM budgets.actual_costs
    WHERE project_id = p_project_id
        AND cost_date <= p_analysis_date;

    -- EV: Valor ganado (basado en % avance real de partidas completadas)
    SELECT COALESCE(SUM(
        bi.total_cost *
        COALESCE((bi.notes::JSONB->>'percentComplete')::DECIMAL / 100, 0)
    ), 0) INTO v_ev
    FROM budgets.budget_items bi
    WHERE bi.budget_id = p_budget_id
        AND bi.item_type = 'item';

    -- PV: Valor planeado (basado en curva S o % tiempo transcurrido)
    -- Simplificado: usar % tiempo transcurrido del proyecto
    SELECT v_bac *
        CASE
            WHEN (SELECT end_date FROM projects WHERE id = p_project_id) > p_analysis_date
            THEN EXTRACT(EPOCH FROM p_analysis_date - (SELECT start_date FROM projects WHERE id = p_project_id))::DECIMAL /
                 EXTRACT(EPOCH FROM (SELECT end_date - start_date FROM projects WHERE id = p_project_id))::DECIMAL
            ELSE 1.0
        END
    INTO v_pv;

    -- CPI: Cost Performance Index = EV / AC
    IF v_ac > 0 THEN
        v_cpi := v_ev / v_ac;
    ELSE
        v_cpi := 1.0;
    END IF;

    -- SPI: Schedule Performance Index = EV / PV
    IF v_pv > 0 THEN
        v_spi := v_ev / v_pv;
    ELSE
        v_spi := 1.0;
    END IF;

    -- EAC: Estimate At Completion
    -- Metodo: EAC = BAC / CPI (asume tendencia actual continuara)
    IF v_cpi > 0 THEN
        v_eac := v_bac / v_cpi;
    ELSE
        v_eac := v_bac;
    END IF;

    -- ETC: Estimate To Complete = EAC - AC
    v_etc := v_eac - v_ac;

    -- VAC: Variance At Completion = BAC - EAC
    v_vac := v_bac - v_eac;

    -- TCPI: To Complete Performance Index
    -- TCPI = (BAC - EV) / (BAC - AC)
    IF (v_bac - v_ac) > 0 THEN
        v_tcpi := (v_bac - v_ev) / (v_bac - v_ac);
    ELSE
        v_tcpi := 1.0;
    END IF;

    RETURN QUERY SELECT v_eac, v_etc, v_vac, v_cpi, v_spi, v_tcpi;
END;
$$ LANGUAGE plpgsql STABLE;

4. Actualizar Precio Unitario Concepto

Recalcula el precio unitario de un concepto compuesto.

CREATE OR REPLACE FUNCTION budgets.update_concept_unit_price(
    p_concept_id UUID
) RETURNS BOOLEAN AS $$
DECLARE
    v_result RECORD;
BEGIN
    -- Calcular APU
    SELECT * INTO v_result
    FROM budgets.calculate_apu(p_concept_id);

    -- Actualizar concepto
    UPDATE budgets.concept_catalog SET
        direct_cost = v_result.direct_cost,
        unit_price = v_result.unit_price,
        unit_price_with_vat = v_result.unit_price_with_vat,
        updated_at = NOW()
    WHERE id = p_concept_id;

    RETURN FOUND;
END;
$$ LANGUAGE plpgsql;

5. Recalcular Totales Presupuesto

Recalcula los totales de un presupuesto sumando sus partidas.

CREATE OR REPLACE FUNCTION budgets.recalculate_budget_totals(
    p_budget_id UUID,
    p_version_id UUID DEFAULT NULL
) RETURNS BOOLEAN AS $$
DECLARE
    v_version_id UUID;
    v_total_price DECIMAL(15,2);
    v_total_cost DECIMAL(15,2);
    v_margin_amount DECIMAL(15,2);
    v_margin_pct DECIMAL(5,2);
BEGIN
    -- Si no se especifica version, usar la version actual
    IF p_version_id IS NULL THEN
        SELECT id INTO v_version_id
        FROM budgets.budget_versions
        WHERE budget_id = p_budget_id
            AND is_current = true
        LIMIT 1;
    ELSE
        v_version_id := p_version_id;
    END IF;

    IF v_version_id IS NULL THEN
        RAISE EXCEPTION 'No current version found for budget %', p_budget_id;
    END IF;

    -- Sumar partidas de nivel 1 (items ejecutables)
    SELECT
        COALESCE(SUM(total_price), 0),
        COALESCE(SUM(total_cost), 0)
    INTO v_total_price, v_total_cost
    FROM budgets.budget_items
    WHERE budget_version_id = v_version_id
        AND item_type = 'item';

    -- Calcular margen
    v_margin_amount := v_total_price - v_total_cost;
    IF v_total_price > 0 THEN
        v_margin_pct := (v_margin_amount / v_total_price) * 100;
    ELSE
        v_margin_pct := 0;
    END IF;

    -- Actualizar presupuesto
    UPDATE budgets.budgets SET
        total_amount = v_total_price,
        total_cost = v_total_cost,
        margin_amount = v_margin_amount,
        margin_percentage = v_margin_pct,
        updated_at = NOW()
    WHERE id = p_budget_id;

    -- Actualizar version
    UPDATE budgets.budget_versions SET
        total_amount = v_total_price,
        total_cost = v_total_cost
    WHERE id = v_version_id;

    RETURN true;
END;
$$ LANGUAGE plpgsql;

Triggers

1. Actualizar Timestamp

CREATE OR REPLACE FUNCTION budgets.update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_concept_catalog_timestamp
    BEFORE UPDATE ON budgets.concept_catalog
    FOR EACH ROW EXECUTE FUNCTION budgets.update_timestamp();

CREATE TRIGGER trg_budgets_timestamp
    BEFORE UPDATE ON budgets.budgets
    FOR EACH ROW EXECUTE FUNCTION budgets.update_timestamp();

CREATE TRIGGER trg_budget_items_timestamp
    BEFORE UPDATE ON budgets.budget_items
    FOR EACH ROW EXECUTE FUNCTION budgets.update_timestamp();

2. Crear Historial de Precios

CREATE OR REPLACE FUNCTION budgets.create_price_history()
RETURNS TRIGGER AS $$
DECLARE
    v_variation DECIMAL(6,2);
BEGIN
    -- Solo si cambio el precio base
    IF (NEW.base_price IS DISTINCT FROM OLD.base_price) THEN
        -- Calcular variacion porcentual
        IF OLD.base_price IS NOT NULL AND OLD.base_price > 0 THEN
            v_variation := ((NEW.base_price - OLD.base_price) / OLD.base_price) * 100;
        ELSE
            v_variation := NULL;
        END IF;

        -- Cerrar registro anterior
        UPDATE budgets.concept_price_history
        SET valid_until = CURRENT_DATE - INTERVAL '1 day'
        WHERE concept_id = NEW.id
            AND valid_until IS NULL;

        -- Crear nuevo registro
        INSERT INTO budgets.concept_price_history (
            concept_id,
            price,
            valid_from,
            variation_percentage,
            created_by
        ) VALUES (
            NEW.id,
            NEW.base_price,
            CURRENT_DATE,
            v_variation,
            NEW.updated_by
        );
    END IF;

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_concept_price_history
    AFTER UPDATE ON budgets.concept_catalog
    FOR EACH ROW
    WHEN (OLD.base_price IS DISTINCT FROM NEW.base_price)
    EXECUTE FUNCTION budgets.create_price_history();

3. Recalcular APU al Modificar Componentes

CREATE OR REPLACE FUNCTION budgets.recalculate_apu_on_change()
RETURNS TRIGGER AS $$
BEGIN
    -- Si cambiaron los componentes, cuadrilla o porcentajes, recalcular
    IF (NEW.components IS DISTINCT FROM OLD.components OR
        NEW.labor_crew IS DISTINCT FROM OLD.labor_crew OR
        NEW.indirect_percentage IS DISTINCT FROM OLD.indirect_percentage OR
        NEW.financing_percentage IS DISTINCT FROM OLD.financing_percentage OR
        NEW.profit_percentage IS DISTINCT FROM OLD.profit_percentage OR
        NEW.additional_charges IS DISTINCT FROM OLD.additional_charges) AND
        NEW.concept_type = 'composite' THEN

        PERFORM budgets.update_concept_unit_price(NEW.id);
    END IF;

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_concept_recalculate_apu
    AFTER UPDATE ON budgets.concept_catalog
    FOR EACH ROW
    EXECUTE FUNCTION budgets.recalculate_apu_on_change();

4. Actualizar Path Jerarquico

CREATE OR REPLACE FUNCTION budgets.update_budget_item_path()
RETURNS TRIGGER AS $$
BEGIN
    IF NEW.parent_item_id IS NULL THEN
        NEW.path := NEW.id::text::ltree;
    ELSE
        SELECT path || NEW.id::text
        INTO NEW.path
        FROM budgets.budget_items
        WHERE id = NEW.parent_item_id;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_budget_item_path
    BEFORE INSERT OR UPDATE OF parent_item_id ON budgets.budget_items
    FOR EACH ROW
    EXECUTE FUNCTION budgets.update_budget_item_path();

5. Calcular Total de Partida

CREATE OR REPLACE FUNCTION budgets.calculate_budget_item_total()
RETURNS TRIGGER AS $$
BEGIN
    -- Calcular total precio
    NEW.total_price := NEW.quantity * NEW.unit_price;

    -- Calcular total costo
    NEW.total_cost := NEW.quantity * NEW.unit_cost;

    -- Calcular margen
    IF NEW.total_price > 0 THEN
        NEW.margin_percentage := ((NEW.total_price - NEW.total_cost) / NEW.total_price) * 100;
    ELSE
        NEW.margin_percentage := 0;
    END IF;

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_budget_item_calculate_total
    BEFORE INSERT OR UPDATE OF quantity, unit_price, unit_cost ON budgets.budget_items
    FOR EACH ROW
    EXECUTE FUNCTION budgets.calculate_budget_item_total();

Seed Data

Regiones de Mexico

-- Seed data para regiones (opcional, por tenant)
-- Este es un ejemplo que puede ejecutarse al crear un nuevo tenant

INSERT INTO budgets.regions (tenant_id, code, name, description) VALUES
(current_setting('app.current_tenant_id')::uuid, 'NORTE', 'Region Norte', 'Chihuahua, Sonora, Coahuila'),
(current_setting('app.current_tenant_id')::uuid, 'CENTRO', 'Region Centro', 'CDMX, EdoMex, Queretaro'),
(current_setting('app.current_tenant_id')::uuid, 'BAJIO', 'Region Bajio', 'Guanajuato, Aguascalientes, San Luis Potosi'),
(current_setting('app.current_tenant_id')::uuid, 'SUR', 'Region Sur', 'Oaxaca, Chiapas, Yucatan');

Historial

Version Fecha Autor Cambios
1.0 2025-12-06 Requirements-Analyst Creacion inicial

Aprobaciones

Rol Nombre Fecha Firma
DBA - - [ ]
Tech Lead - - [ ]
Architect - - [ ]