From 9747f8cbf533e52371a11ebdc8833cdf0ccb8d93 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Mon, 4 May 2026 12:20:21 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20Infra+App:=20Chunk=20C=20=E2=80=94?= =?UTF-8?q?=20Smart=20reject=20+=20Resume=20after=20reject=20(3=20module)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ràng buộc 2 (Phase 9): khi reject, trả về Drafter (DangSoanThao) + lưu phase nguồn. Drafter sửa lại + trình lại → quay về phase đã reject (skip phase trung gian). Logic flow: 1. Reject (Decision=Reject): - entity.RejectedFromPhase = currentPhase // snapshot phase đang reject - targetPhase override = DangSoanThao // force về Drafter - Approval row: FromPhase=X, ToPhase=DangSoanThao, Decision=Reject - Notification cho Drafter 2. Resume after reject (Decision=Approve, fromPhase=DangSoanThao, RejectedFromPhase != null): - targetPhase override = entity.RejectedFromPhase!.Value - entity.RejectedFromPhase = null // clear field - Skip policy guard (Drafter có quyền trình lại sau khi sửa) - Approval row: FromPhase=DangSoanThao, ToPhase=ResumePhase, Decision=Approve 3. Normal transition (chưa reject hoặc đã clear): - Logic cũ giữ nguyên — policy guard check + transition Pattern unified cho 3 module: - ContractWorkflowService.TransitionAsync: 2 case detect + override - PurchaseEvaluationWorkflowService.TransitionAsync: tương tự - TransitionBudgetCommandHandler.Handle: tương tự (Budget không có service riêng, logic ở handler) Files: - src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs - src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs - src/Backend/SolutionErp.Application/Budgets/BudgetFeatures.cs Verify: - Build pass (2 warning DocxRenderer cũ, không liên quan) - 77 unit test pass — Domain policy không đổi, tests giữ nguyên Note: Approval history giờ track đầy đủ cycle reject→sửa→resume: Approval 1: DangGopY → DangSoanThao, Decision=Reject (CCM reject) Approval 2: DangSoanThao → DangGopY, Decision=Approve (Drafter resume) UI có thể detect "đã từng reject" qua RejectedFromPhase != null hoặc qua Approval history (Decision=Reject row gần nhất). Hiển thị banner đỏ "Phiếu đã bị reject từ phase X, lý do: Y" cho Drafter. Smart reject hoàn tất Ràng buộc 2. Còn Ràng buộc 3 (2-stage dept approval) ở Chunk D. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Budgets/BudgetFeatures.cs | 35 +++++++++++++----- .../Services/ContractWorkflowService.cs | 36 ++++++++++++++----- .../PurchaseEvaluationWorkflowService.cs | 29 +++++++++++---- 3 files changed, 78 insertions(+), 22 deletions(-) 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;