From 6db195dd4270a8c463305d5051c901e05748c403 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Fri, 12 Jun 2026 14:30:38 +0700 Subject: [PATCH] [CLAUDE] PurchaseEvaluation: go han hanh dong "Tu choi" - chi con Duyet hoac Tra lai (UAT anh Kiet S60 14:14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Domain policy: xoa MOI transition -> TuChoi o ca 4 policy (NccOnly + NccWithPlan + ForV2Schema + FromDefinition) -> NextPhases het tra TuChoi, nut FE tu bien mat - Service guard S60: chan targetPhase=TuChoi moi caller ke ca Admin (dung truoc moi branch — spec bo han, khong escape hatch); message huong dan dung Tra lai / Xoa nhap - FE x2 app: filter phong thu next.filter(p != TuChoi) PeWorkflowPanel (SHA256 identical); dialog/isCancel giu dead-safe de flip lai de - Enum TuChoi + phieu TuChoi cu + tab filter "Tu choi" GIU display (data cu render binh thuong) - SlaExpiryJob chi dung Contract — PE khong auto-TuChoi, khong anh huong - Tests spec-change cung commit: Domain flip BothPolicies_TuChoi_RemovedFromAllTransitions_S60 + NEW V2SchemaPolicy fact; Infra NEW TargetTuChoi_WithRejectDecision_Throws_TuChoiRemoved_S60 (guard #45 test cu giu nguyen PASS — van dung truoc) - Test 254 -> 256 PASS (59 Domain + 197 Infra) Co-Authored-By: Claude Fable 5 --- .../src/components/pe/PeWorkflowPanel.tsx | 5 ++- fe-user/src/components/pe/PeWorkflowPanel.tsx | 5 ++- .../PurchaseEvaluationPolicy.cs | 42 +++++-------------- .../PurchaseEvaluationWorkflowService.cs | 14 +++++++ .../PurchaseEvaluationPolicyTests.cs | 38 +++++++++++++++-- ...haseEvaluationWorkflowServiceGuardTests.cs | 31 ++++++++++++++ 6 files changed, 98 insertions(+), 37 deletions(-) diff --git a/fe-admin/src/components/pe/PeWorkflowPanel.tsx b/fe-admin/src/components/pe/PeWorkflowPanel.tsx index a193b5e..d13d941 100644 --- a/fe-admin/src/components/pe/PeWorkflowPanel.tsx +++ b/fe-admin/src/components/pe/PeWorkflowPanel.tsx @@ -136,7 +136,10 @@ export function PeWorkflowPanel({ onError: e => toast.error(getErrorMessage(e)), }) - const next = evaluation.workflow.nextPhases + // UAT S60 (anh Kiệt 14:14): "bỏ luôn nút Từ chối — Duyệt hoặc Trả về thôi". + // BE policy đã gỡ TuChoi khỏi nextPhases; filter này = defense-in-depth FE + // (BE cũ cache / deploy lệch vẫn không lòi nút). Dialog/isCancel giữ dead-safe. + const next = evaluation.workflow.nextPhases.filter(p => p !== PurchaseEvaluationPhase.TuChoi) const flow = evaluation.approvalFlow return ( diff --git a/fe-user/src/components/pe/PeWorkflowPanel.tsx b/fe-user/src/components/pe/PeWorkflowPanel.tsx index a193b5e..d13d941 100644 --- a/fe-user/src/components/pe/PeWorkflowPanel.tsx +++ b/fe-user/src/components/pe/PeWorkflowPanel.tsx @@ -136,7 +136,10 @@ export function PeWorkflowPanel({ onError: e => toast.error(getErrorMessage(e)), }) - const next = evaluation.workflow.nextPhases + // UAT S60 (anh Kiệt 14:14): "bỏ luôn nút Từ chối — Duyệt hoặc Trả về thôi". + // BE policy đã gỡ TuChoi khỏi nextPhases; filter này = defense-in-depth FE + // (BE cũ cache / deploy lệch vẫn không lòi nút). Dialog/isCancel giữ dead-safe. + const next = evaluation.workflow.nextPhases.filter(p => p !== PurchaseEvaluationPhase.TuChoi) const flow = evaluation.approvalFlow return ( diff --git a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationPolicy.cs b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationPolicy.cs index be525bf..43f2a14 100644 --- a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationPolicy.cs +++ b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationPolicy.cs @@ -58,22 +58,18 @@ public static class PurchaseEvaluationPolicies { // Drafter trình từ Nháp HOẶC gửi lại từ Trả lại — cùng entry point. [(PurchaseEvaluationPhase.DangSoanThao, PurchaseEvaluationPhase.ChoPurchasing)] = [AppRoles.Drafter, AppRoles.DeptManager], - [(PurchaseEvaluationPhase.DangSoanThao, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager], [(PurchaseEvaluationPhase.TraLai, PurchaseEvaluationPhase.ChoPurchasing)] = [AppRoles.Drafter, AppRoles.DeptManager], - [(PurchaseEvaluationPhase.TraLai, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager], - // Phase trung gian: 3 hành động — Duyệt forward / Trả lại (TraLai Phase riêng) / Từ chối (TuChoi terminal). + // Phase trung gian: 2 hành động — Duyệt forward / Trả lại (TraLai Phase riêng). + // UAT S60: TuChoi gỡ khỏi quy trình ("Duyệt hoặc Trả về thôi"). [(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.ChoCCM)] = [AppRoles.Procurement], [(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.TraLai)] = [AppRoles.Procurement], - [(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Procurement], [(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.ChoCEODuyetNCC)] = [AppRoles.CostControl], [(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.TraLai)] = [AppRoles.CostControl], - [(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.CostControl], [(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.DaDuyet)] = [AppRoles.Director, AppRoles.AuthorizedSigner], [(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.TraLai)] = [AppRoles.Director, AppRoles.AuthorizedSigner], - [(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Director, AppRoles.AuthorizedSigner], }, PhaseSla: DefaultSla, ActivePhases: @@ -94,30 +90,23 @@ public static class PurchaseEvaluationPolicies Transitions: new Dictionary<(PurchaseEvaluationPhase, PurchaseEvaluationPhase), string[]> { [(PurchaseEvaluationPhase.DangSoanThao, PurchaseEvaluationPhase.ChoPurchasing)] = [AppRoles.Drafter, AppRoles.DeptManager], - [(PurchaseEvaluationPhase.DangSoanThao, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager], [(PurchaseEvaluationPhase.TraLai, PurchaseEvaluationPhase.ChoPurchasing)] = [AppRoles.Drafter, AppRoles.DeptManager], - [(PurchaseEvaluationPhase.TraLai, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager], - // Phase trung gian: 3 hành động — Duyệt forward / Trả lại (TraLai) / Từ chối (TuChoi). + // Phase trung gian: 2 hành động — Duyệt forward / Trả lại (TraLai). UAT S60: TuChoi gỡ. [(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.ChoDuAn)] = [AppRoles.Procurement], [(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.TraLai)] = [AppRoles.Procurement], - [(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Procurement], [(PurchaseEvaluationPhase.ChoDuAn, PurchaseEvaluationPhase.ChoCCM)] = [AppRoles.ProjectManager], [(PurchaseEvaluationPhase.ChoDuAn, PurchaseEvaluationPhase.TraLai)] = [AppRoles.ProjectManager], - [(PurchaseEvaluationPhase.ChoDuAn, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.ProjectManager], [(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.ChoCEODuyetPA)] = [AppRoles.CostControl], [(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.TraLai)] = [AppRoles.CostControl], - [(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.CostControl], [(PurchaseEvaluationPhase.ChoCEODuyetPA, PurchaseEvaluationPhase.ChoCEODuyetNCC)] = [AppRoles.Director, AppRoles.AuthorizedSigner], [(PurchaseEvaluationPhase.ChoCEODuyetPA, PurchaseEvaluationPhase.TraLai)] = [AppRoles.Director, AppRoles.AuthorizedSigner], - [(PurchaseEvaluationPhase.ChoCEODuyetPA, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Director, AppRoles.AuthorizedSigner], [(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.DaDuyet)] = [AppRoles.Director, AppRoles.AuthorizedSigner], [(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.TraLai)] = [AppRoles.Director, AppRoles.AuthorizedSigner], - [(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Director, AppRoles.AuthorizedSigner], }, PhaseSla: DefaultSla, ActivePhases: @@ -160,24 +149,23 @@ public static class PurchaseEvaluationPolicyRegistry // Workflow chạy theo state machine 5 trạng thái + iterate Steps/Levels — // Phase enum chỉ dùng (DangSoanThao/TraLai/ChoDuyet/DaDuyet/TuChoi). Service // tự handle advance level/step bên trong ChoDuyet, FE chỉ cần biết: - // DangSoanThao/TraLai → ChoDuyet (trình) | TuChoi (huỷ) - // ChoDuyet → ChoDuyet (advance) | TraLai (trả lại) | TuChoi (từ chối) + // DangSoanThao/TraLai → ChoDuyet (trình) + // ChoDuyet → ChoDuyet (advance) | TraLai (trả lại) + // UAT S60 (anh Kiệt): TuChoi GỠ khỏi quy trình ("Duyệt hoặc Trả về thôi") — + // enum + display phiếu TuChoi cũ GIỮ, chỉ hết đường transition tới. public static PurchaseEvaluationPolicy ForV2Schema() { var transitions = new Dictionary<(PurchaseEvaluationPhase, PurchaseEvaluationPhase), string[]> { // Drafter trình từ Nháp HOẶC gửi lại từ Trả lại — cùng entry point [(PurchaseEvaluationPhase.DangSoanThao, PurchaseEvaluationPhase.ChoDuyet)] = [AppRoles.Drafter, AppRoles.DeptManager], - [(PurchaseEvaluationPhase.DangSoanThao, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager], [(PurchaseEvaluationPhase.TraLai, PurchaseEvaluationPhase.ChoDuyet)] = [AppRoles.Drafter, AppRoles.DeptManager], - [(PurchaseEvaluationPhase.TraLai, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager], // ChoDuyet — Service guard match approver ApproverUserId, Policy chỉ // expose 3 nút cho FE (Duyệt forward / Trả lại / Từ chối). Roles "*" // để guard không block; Service tự enforce ApproverUserId match. [(PurchaseEvaluationPhase.ChoDuyet, PurchaseEvaluationPhase.ChoDuyet)] = ["*"], [(PurchaseEvaluationPhase.ChoDuyet, PurchaseEvaluationPhase.TraLai)] = ["*"], - [(PurchaseEvaluationPhase.ChoDuyet, PurchaseEvaluationPhase.TuChoi)] = ["*"], }; var sla = new Dictionary { @@ -245,29 +233,21 @@ public static class PurchaseEvaluationPolicyRegistry userTransitions.TryAdd((PurchaseEvaluationPhase.TraLai, s.Phase), userIds); } - // 3 hành động phase trung gian — Duyệt forward + Trả lại (TraLai) + Từ chối (TuChoi) + // 2 hành động phase trung gian — Duyệt forward + Trả lại (TraLai). + // UAT S60 (anh Kiệt 14:14): "bỏ luôn nút Từ chối — Duyệt hoặc Trả + // về thôi" → TuChoi GỠ khỏi mọi transition map (enum + phase + // display + data cũ GIỮ; service có guard mirror chặn API direct). if (prev.Value != PurchaseEvaluationPhase.DangSoanThao && s.Phase != PurchaseEvaluationPhase.DangSoanThao) { transitions.TryAdd((s.Phase, PurchaseEvaluationPhase.TraLai), roles); - transitions.TryAdd((s.Phase, PurchaseEvaluationPhase.TuChoi), roles); if (userIds.Length > 0) { userTransitions.TryAdd((s.Phase, PurchaseEvaluationPhase.TraLai), userIds); - userTransitions.TryAdd((s.Phase, PurchaseEvaluationPhase.TuChoi), userIds); } } } prev = s.Phase; } - // First step (DangSoanThao) — Drafter có thể TuChoi (huỷ phiếu). - // Tương tự cho TraLai — Drafter có thể TuChoi luôn từ Trả lại. - if (steps.Count > 0) - { - transitions.TryAdd((steps[0].Phase, PurchaseEvaluationPhase.TuChoi), - [AppRoles.Drafter, AppRoles.DeptManager]); - transitions.TryAdd((PurchaseEvaluationPhase.TraLai, PurchaseEvaluationPhase.TuChoi), - [AppRoles.Drafter, AppRoles.DeptManager]); - } // Terminal states always available + TraLai phase if (!activePhases.Contains(PurchaseEvaluationPhase.TuChoi)) diff --git a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs index 5b50a9c..485c0bb 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs @@ -71,6 +71,20 @@ public class PurchaseEvaluationWorkflowService( "(xem gotcha #45 + docs/workflow-contract.md)."); } + // ===== UAT S60 (anh Kiệt 14:14) — GỠ hành động "Từ chối" ===== + // "Bỏ luôn nút Từ chối — Duyệt hoặc Trả về thôi." Mọi policy đã bỏ + // TuChoi khỏi NextPhases (FE hết nút); guard này chặn caller direct + // (API forge / client cũ cache) — đứng TRƯỚC mọi branch nên chặn CẢ + // Admin manual override (spec = bỏ hẳn hành động, không escape hatch). + // Phase TuChoi + phiếu TuChoi cũ GIỮ display/filter. Flip lại nếu cần: + // xóa guard này + restore transitions trong PurchaseEvaluationPolicy. + if (targetPhase == PurchaseEvaluationPhase.TuChoi) + { + throw new ConflictException( + "Hành động \"Từ chối\" đã được gỡ khỏi quy trình duyệt — chỉ còn Duyệt hoặc Trả lại. " + + "Phiếu cần dừng: dùng Trả lại để người soạn sửa, hoặc Xóa phiếu khi còn Bản nháp."); + } + // ===== REJECT BRANCH (extended Mig 28 — F1 multi-mode Trả lại) ===== if (decision == ApprovalDecision.Reject) { diff --git a/tests/SolutionErp.Domain.Tests/PurchaseEvaluations/PurchaseEvaluationPolicyTests.cs b/tests/SolutionErp.Domain.Tests/PurchaseEvaluations/PurchaseEvaluationPolicyTests.cs index c12095d..60a38eb 100644 --- a/tests/SolutionErp.Domain.Tests/PurchaseEvaluations/PurchaseEvaluationPolicyTests.cs +++ b/tests/SolutionErp.Domain.Tests/PurchaseEvaluations/PurchaseEvaluationPolicyTests.cs @@ -158,21 +158,51 @@ public class PurchaseEvaluationPolicyTests .Should().BeTrue(); } + // UAT S60 (anh Kiệt 14:14): "bỏ luôn nút Từ chối — Duyệt hoặc Trả về thôi". + // Spec change: TuChoi GỠ khỏi MỌI transition map (trước đó Drafter/DeptManager + // được TuChoi từ Nháp/TraLai + approver TuChoi từ phase trung gian). [Theory] [InlineData(nameof(PurchaseEvaluationPolicies.NccOnly))] [InlineData(nameof(PurchaseEvaluationPolicies.NccWithPlan))] - public void BothPolicies_DangSoanThao_To_TuChoi_DrafterOrDeptManager(string policyName) + public void BothPolicies_TuChoi_RemovedFromAllTransitions_S60(string policyName) { var policy = policyName == nameof(PurchaseEvaluationPolicies.NccOnly) ? PurchaseEvaluationPolicies.NccOnly : PurchaseEvaluationPolicies.NccWithPlan; + // Không role nào TuChoi được nữa — kể cả Drafter/DeptManager (huỷ phiếu + // nháp = nút Xóa riêng) lẫn approver phase trung gian. policy.IsTransitionAllowed(PurchaseEvaluationPhase.DangSoanThao, PurchaseEvaluationPhase.TuChoi, - [AppRoles.Drafter]) - .Should().BeTrue(); - policy.IsTransitionAllowed(PurchaseEvaluationPhase.DangSoanThao, PurchaseEvaluationPhase.TuChoi, + [AppRoles.Drafter, AppRoles.DeptManager]) + .Should().BeFalse(); + policy.IsTransitionAllowed(PurchaseEvaluationPhase.TraLai, PurchaseEvaluationPhase.TuChoi, + [AppRoles.Drafter, AppRoles.DeptManager]) + .Should().BeFalse(); + policy.IsTransitionAllowed(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.TuChoi, [AppRoles.Procurement]) .Should().BeFalse(); + + // NextPhases (nguồn nút FE) không bao giờ chứa TuChoi nữa. + foreach (var phase in policy.ActivePhases) + policy.NextPhasesFrom(phase).Should().NotContain(PurchaseEvaluationPhase.TuChoi); + } + + [Fact] + public void V2SchemaPolicy_TuChoi_RemovedFromAllTransitions_S60() + { + var policy = PurchaseEvaluationPolicyRegistry.ForV2Schema(); + + policy.IsTransitionAllowed(PurchaseEvaluationPhase.ChoDuyet, PurchaseEvaluationPhase.TuChoi, + [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.Director]) + .Should().BeFalse(); + foreach (var phase in policy.ActivePhases) + policy.NextPhasesFrom(phase).Should().NotContain(PurchaseEvaluationPhase.TuChoi); + + // 2 hành động còn lại giữ nguyên: trình + duyệt/trả lại. + policy.NextPhasesFrom(PurchaseEvaluationPhase.DangSoanThao) + .Should().Contain(PurchaseEvaluationPhase.ChoDuyet); + policy.NextPhasesFrom(PurchaseEvaluationPhase.ChoDuyet) + .Should().Contain(PurchaseEvaluationPhase.TraLai); } // ===== Registry mapping per PEType ===== diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationWorkflowServiceGuardTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationWorkflowServiceGuardTests.cs index c98738f..e01ce94 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationWorkflowServiceGuardTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationWorkflowServiceGuardTests.cs @@ -121,6 +121,37 @@ public class PurchaseEvaluationWorkflowServiceGuardTests } } + [Fact] + public async Task TransitionAsync_TargetTuChoi_WithRejectDecision_Throws_TuChoiRemoved_S60() + { + // UAT S60 (anh Kiệt 14:14): "bỏ luôn nút Từ chối — Duyệt hoặc Trả về + // thôi". Payload TuChoi + decision=Reject (hợp lệ THEO SPEC CŨ) giờ bị + // guard S60 chặn hẳn — TuChoi hết đường vào từ transition API (guard + // #45 decision-mismatch vẫn đứng trước cover nhánh TuChoi+Approve). + var (svc, fix, db) = CreateService(); + using (fix) + { + var pe = BuildPeInChoDuyet("PE-GUARD-004"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + var act = async () => await svc.TransitionAsync( + evaluation: pe, + targetPhase: PurchaseEvaluationPhase.TuChoi, + actorUserId: Guid.NewGuid(), + actorRoles: new[] { AppRoles.CostControl }, + decision: ApprovalDecision.Reject, + comment: "test tu choi removed S60", + ct: CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*Từ chối*đã được gỡ*"); + + pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, + "Guard chặn trước khi mutate — phiếu giữ nguyên ChoDuyet"); + } + } + [Fact] public async Task TransitionAsync_TargetTraLai_WithRejectDecision_SetsPhaseTraLai() {