diff --git a/src/Backend/SolutionErp.Application/Budgets/BudgetFeatures.cs b/src/Backend/SolutionErp.Application/Budgets/BudgetFeatures.cs index 442c073..73d8ef6 100644 --- a/src/Backend/SolutionErp.Application/Budgets/BudgetFeatures.cs +++ b/src/Backend/SolutionErp.Application/Budgets/BudgetFeatures.cs @@ -131,24 +131,43 @@ public class TransitionBudgetCommandHandler( var entity = await db.Budgets.FirstOrDefaultAsync(x => x.Id == request.Id, ct) ?? throw new NotFoundException("Budget", request.Id); + // ===== Smart reject + resume (Phase 9 — Migration 16) ===== + var fromPhase = entity.Phase; + var targetPhase = request.TargetPhase; + var isResumingAfterReject = request.Decision == ApprovalDecision.Approve + && fromPhase == BudgetPhase.DangSoanThao + && entity.RejectedFromPhase != null; + + if (request.Decision == ApprovalDecision.Reject) + { + entity.RejectedFromPhase = fromPhase; + targetPhase = BudgetPhase.DangSoanThao; + } + else if (isResumingAfterReject) + { + targetPhase = entity.RejectedFromPhase!.Value; + entity.RejectedFromPhase = null; + } + var policy = BudgetPolicies.Default; var isAdmin = currentUser.Roles.Contains(AppRoles.Admin); - if (!isAdmin && !policy.IsTransitionAllowed(entity.Phase, request.TargetPhase, currentUser.Roles)) + // Policy guard — bypass khi resume sau reject. + if (!isAdmin && !isResumingAfterReject + && !policy.IsTransitionAllowed(fromPhase, targetPhase, currentUser.Roles)) throw new ForbiddenException( - $"Role không đủ quyền chuyển {entity.Phase} → {request.TargetPhase}."); + $"Role không đủ quyền chuyển {fromPhase} → {targetPhase}."); - var fromPhase = entity.Phase; entity.SlaWarningSent = false; - entity.Phase = request.TargetPhase; - var sla = policy.PhaseSla.GetValueOrDefault(request.TargetPhase); + entity.Phase = targetPhase; + var sla = policy.PhaseSla.GetValueOrDefault(targetPhase); entity.SlaDeadline = sla is null ? null : DateTime.UtcNow.Add(sla.Value); db.BudgetApprovals.Add(new BudgetApproval { BudgetId = entity.Id, FromPhase = fromPhase, - ToPhase = request.TargetPhase, + ToPhase = targetPhase, ApproverUserId = currentUser.UserId, Decision = request.Decision, Comment = request.Comment, @@ -166,10 +185,10 @@ public class TransitionBudgetCommandHandler( BudgetId = entity.Id, EntityType = BudgetEntityType.Workflow, Action = ChangelogAction.Transition, - PhaseAtChange = request.TargetPhase, + PhaseAtChange = targetPhase, UserId = currentUser.UserId, UserName = actorName ?? "Hệ thống", - Summary = $"Chuyển phase {fromPhase} → {request.TargetPhase}", + Summary = $"Chuyển phase {fromPhase} → {targetPhase}", ContextNote = request.Comment, }); await db.SaveChangesAsync(ct); diff --git a/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs index 45c1a31..9ec97ab 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs @@ -37,6 +37,26 @@ public class ContractWorkflowService( if (contract.Phase == targetPhase) throw new ConflictException("HĐ đã ở phase đích."); + // ===== Smart reject + resume (Phase 9 — Migration 16) ===== + // Reject: override target = DangSoanThao + lưu phase gốc → Drafter sửa. + // Resume sau reject: Drafter trình từ DangSoanThao + RejectedFromPhase + // != null → jump straight tới phase đã reject, bypass phase trung gian. + var fromPhase = contract.Phase; + var isResumingAfterReject = decision == ApprovalDecision.Approve + && fromPhase == ContractPhase.DangSoanThao + && contract.RejectedFromPhase != null; + + if (decision == ApprovalDecision.Reject) + { + contract.RejectedFromPhase = fromPhase; + targetPhase = ContractPhase.DangSoanThao; + } + else if (isResumingAfterReject) + { + targetPhase = contract.RejectedFromPhase!.Value; + contract.RejectedFromPhase = null; + } + // Resolve the workflow: prefer the pinned WorkflowDefinition (new // versioned system), else fall back to the static/override registry // (legacy path for contracts created before versioning rolled out). @@ -60,31 +80,31 @@ public class ContractWorkflowService( var isAdmin = actorRoles.Contains(AppRoles.Admin); var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove; - if (!isAdmin && !isSystem) + // Policy guard — bypass cho resume (Drafter có quyền trình lại sau khi + // sửa, không cần policy check vì target đã pinned bởi RejectedFromPhase). + if (!isAdmin && !isSystem && !isResumingAfterReject) { - if (!policy.Transitions.TryGetValue((contract.Phase, targetPhase), out var allowedRoles)) + if (!policy.Transitions.TryGetValue((fromPhase, targetPhase), out var allowedRoles)) throw new ForbiddenException( - $"Policy '{policy.Name}' không cho phép {contract.Phase} → {targetPhase}. " + + $"Policy '{policy.Name}' không cho phép {fromPhase} → {targetPhase}. " + $"Kiểm tra ContractType hoặc BypassProcurementAndCCM."); // Sử dụng IsTransitionAllowed — check Role + User-kind fallback. // User-kind chỉ áp dụng khi WorkflowDefinition pinned có // WorkflowStepApprover Kind=User cho step này. - if (!policy.IsTransitionAllowed(contract.Phase, targetPhase, actorRoles, actorUserId)) + if (!policy.IsTransitionAllowed(fromPhase, targetPhase, actorRoles, actorUserId)) { var userExtra = policy.UserTransitions is not null - && policy.UserTransitions.TryGetValue((contract.Phase, targetPhase), out var userIds) + && policy.UserTransitions.TryGetValue((fromPhase, targetPhase), out var userIds) && userIds.Length > 0 ? $" hoặc {userIds.Length} user explicit" : ""; throw new ForbiddenException( - $"Role ({string.Join(",", actorRoles)}) không đủ quyền chuyển {contract.Phase} → {targetPhase}. " + + $"Role ({string.Join(",", actorRoles)}) không đủ quyền chuyển {fromPhase} → {targetPhase}. " + $"Policy '{policy.Name}' yêu cầu: {string.Join(",", allowedRoles)}{userExtra}."); } } - var fromPhase = contract.Phase; - // Defensive — gen mã HĐ nếu chưa có khi chuyển sang DangDongDau. // Nominal flow (sau user feedback): mã đã gen sẵn từ CreateContract → skip. // Fallback chỉ trigger cho HĐ legacy chưa qua backfill, hoặc HĐ tạo bằng diff --git a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs index f26dd5d..3e2df7e 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs @@ -34,6 +34,23 @@ public class PurchaseEvaluationWorkflowService( if (evaluation.Phase == targetPhase) throw new ConflictException("Phiếu đã ở phase đích."); + // ===== Smart reject + resume (Phase 9 — Migration 16) ===== + var fromPhase = evaluation.Phase; + var isResumingAfterReject = decision == ApprovalDecision.Approve + && fromPhase == PurchaseEvaluationPhase.DangSoanThao + && evaluation.RejectedFromPhase != null; + + if (decision == ApprovalDecision.Reject) + { + evaluation.RejectedFromPhase = fromPhase; + targetPhase = PurchaseEvaluationPhase.DangSoanThao; + } + else if (isResumingAfterReject) + { + targetPhase = evaluation.RejectedFromPhase!.Value; + evaluation.RejectedFromPhase = null; + } + PurchaseEvaluationPolicy policy; if (evaluation.WorkflowDefinitionId is Guid wfId) { @@ -53,21 +70,21 @@ public class PurchaseEvaluationWorkflowService( var isAdmin = actorRoles.Contains(AppRoles.Admin); var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove; - if (!isAdmin && !isSystem) + // Policy guard — bypass khi resume sau reject (target đã pinned). + if (!isAdmin && !isSystem && !isResumingAfterReject) { - if (!policy.Transitions.TryGetValue((evaluation.Phase, targetPhase), out var allowedRoles)) + if (!policy.Transitions.TryGetValue((fromPhase, targetPhase), out var allowedRoles)) throw new ForbiddenException( - $"Policy '{policy.Name}' không cho phép {evaluation.Phase} → {targetPhase}."); + $"Policy '{policy.Name}' không cho phép {fromPhase} → {targetPhase}."); - if (!policy.IsTransitionAllowed(evaluation.Phase, targetPhase, actorRoles, actorUserId)) + if (!policy.IsTransitionAllowed(fromPhase, targetPhase, actorRoles, actorUserId)) { throw new ForbiddenException( - $"Role ({string.Join(",", actorRoles)}) không đủ quyền chuyển {evaluation.Phase} → {targetPhase}. " + + $"Role ({string.Join(",", actorRoles)}) không đủ quyền chuyển {fromPhase} → {targetPhase}. " + $"Policy '{policy.Name}' yêu cầu: {string.Join(",", allowedRoles)}."); } } - var fromPhase = evaluation.Phase; evaluation.SlaWarningSent = false; evaluation.Phase = targetPhase;