erp-construccion/docs/02-definicion-modulos/MAI-008-estimaciones-facturacion/especificaciones/ET-EST-005-workflow-estados.md

4.5 KiB

ET-EST-005: Workflow de Estados

ID: ET-EST-005
Módulo: MAI-008
Relacionado con: RF-EST-005


🔧 Backend Service

estimation-workflow.service.ts

@Injectable()
export class EstimationWorkflowService {
  
  async transitionTo(
    estimacionId: string,
    newStatus: EstimationStatus,
    userId: string,
    comentario?: string
  ): Promise<Estimation> {
    const estimation = await this.estimationsRepo.findOne(estimacionId);
    const user = await this.usersRepo.findOne(userId);

    // Validar transición permitida
    this.validateTransition(estimation.status, newStatus, user.role);

    const previousStatus = estimation.status;
    estimation.status = newStatus;

    // Actualizar campos según estado
    switch (newStatus) {
      case EstimationStatus.SUBMITTED:
        estimation.submittedAt = new Date();
        estimation.submittedBy = userId;
        break;
      case EstimationStatus.REVIEWED:
        estimation.reviewedAt = new Date();
        estimation.reviewedBy = userId;
        break;
      case EstimationStatus.AUTHORIZED:
        estimation.authorizedAt = new Date();
        estimation.authorizedBy = userId;
        break;
      case EstimationStatus.PAID:
        estimation.paidAt = new Date();
        break;
    }

    await this.estimationsRepo.save(estimation);

    // Registrar en historial
    await this.recordWorkflowHistory(
      estimacionId,
      previousStatus,
      newStatus,
      userId,
      user.role,
      comentario
    );

    // Enviar notificaciones
    await this.sendWorkflowNotifications(estimation, newStatus);

    // Audit log
    await this.auditService.log({
      action: 'estimation_status_changed',
      entityId: estimacionId,
      from: previousStatus,
      to: newStatus,
      userId
    });

    return estimation;
  }

  private validateTransition(
    currentStatus: EstimationStatus,
    newStatus: EstimationStatus,
    userRole: string
  ): void {
    const allowedTransitions = {
      draft: {
        submitted: ['engineer', 'resident']
      },
      submitted: {
        reviewed: ['finance'],
        draft: ['engineer', 'finance']
      },
      reviewed: {
        authorized: ['director', 'finance'],
        submitted: ['finance']
      },
      authorized: {
        paid: ['finance']
      }
    };

    const allowed = allowedTransitions[currentStatus]?.[newStatus];
    if (!allowed || !allowed.includes(userRole)) {
      throw new ForbiddenException(
        `Usuario con rol ${userRole} no puede cambiar de ${currentStatus} a ${newStatus}`
      );
    }
  }

  private async sendWorkflowNotifications(
    estimation: Estimation,
    newStatus: EstimationStatus
  ): Promise<void> {
    const notifications = {
      submitted: {
        to: 'finance_team',
        subject: `Nueva estimación ${estimation.numero} pendiente de revisar`,
        template: 'estimation-submitted'
      },
      reviewed: {
        to: 'director',
        subject: `Estimación ${estimation.numero} requiere autorización`,
        template: 'estimation-reviewed'
      },
      authorized: {
        to: 'finance_team',
        subject: `Estimación ${estimation.numero} autorizada para pago`,
        template: 'estimation-authorized'
      }
    };

    const config = notifications[newStatus];
    if (config) {
      await this.notificationService.send({
        ...config,
        data: { estimation }
      });
    }
  }

  @Cron('0 */6 * * *') // Cada 6 horas
  async checkPendingEstimations(): Promise<void> {
    const pendientes = await this.estimationsRepo.find({
      where: { status: In([EstimationStatus.SUBMITTED, EstimationStatus.REVIEWED]) }
    });

    for (const est of pendientes) {
      const horasPendiente = differenceInHours(new Date(), est.submittedAt);

      if (horasPendiente > 48) {
        await this.escalateEstimation(est, 'critical');
      } else if (horasPendiente > 24) {
        await this.escalateEstimation(est, 'warning');
      }
    }
  }

  private async escalateEstimation(estimation: Estimation, level: string): Promise<void> {
    await this.notificationService.send({
      to: level === 'critical' ? ['director', 'cfo'] : ['finance_supervisor'],
      subject: `⚠️ Estimación ${estimation.numero} pendiente hace ${differenceInHours(new Date(), estimation.submittedAt)}h`,
      priority: level === 'critical' ? 'high' : 'medium',
      channels: level === 'critical' ? ['email', 'sms'] : ['email']
    });
  }
}

Generado: 2025-11-20
Estado: Completo