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