erp-construccion/docs/02-definicion-modulos/MAI-008-estimaciones-facturacion/especificaciones/ET-EST-001-modelo-datos.md

9.4 KiB

ET-EST-001: Modelo de Datos de Estimaciones

ID: ET-EST-001
Módulo: MAI-008
Relacionado con: RF-EST-001, RF-EST-002


📋 Schema de Base de Datos: estimations

Tabla: estimations

CREATE TYPE estimations.estimation_type AS ENUM ('to_client', 'to_subcontractor');
CREATE TYPE estimations.estimation_status AS ENUM (
  'draft', 'submitted', 'reviewed', 'authorized', 'paid', 'rejected', 'cancelled'
);

CREATE TABLE estimations.estimations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  numero VARCHAR(50) UNIQUE NOT NULL,  -- EST-PRJ001-CLI-2025-001
  
  -- Relaciones
  project_id UUID NOT NULL REFERENCES projects.projects(id),
  contract_id UUID NOT NULL REFERENCES contracts.contracts(id),
  client_id UUID REFERENCES clients.clients(id),  -- Si es to_client
  subcontractor_id UUID REFERENCES subcontractors.subcontractors(id),  -- Si es to_subcontractor
  
  -- Tipo y periodo
  estimation_type estimations.estimation_type NOT NULL,
  numero_estimacion INT NOT NULL,  -- 1, 2, 3...
  periodo_inicio DATE NOT NULL,
  periodo_fin DATE NOT NULL,
  
  -- Montos (en centavos para precision)
  monto_bruto BIGINT NOT NULL,
  amortizacion_anticipo BIGINT DEFAULT 0,
  total_retenciones BIGINT DEFAULT 0,
  monto_neto BIGINT NOT NULL,
  
  -- Desglose retenciones
  retencion_fondo_garantia BIGINT DEFAULT 0,
  retencion_isr BIGINT DEFAULT 0,
  retencion_iva BIGINT DEFAULT 0,
  otras_retenciones BIGINT DEFAULT 0,
  
  -- Estado
  status estimations.estimation_status DEFAULT 'draft',
  
  -- Fechas workflow
  created_at TIMESTAMPTZ DEFAULT NOW(),
  submitted_at TIMESTAMPTZ,
  reviewed_at TIMESTAMPTZ,
  authorized_at TIMESTAMPTZ,
  paid_at TIMESTAMPTZ,
  fecha_pago_estimada DATE,
  fecha_pago_programada DATE,  -- Para subcontratistas
  
  -- Usuarios workflow
  created_by UUID NOT NULL REFERENCES auth_management.users(id),
  submitted_by UUID REFERENCES auth_management.users(id),
  reviewed_by UUID REFERENCES auth_management.users(id),
  authorized_by UUID REFERENCES auth_management.users(id),
  
  -- Pagos
  referencia_pago VARCHAR(100),
  comprobante_pago TEXT,
  
  -- Metadata
  observaciones TEXT,
  razon_rechazo TEXT,
  documentos_pdf TEXT[],
  documentos_excel TEXT[],
  
  -- Audit
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_estimations_project ON estimations.estimations(project_id);
CREATE INDEX idx_estimations_contract ON estimations.estimations(contract_id);
CREATE INDEX idx_estimations_status ON estimations.estimations(status);
CREATE INDEX idx_estimations_type ON estimations.estimations(estimation_type);
CREATE INDEX idx_estimations_periodo ON estimations.estimations(periodo_inicio, periodo_fin);
CREATE INDEX idx_estimations_numero ON estimations.estimations(numero);

Tabla: estimation_items

CREATE TABLE estimations.estimation_items (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  estimation_id UUID NOT NULL REFERENCES estimations.estimations(id) ON DELETE CASCADE,
  
  -- Concepto
  concept_catalog_id UUID NOT NULL,  -- Link a catalog de conceptos
  descripcion VARCHAR(500) NOT NULL,
  unidad VARCHAR(50) NOT NULL,
  precio_unitario BIGINT NOT NULL,  -- En centavos
  
  -- Cantidades
  cantidad_contratada DECIMAL(15,4),
  cantidad_estimada_anterior DECIMAL(15,4) DEFAULT 0,
  cantidad_estimada_actual DECIMAL(15,4) NOT NULL,
  cantidad_acumulada DECIMAL(15,4),
  porcentaje_avance DECIMAL(5,2),
  
  -- Importes
  importe_actual BIGINT NOT NULL,
  importe_acumulado BIGINT,
  
  -- Trazabilidad
  avance_obra_id UUID,  -- Link a MAI-005
  verificado_por UUID REFERENCES auth_management.users(id),
  fecha_verificacion TIMESTAMPTZ,
  
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_estimation_items_estimation ON estimations.estimation_items(estimation_id);
CREATE INDEX idx_estimation_items_concept ON estimations.estimation_items(concept_catalog_id);

Tabla: estimation_workflow_history

CREATE TABLE estimations.estimation_workflow_history (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  estimation_id UUID NOT NULL REFERENCES estimations.estimations(id),
  
  from_status estimations.estimation_status,
  to_status estimations.estimation_status NOT NULL,
  
  user_id UUID NOT NULL REFERENCES auth_management.users(id),
  user_role VARCHAR(50) NOT NULL,
  
  timestamp TIMESTAMPTZ DEFAULT NOW(),
  comentario TEXT,
  razon_rechazo TEXT,
  
  tiempo_respuesta INT  -- Minutos desde transición anterior
);

CREATE INDEX idx_workflow_history_estimation ON estimations.estimation_workflow_history(estimation_id);
CREATE INDEX idx_workflow_history_timestamp ON estimations.estimation_workflow_history(timestamp DESC);

🔧 Backend: NestJS Entities

estimation.entity.ts

import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany } from 'typeorm';

export enum EstimationType {
  TO_CLIENT = 'to_client',
  TO_SUBCONTRACTOR = 'to_subcontractor'
}

export enum EstimationStatus {
  DRAFT = 'draft',
  SUBMITTED = 'submitted',
  REVIEWED = 'reviewed',
  AUTHORIZED = 'authorized',
  PAID = 'paid',
  REJECTED = 'rejected',
  CANCELLED = 'cancelled'
}

@Entity({ schema: 'estimations', name: 'estimations' })
export class Estimation {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  numero: string;

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

  @Column({ name: 'contract_id' })
  contractId: string;

  @Column({ name: 'client_id', nullable: true })
  clientId?: string;

  @Column({ name: 'subcontractor_id', nullable: true })
  subcontractorId?: string;

  @Column({ type: 'enum', enum: EstimationType })
  estimationType: EstimationType;

  @Column({ name: 'numero_estimacion' })
  numeroEstimacion: number;

  @Column({ type: 'date' })
  periodoInicio: Date;

  @Column({ type: 'date' })
  periodoFin: Date;

  @Column({ type: 'bigint' })
  montoBruto: number;

  @Column({ type: 'bigint', default: 0 })
  amortizacionAnticipo: number;

  @Column({ type: 'bigint', default: 0 })
  totalRetenciones: number;

  @Column({ type: 'bigint' })
  montoNeto: number;

  @Column({ type: 'bigint', default: 0 })
  retencionFondoGarantia: number;

  @Column({ type: 'enum', enum: EstimationStatus, default: EstimationStatus.DRAFT })
  status: EstimationStatus;

  @Column({ type: 'timestamptz', default: () => 'NOW()' })
  createdAt: Date;

  @Column({ name: 'submitted_at', nullable: true })
  submittedAt?: Date;

  @Column({ name: 'authorized_at', nullable: true })
  authorizedAt?: Date;

  @Column({ name: 'paid_at', nullable: true })
  paidAt?: Date;

  @Column({ name: 'created_by' })
  createdBy: string;

  @Column({ name: 'authorized_by', nullable: true })
  authorizedBy?: string;

  @Column({ nullable: true, type: 'text' })
  observaciones?: string;

  @OneToMany(() => EstimationItem, item => item.estimation, { cascade: true })
  items: EstimationItem[];

  // Métodos de cálculo
  calculateMontoNeto(): number {
    return this.montoBruto - this.amortizacionAnticipo - this.totalRetenciones;
  }
}

@Entity({ schema: 'estimations', name: 'estimation_items' })
export class EstimationItem {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'estimation_id' })
  estimationId: string;

  @ManyToOne(() => Estimation, estimation => estimation.items)
  estimation: Estimation;

  @Column({ name: 'concept_catalog_id' })
  conceptCatalogId: string;

  @Column({ length: 500 })
  descripcion: string;

  @Column({ length: 50 })
  unidad: string;

  @Column({ type: 'bigint' })
  precioUnitario: number;

  @Column({ type: 'decimal', precision: 15, scale: 4 })
  cantidadEstimadaActual: number;

  @Column({ type: 'bigint' })
  importeActual: number;

  @Column({ name: 'avance_obra_id', nullable: true })
  avanceObraId?: string;
}

📊 Funciones SQL Útiles

Función: get_next_estimation_number

CREATE OR REPLACE FUNCTION estimations.get_next_estimation_number(
  p_project_id UUID,
  p_estimation_type estimations.estimation_type,
  p_year INT
)
RETURNS VARCHAR AS $$
DECLARE
  next_number INT;
  project_code VARCHAR;
  type_code VARCHAR;
BEGIN
  -- Obtener siguiente consecutivo
  SELECT COALESCE(MAX(numero_estimacion), 0) + 1
  INTO next_number
  FROM estimations.estimations
  WHERE project_id = p_project_id
    AND estimation_type = p_estimation_type
    AND EXTRACT(YEAR FROM created_at) = p_year;
  
  -- Obtener código proyecto
  SELECT code INTO project_code
  FROM projects.projects
  WHERE id = p_project_id;
  
  -- Tipo
  type_code := CASE 
    WHEN p_estimation_type = 'to_client' THEN 'CLI'
    WHEN p_estimation_type = 'to_subcontractor' THEN 'SUB'
  END;
  
  RETURN FORMAT('EST-%s-%s-%s-%s',
    project_code,
    type_code,
    p_year,
    LPAD(next_number::TEXT, 3, '0')
  );
END;
$$ LANGUAGE plpgsql;

Función: validate_estimation_totals

CREATE OR REPLACE FUNCTION estimations.validate_estimation_totals()
RETURNS TRIGGER AS $$
BEGIN
  -- Validar que monto_neto = monto_bruto - amortizacion - retenciones
  IF NEW.monto_neto != (NEW.monto_bruto - NEW.amortizacion_anticipo - NEW.total_retenciones) THEN
    RAISE EXCEPTION 'Monto neto incorrecto. Debe ser: monto_bruto - amortizacion - retenciones';
  END IF;
  
  -- Validar que monto_neto >= 0
  IF NEW.monto_neto < 0 THEN
    RAISE EXCEPTION 'Monto neto no puede ser negativo';
  END IF;
  
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_validate_estimation_totals
  BEFORE INSERT OR UPDATE ON estimations.estimations
  FOR EACH ROW
  EXECUTE FUNCTION estimations.validate_estimation_totals();

Generado: 2025-11-20
Estado: Completo