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

13 KiB

ET-COST-002: Implementación de Presupuestos (Obra, Etapa, Prototipo)

Épica: MAI-003 - Presupuestos y Control de Costos Versión: 1.0 Fecha: 2025-11-17


1. Schemas SQL

-- Tabla: budgets (Presupuestos maestros)
CREATE TABLE budgets.budgets (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

  -- Multi-tenant discriminator (tenant = constructora)
  -- Each budget belongs to a constructora (see GLOSARIO.md)
  constructora_id UUID NOT NULL REFERENCES public.constructoras(id) ON DELETE CASCADE,
  project_id UUID REFERENCES projects.projects(id) ON DELETE CASCADE,
  stage_id UUID REFERENCES projects.stages(id) ON DELETE CASCADE,
  prototype_id UUID REFERENCES projects.housing_prototypes(id) ON DELETE CASCADE,

  code VARCHAR(20) NOT NULL UNIQUE,
  name VARCHAR(255) NOT NULL,
  budget_type VARCHAR(20) NOT NULL CHECK (budget_type IN ('project', 'stage', 'prototype')),

  version INTEGER DEFAULT 1,
  is_baseline BOOLEAN DEFAULT false,
  status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'approved', 'closed')),

  -- Alcance
  housing_units_count INTEGER,
  total_built_area DECIMAL(12,2),
  total_land_area DECIMAL(12,2),

  -- Montos
  direct_cost DECIMAL(15,2) DEFAULT 0,
  indirect_percentage DECIMAL(5,2) DEFAULT 12.00,
  indirect_amount DECIMAL(15,2) DEFAULT 0,
  financing_percentage DECIMAL(5,2) DEFAULT 3.00,
  financing_amount DECIMAL(15,2) DEFAULT 0,
  profit_percentage DECIMAL(5,2) DEFAULT 10.00,
  profit_amount DECIMAL(15,2) DEFAULT 0,
  additional_charges DECIMAL(15,2) DEFAULT 0,
  total_cost DECIMAL(15,2) DEFAULT 0,

  -- Precio y rentabilidad
  sale_price DECIMAL(15,2),
  gross_margin DECIMAL(15,2),
  margin_percentage DECIMAL(5,2),
  roi DECIMAL(5,2),

  -- Indicadores
  cost_per_sqm DECIMAL(10,2),
  cost_per_unit DECIMAL(12,2),

  -- Auditoría
  approved_by UUID,
  approved_at TIMESTAMP,
  created_by UUID NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

  CONSTRAINT check_one_parent CHECK (
    (project_id IS NOT NULL AND stage_id IS NULL AND prototype_id IS NULL) OR
    (project_id IS NULL AND stage_id IS NOT NULL AND prototype_id IS NULL) OR
    (project_id IS NULL AND stage_id IS NULL AND prototype_id IS NOT NULL)
  )
);

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);


-- Tabla: budget_items (Partidas del presupuesto)
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,
  parent_item_id UUID REFERENCES budgets.budget_items(id),

  level INTEGER NOT NULL CHECK (level IN (1, 2, 3)), -- 1=División, 2=Grupo, 3=Concepto
  sort_order INTEGER DEFAULT 0,

  -- Concepto (si es partida individual)
  concept_id UUID REFERENCES budgets.concept_catalog(id),

  code VARCHAR(20) NOT NULL,
  name VARCHAR(255) NOT NULL,
  unit VARCHAR(20),
  quantity DECIMAL(12,4),
  unit_price DECIMAL(12,2),
  amount DECIMAL(15,2),

  -- Generadores (para presupuestos de prototipo)
  has_generator BOOLEAN DEFAULT false,
  generator_formula TEXT,
  generator_inputs JSONB,

  notes TEXT,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_budget_items_budget ON budgets.budget_items(budget_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);


-- Tabla: budget_versions (Historial de versiones)
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 INTEGER NOT NULL,
  version_type VARCHAR(30) CHECK (version_type IN ('baseline', 'price_adjustment', 'scope_change', 'additional_volume')),
  previous_version_id UUID REFERENCES budgets.budget_versions(id),

  total_cost DECIMAL(15,2),
  variation_amount DECIMAL(15,2),
  variation_percentage DECIMAL(6,2),

  reason TEXT,
  approved_by UUID,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

  CONSTRAINT unique_budget_version UNIQUE (budget_id, version)
);

2. Triggers

-- Trigger: Calcular totales del presupuesto
CREATE OR REPLACE FUNCTION budgets.calculate_budget_totals()
RETURNS TRIGGER AS $$
BEGIN
  -- Sumar partidas de nivel 3 (conceptos individuales)
  UPDATE budgets.budgets b
  SET
    direct_cost = COALESCE((
      SELECT SUM(amount)
      FROM budgets.budget_items
      WHERE budget_id = NEW.budget_id AND level = 3
    ), 0),
    updated_at = CURRENT_TIMESTAMP
  WHERE id = NEW.budget_id;

  -- Calcular costos indirectos, financiamiento, etc.
  UPDATE budgets.budgets
  SET
    indirect_amount = direct_cost * (indirect_percentage / 100),
    financing_amount = (direct_cost + indirect_amount) * (financing_percentage / 100),
    profit_amount = (direct_cost + indirect_amount + financing_amount) * (profit_percentage / 100),
    total_cost = direct_cost + indirect_amount + financing_amount + profit_amount + additional_charges,
    cost_per_sqm = CASE
      WHEN total_built_area > 0 THEN total_cost / total_built_area
      ELSE 0
    END,
    cost_per_unit = CASE
      WHEN housing_units_count > 0 THEN total_cost / housing_units_count
      ELSE 0
    END,
    gross_margin = CASE
      WHEN sale_price IS NOT NULL THEN sale_price - total_cost
      ELSE NULL
    END,
    margin_percentage = CASE
      WHEN sale_price IS NOT NULL AND sale_price > 0 THEN ((sale_price - total_cost) / sale_price) * 100
      ELSE NULL
    END
  WHERE id = NEW.budget_id;

  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trigger_budget_items_calculate
  AFTER INSERT OR UPDATE OR DELETE ON budgets.budget_items
  FOR EACH ROW
  EXECUTE FUNCTION budgets.calculate_budget_totals();

3. TypeORM Entities (Simplificado)

// src/budgets/entities/budget.entity.ts
@Entity('budgets', { schema: 'budgets' })
export class Budget {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'constructora_id' })
  constructoraId: string;

  @Column({ name: 'project_id', nullable: true })
  projectId: string;

  @Column({ name: 'stage_id', nullable: true })
  stageId: string;

  @Column({ name: 'prototype_id', nullable: true })
  prototypeId: string;

  @Column({ length: 20, unique: true })
  code: string;

  @Column({ length: 255 })
  name: string;

  @Column({ name: 'budget_type' })
  budgetType: 'project' | 'stage' | 'prototype';

  @Column({ type: 'integer', default: 1 })
  version: number;

  @Column({ name: 'is_baseline', default: false })
  isBaseline: boolean;

  @Column({ default: 'draft' })
  status: 'draft' | 'active' | 'approved' | 'closed';

  // Alcance
  @Column({ name: 'housing_units_count', nullable: true })
  housingUnitsCount: number;

  @Column({ name: 'total_built_area', type: 'decimal', precision: 12, scale: 2, nullable: true })
  totalBuiltArea: number;

  // Montos (calculados por trigger)
  @Column({ name: 'direct_cost', type: 'decimal', precision: 15, scale: 2, default: 0 })
  directCost: number;

  @Column({ name: 'total_cost', type: 'decimal', precision: 15, scale: 2, default: 0 })
  totalCost: number;

  @Column({ name: 'sale_price', type: 'decimal', precision: 15, scale: 2, nullable: true })
  salePrice: number;

  @Column({ name: 'margin_percentage', type: 'decimal', precision: 5, scale: 2, nullable: true })
  marginPercentage: number;

  // Relación con partidas
  @OneToMany(() => BudgetItem, (item) => item.budget, { cascade: true })
  items: BudgetItem[];

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;
}

// src/budgets/entities/budget-item.entity.ts
@Entity('budget_items', { schema: 'budgets' })
export class BudgetItem {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'budget_id' })
  budgetId: string;

  @ManyToOne(() => Budget, (budget) => budget.items)
  @JoinColumn({ name: 'budget_id' })
  budget: Budget;

  @Column({ name: 'concept_id', nullable: true })
  conceptId: string;

  @Column({ length: 20 })
  code: string;

  @Column({ length: 255 })
  name: string;

  @Column({ length: 20, nullable: true })
  unit: string;

  @Column({ type: 'decimal', precision: 12, scale: 4, nullable: true })
  quantity: number;

  @Column({ name: 'unit_price', type: 'decimal', precision: 12, scale: 2, nullable: true })
  unitPrice: number;

  @Column({ type: 'decimal', precision: 15, scale: 2, nullable: true })
  amount: number;

  @Column({ type: 'integer' })
  level: 1 | 2 | 3;
}

4. Service (Métodos Clave)

// src/budgets/services/budget.service.ts
@Injectable()
export class BudgetService {
  async createFromPrototype(prototypeId: string, constructoraId: string): Promise<Budget> {
    // 1. Obtener prototipo con sus características
    const prototype = await this.prototypeService.findOne(prototypeId);

    // 2. Crear presupuesto base
    const budget = this.budgetRepo.create({
      constructoraId,
      prototypeId,
      budgetType: 'prototype',
      code: await this.generateCode('prototype', constructoraId),
      name: `Presupuesto ${prototype.name}`,
      housingUnitsCount: 1,
      totalBuiltArea: prototype.totalBuiltArea,
    });
    await this.budgetRepo.save(budget);

    // 3. Cargar plantilla de conceptos base (200 partidas típicas)
    const templateItems = await this.loadTemplate('vivienda-unifamiliar');

    // 4. Ejecutar generadores automáticos
    const items = [];
    for (const template of templateItems) {
      if (template.hasGenerator) {
        const quantity = this.executeGenerator(template.generatorFormula, prototype);
        items.push({
          budgetId: budget.id,
          conceptId: template.conceptId,
          code: template.code,
          name: template.name,
          unit: template.unit,
          quantity,
          unitPrice: template.unitPrice,
          amount: quantity * template.unitPrice,
          level: 3,
        });
      }
    }

    await this.budgetItemRepo.save(items);

    return budget;
  }

  private executeGenerator(formula: string, prototype: any): number {
    // Ejemplo: formula = "builtArea * 1.0"
    const context = {
      builtArea: prototype.totalBuiltArea,
      landArea: prototype.landAreaRequired,
      bedrooms: prototype.bedrooms,
      bathrooms: prototype.bathrooms,
      // ... más variables
    };

    // Evaluar fórmula de forma segura (usar librería math.js o similar)
    return eval(formula.replace(/(\w+)/g, (match) => context[match] || match));
  }

  async createVersion(budgetId: string, versionType: string, reason: string): Promise<void> {
    const budget = await this.budgetRepo.findOne({ where: { id: budgetId } });

    const newVersion = budget.version + 1;

    // Crear registro de versión
    await this.versionRepo.save({
      budgetId,
      version: newVersion,
      versionType,
      totalCost: budget.totalCost,
      reason,
    });

    budget.version = newVersion;
    await this.budgetRepo.save(budget);
  }

  async compareVersions(budgetId: string, v1: number, v2: number): Promise<any> {
    // Obtener datos de ambas versiones y comparar
    const version1 = await this.versionRepo.findOne({ where: { budgetId, version: v1 } });
    const version2 = await this.versionRepo.findOne({ where: { budgetId, version: v2 } });

    return {
      version1: { version: v1, totalCost: version1.totalCost },
      version2: { version: v2, totalCost: version2.totalCost },
      variance: {
        amount: version2.totalCost - version1.totalCost,
        percentage: ((version2.totalCost - version1.totalCost) / version1.totalCost) * 100,
      },
    };
  }
}

5. React Components (Simplificado)

// src/pages/Budgets/BudgetDetail.tsx
export function BudgetDetail({ budgetId }: { budgetId: string }) {
  const { budget, loading, fetchBudget } = useBudgetStore();

  useEffect(() => {
    fetchBudget(budgetId);
  }, [budgetId]);

  if (loading) return <Loader />;

  return (
    <div>
      <BudgetHeader budget={budget} />
      <BudgetSummary budget={budget} />
      <BudgetItemsTree items={budget.items} />
      <BudgetAnalysis budget={budget} />
    </div>
  );
}

// src/components/Budget/BudgetSummary.tsx
export function BudgetSummary({ budget }: { budget: Budget }) {
  return (
    <div className="budget-summary">
      <div className="summary-card">
        <h3>Costo Total</h3>
        <p className="amount">${budget.totalCost.toLocaleString()}</p>
      </div>
      <div className="summary-card">
        <h3>Costo Directo</h3>
        <p>${budget.directCost.toLocaleString()}</p>
      </div>
      <div className="summary-card">
        <h3>$/m²</h3>
        <p>${budget.costPerSqm.toLocaleString()}</p>
      </div>
      {budget.salePrice && (
        <div className="summary-card">
          <h3>Margen</h3>
          <p className={budget.marginPercentage >= 10 ? 'positive' : 'negative'}>
            {budget.marginPercentage.toFixed(1)}%
          </p>
        </div>
      )}
    </div>
  );
}

Estado: Ready for Implementation

Nota: Este documento contiene los componentes esenciales. Para detalles completos de DTOs, validaciones y componentes UI, referirse a ET-COST-001 como patrón.