workspace-v1/projects/erp-construccion/docs/02-definicion-modulos/MAI-008-estimaciones-facturacion/especificaciones/ET-EST-002-calculo-montos.md
rckrdmrd 66161b1566 feat: Workspace-v1 complete migration with NEXUS v3.4
Sistema NEXUS v3.4 migrado con:

Estructura principal:
- core/orchestration: Sistema SIMCO + CAPVED (27 directivas, 28 perfiles)
- core/catalog: Catalogo de funcionalidades reutilizables
- shared/knowledge-base: Base de conocimiento compartida
- devtools/scripts: Herramientas de desarrollo
- control-plane/registries: Control de servicios y CI/CD
- orchestration/: Configuracion de orquestacion de agentes

Proyectos incluidos (11):
- gamilit (submodule -> GitHub)
- trading-platform (OrbiquanTIA)
- erp-suite con 5 verticales:
  - erp-core, construccion, vidrio-templado
  - mecanicas-diesel, retail, clinicas
- betting-analytics
- inmobiliaria-analytics
- platform_marketing_content
- pos-micro, erp-basico

Configuracion:
- .gitignore completo para Node.js/Python/Docker
- gamilit como submodule (git@github.com:rckrdmrd/gamilit-workspace.git)
- Sistema de puertos estandarizado (3005-3199)

Generated with NEXUS v3.4 Migration System
EPIC-010: Configuracion Git y Repositorios
2026-01-04 03:37:42 -06:00

6.5 KiB

ET-EST-002: Cálculo Automático de Montos

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


🧮 EstimationCalculator Service

@Injectable()
export class EstimationCalculatorService {
  
  /**
   * Calcula monto bruto desde items
   */
  calculateMontoBruto(items: EstimationItemDto[]): number {
    return items.reduce((total, item) => {
      return total + (item.cantidadEstimadaActual * item.precioUnitario);
    }, 0);
  }

  /**
   * Calcula amortización de anticipo
   */
  async calculateAmortizacion(
    montoBruto: number,
    contractId: string,
    estimacionNumero: number
  ): Promise<number> {
    // Obtener anticipo del contrato
    const anticipo = await this.getAnticipo(contractId);
    if (!anticipo) return 0;

    // Obtener amortizaciones previas
    const amortizadoAnterior = await this.getTotalAmortizado(contractId, estimacionNumero - 1);
    const saldoAnticipo = anticipo.monto - amortizadoAnterior;

    if (saldoAnticipo <= 0) return 0;

    // Calcular amortización actual
    const porcentaje = anticipo.porcentajeAmortizacionPorEstimacion / 100;
    const amortizacion = Math.min(
      saldoAnticipo,
      montoBruto * porcentaje
    );

    return Math.round(amortizacion);
  }

  /**
   * Calcula retenciones
   */
  calculateRetenciones(
    montoBruto: number,
    amortizacion: number,
    contractConfig: RetentionConfig
  ): RetentionesDetalle {
    const base = montoBruto - amortizacion;

    const retenciones = {
      fondoGarantia: Math.round(base * (contractConfig.porcentajeFondoGarantia / 100)),
      isr: contractConfig.retieneISR ? Math.round(base * (contractConfig.tasaISR / 100)) : 0,
      iva: contractConfig.retieneIVA ? Math.round(base * (contractConfig.tasaIVA / 100)) : 0,
      otras: contractConfig.otrasRetenciones || 0
    };

    const total = Object.values(retenciones).reduce((sum, val) => sum + val, 0);

    return {
      ...retenciones,
      total
    };
  }

  /**
   * Calcula monto neto final
   */
  calculateMontoNeto(
    montoBruto: number,
    amortizacion: number,
    totalRetenciones: number
  ): number {
    const neto = montoBruto - amortizacion - totalRetenciones;
    if (neto < 0) {
      throw new BadRequestException('Monto neto no puede ser negativo');
    }
    return neto;
  }

  /**
   * Cálculo completo de estimación
   */
  async calculateEstimationTotals(
    items: EstimationItemDto[],
    contractId: string,
    estimacionNumero: number,
    estimationType: EstimationType
  ): Promise<EstimationTotals> {
    // 1. Monto bruto
    const montoBruto = this.calculateMontoBruto(items);

    // 2. Amortización
    const amortizacion = await this.calculateAmortizacion(
      montoBruto,
      contractId,
      estimacionNumero
    );

    // 3. Retenciones
    const contractConfig = await this.getContractConfig(contractId, estimationType);
    const retenciones = this.calculateRetenciones(montoBruto, amortizacion, contractConfig);

    // 4. Monto neto
    const montoNeto = this.calculateMontoNeto(montoBruto, amortizacion, retenciones.total);

    return {
      montoBruto,
      amortizacion,
      retenciones,
      montoNeto
    };
  }
}

📊 Fórmulas de Cálculo

Para Estimación a Cliente

// Ejemplo real
const calculo = {
  // Entrada
  viviendas_terminadas: 25,
  precio_unitario: 500_000_00, // $500K en centavos
  
  // Paso 1: Monto bruto
  monto_bruto: 25 * 500_000_00 = 12_500_000_00, // $12.5M
  
  // Paso 2: Amortización
  anticipo_inicial: 10_000_000_00, // $10M (20% del contrato)
  porcentaje_amortizacion: 25,
  amortizacion: Math.min(
    10_000_000_00,  // Saldo disponible
    12_500_000_00 * 0.25  // 25% del bruto
  ) = 2_500_000_00, // $2.5M
  
  // Paso 3: Base retenciones
  base_retenciones: 12_500_000_00 - 2_500_000_00 = 10_000_000_00,
  
  // Paso 4: Retenciones
  retencion_fondo_garantia: 10_000_000_00 * 0.05 = 500_000_00, // 5%
  retencion_isr: 0,
  retencion_iva: 0,
  total_retenciones: 500_000_00,
  
  // Paso 5: Monto neto
  monto_neto: 12_500_000_00 - 2_500_000_00 - 500_000_00 = 9_500_000_00  // $9.5M
};

Para Estimación a Subcontratista

const calculoSub = {
  // Entrada
  monto_subcontrato: 2_000_000_00, // $2M
  porcentaje_avance: 30,
  
  // Paso 1: Monto bruto
  monto_bruto: 2_000_000_00 * 0.30 = 600_000_00, // $600K
  
  // Paso 2: Amortización proporcional
  anticipo: 2_000_000_00 * 0.10 = 200_000_00, // 10% anticipo
  amortizacion: 200_000_00 * 0.30 = 60_000_00, // 30% del anticipo
  
  // Paso 3: Retenciones
  base: 600_000_00 - 60_000_00 = 540_000_00,
  retencion: 540_000_00 * 0.10 = 54_000_00, // 10%
  
  // Paso 4: Neto
  monto_neto: 600_000_00 - 60_000_00 - 54_000_00 = 486_000_00  // $486K
};

Validaciones

@Injectable()
export class EstimationValidatorService {
  
  /**
   * Valida que no se exceda el monto del contrato
   */
  async validateContractLimit(
    contractId: string,
    newMontoBruto: number
  ): Promise<void> {
    const contract = await this.contractsRepo.findOne(contractId);
    const estimatedTotal = await this.getTotalEstimated(contractId);
    
    if (estimatedTotal + newMontoBruto > contract.montoTotal) {
      throw new BadRequestException(
        `Excede monto del contrato. Disponible: $${(contract.montoTotal - estimatedTotal) / 100}`
      );
    }
  }

  /**
   * Valida que no se dupliquen conceptos
   */
  async validateNoDuplicateItems(
    projectId: string,
    items: EstimationItemDto[]
  ): Promise<void> {
    const previousItems = await this.getEstimatedItems(projectId);
    
    for (const item of items) {
      const alreadyEstimated = previousItems.find(
        prev => prev.conceptCatalogId === item.conceptCatalogId
      );
      
      if (alreadyEstimated) {
        throw new BadRequestException(
          `Concepto "${item.descripcion}" ya fue estimado previamente`
        );
      }
    }
  }

  /**
   * Valida avances verificados
   */
  async validateVerifiedProgress(items: EstimationItemDto[]): Promise<void> {
    for (const item of items) {
      if (!item.avanceObraId) {
        throw new BadRequestException(
          `Item "${item.descripcion}" no tiene avance de obra vinculado`
        );
      }
      
      const avance = await this.avancesRepo.findOne(item.avanceObraId);
      if (avance.status !== 'verified') {
        throw new BadRequestException(
          `Avance de "${item.descripcion}" no está verificado`
        );
      }
    }
  }
}

Generado: 2025-11-20
Estado: Completo