diff --git a/src/Backend/SolutionErp.Application/SolutionErp.Application.csproj b/src/Backend/SolutionErp.Application/SolutionErp.Application.csproj
index eaeda99..94d9b30 100644
--- a/src/Backend/SolutionErp.Application/SolutionErp.Application.csproj
+++ b/src/Backend/SolutionErp.Application/SolutionErp.Application.csproj
@@ -18,4 +18,9 @@
enable
+
+
+
+
+
diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/PurchaseEvaluationDraftGuardTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/PurchaseEvaluationDraftGuardTests.cs
new file mode 100644
index 0000000..b197ad2
--- /dev/null
+++ b/tests/SolutionErp.Infrastructure.Tests/Application/PurchaseEvaluationDraftGuardTests.cs
@@ -0,0 +1,231 @@
+using Microsoft.Extensions.DependencyInjection;
+using SolutionErp.Application.Common.Exceptions;
+using SolutionErp.Application.Common.Interfaces;
+using SolutionErp.Application.PurchaseEvaluations;
+using SolutionErp.Domain.ApprovalWorkflowsV2;
+using SolutionErp.Domain.Identity;
+using SolutionErp.Domain.PurchaseEvaluations;
+using SolutionErp.Infrastructure.Tests.Common;
+
+namespace SolutionErp.Infrastructure.Tests.Application;
+
+// Plan C task 3 catch-up cho S21 t4-t5 helper `EnsureEditableForDetailsAsync`:
+// F3 (Mig 28 → Mig 29) — gating Section 2 (Detail + NCC + Báo giá) edit access.
+// 3 scenario decision branch:
+// 1. Drafter scope: Phase ∈ {DangSoanThao, TraLai} → accept any caller
+// 2. F3 Approver scope: Phase=ChoDuyet + level.AllowApproverEditDetails=true
+// + actor.Id == level.ApproverUserId → accept
+// 3. Admin bypass: Phase=ChoDuyet + role contains Admin → accept (skip flag check)
+// Otherwise throw ConflictException / ForbiddenException.
+public class PurchaseEvaluationDraftGuardTests
+{
+ private sealed class FakeCurrentUser : ICurrentUser
+ {
+ public Guid? UserId { get; init; }
+ public string? Email { get; init; }
+ public string? FullName { get; init; }
+ public IReadOnlyList Roles { get; init; } = Array.Empty();
+ public bool IsAuthenticated => UserId is not null;
+ }
+
+ private static async Task<(ApprovalWorkflow wf, ApprovalWorkflowStep step, ApprovalWorkflowLevel level)>
+ SeedWorkflowAsync(TestApplicationDbContext db, Guid approverUserId, bool allowApproverEdit = false)
+ {
+ var wf = new ApprovalWorkflow
+ {
+ Id = Guid.NewGuid(),
+ Code = "QT-GUARD-001",
+ Version = 1,
+ Name = "Guard test workflow",
+ ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
+ IsActive = true,
+ IsUserSelectable = true,
+ };
+ var step = new ApprovalWorkflowStep
+ {
+ Id = Guid.NewGuid(),
+ ApprovalWorkflowId = wf.Id,
+ Order = 1,
+ DepartmentId = null,
+ Name = "Bước 1",
+ };
+ var level = new ApprovalWorkflowLevel
+ {
+ Id = Guid.NewGuid(),
+ ApprovalWorkflowStepId = step.Id,
+ Order = 1,
+ ApproverUserId = approverUserId,
+ AllowApproverEditDetails = allowApproverEdit,
+ };
+ db.ApprovalWorkflows.Add(wf);
+ db.ApprovalWorkflowSteps.Add(step);
+ db.ApprovalWorkflowLevels.Add(level);
+ await db.SaveChangesAsync(CancellationToken.None);
+ return (wf, step, level);
+ }
+
+ private static PurchaseEvaluation BuildPe(
+ PurchaseEvaluationPhase phase,
+ Guid? workflowId = null,
+ int? stepIdx = null,
+ int? levelOrder = null,
+ string code = "PE-G-001")
+ {
+ return new PurchaseEvaluation
+ {
+ Id = Guid.NewGuid(),
+ Type = PurchaseEvaluationType.DuyetNcc,
+ Phase = phase,
+ MaPhieu = code,
+ TenGoiThau = "Test guard",
+ ProjectId = Guid.NewGuid(),
+ DrafterUserId = Guid.NewGuid(),
+ ApprovalWorkflowId = workflowId,
+ CurrentWorkflowStepIndex = stepIdx,
+ CurrentApprovalLevelOrder = levelOrder,
+ };
+ }
+
+ // ===== Scenario 1: Drafter scope =====
+
+ [Fact]
+ public async Task DraftScope_DangSoanThao_AnyCaller_ReturnsPe()
+ {
+ using var fix = new IdentityFixture();
+ var db = fix.Services.GetRequiredService();
+ var pe = BuildPe(PurchaseEvaluationPhase.DangSoanThao);
+ db.PurchaseEvaluations.Add(pe);
+ await db.SaveChangesAsync(CancellationToken.None);
+
+ var anyCaller = new FakeCurrentUser { UserId = Guid.NewGuid(), Roles = new[] { AppRoles.Drafter } };
+
+ var result = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
+ db, pe.Id, anyCaller, CancellationToken.None);
+
+ result.Id.Should().Be(pe.Id);
+ }
+
+ [Fact]
+ public async Task DraftScope_TraLai_AnyCaller_ReturnsPe()
+ {
+ using var fix = new IdentityFixture();
+ var db = fix.Services.GetRequiredService();
+ var pe = BuildPe(PurchaseEvaluationPhase.TraLai, code: "PE-G-002");
+ db.PurchaseEvaluations.Add(pe);
+ await db.SaveChangesAsync(CancellationToken.None);
+
+ var anyCaller = new FakeCurrentUser { UserId = Guid.NewGuid(), Roles = new[] { AppRoles.Drafter } };
+
+ var result = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
+ db, pe.Id, anyCaller, CancellationToken.None);
+
+ result.Id.Should().Be(pe.Id);
+ }
+
+ // ===== Scenario 2: F3 Approver scope =====
+
+ [Fact]
+ public async Task ApproverScope_ChoDuyet_FlagOn_ActorMatches_ReturnsPe()
+ {
+ using var fix = new IdentityFixture();
+ var db = fix.Services.GetRequiredService();
+ var approverUser = await fix.CreateUserAsync("approver-g1@test.local", "Approver G1", departmentId: null, roles: new[] { AppRoles.CostControl });
+ var (wf, _, _) = await SeedWorkflowAsync(db, approverUser.Id, allowApproverEdit: true);
+ var pe = BuildPe(PurchaseEvaluationPhase.ChoDuyet, wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-G-003");
+ db.PurchaseEvaluations.Add(pe);
+ await db.SaveChangesAsync(CancellationToken.None);
+
+ var approver = new FakeCurrentUser { UserId = approverUser.Id, Roles = new[] { AppRoles.CostControl } };
+
+ var result = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
+ db, pe.Id, approver, CancellationToken.None);
+
+ result.Id.Should().Be(pe.Id);
+ }
+
+ [Fact]
+ public async Task ApproverScope_ChoDuyet_FlagOff_NonAdmin_Throws()
+ {
+ // F3 disabled trên Level slot → throw ConflictException.
+ using var fix = new IdentityFixture();
+ var db = fix.Services.GetRequiredService();
+ var approverUser = await fix.CreateUserAsync("approver-g2@test.local", "Approver G2", departmentId: null, roles: new[] { AppRoles.CostControl });
+ var (wf, _, _) = await SeedWorkflowAsync(db, approverUser.Id, allowApproverEdit: false);
+ var pe = BuildPe(PurchaseEvaluationPhase.ChoDuyet, wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-G-004");
+ db.PurchaseEvaluations.Add(pe);
+ await db.SaveChangesAsync(CancellationToken.None);
+
+ var approver = new FakeCurrentUser { UserId = approverUser.Id, Roles = new[] { AppRoles.CostControl } };
+
+ var act = async () => await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
+ db, pe.Id, approver, CancellationToken.None);
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*không được cấp quyền chỉnh sửa Section 2*");
+ }
+
+ [Fact]
+ public async Task ApproverScope_ChoDuyet_FlagOn_ActorMismatch_ThrowsForbidden()
+ {
+ // Khác user gọi (không phải slot ApproverUserId) → Forbidden.
+ using var fix = new IdentityFixture();
+ var db = fix.Services.GetRequiredService();
+ var approverUser = await fix.CreateUserAsync("approver-g3@test.local", "Approver G3", departmentId: null, roles: new[] { AppRoles.CostControl });
+ var (wf, _, _) = await SeedWorkflowAsync(db, approverUser.Id, allowApproverEdit: true);
+ var pe = BuildPe(PurchaseEvaluationPhase.ChoDuyet, wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-G-005");
+ db.PurchaseEvaluations.Add(pe);
+ await db.SaveChangesAsync(CancellationToken.None);
+
+ var someoneElse = new FakeCurrentUser { UserId = Guid.NewGuid(), Roles = new[] { AppRoles.CostControl } };
+
+ var act = async () => await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
+ db, pe.Id, someoneElse, CancellationToken.None);
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*Chỉ NV phụ trách*");
+ }
+
+ // ===== Scenario 3: Admin bypass =====
+
+ [Fact]
+ public async Task AdminBypass_ChoDuyet_FlagOff_ReturnsPe()
+ {
+ // Admin role bypass Allow* flag check + actor match — vẫn được edit.
+ using var fix = new IdentityFixture();
+ var db = fix.Services.GetRequiredService();
+ var approverUser = await fix.CreateUserAsync("approver-g4@test.local", "Approver G4", departmentId: null, roles: new[] { AppRoles.CostControl });
+ var (wf, _, _) = await SeedWorkflowAsync(db, approverUser.Id, allowApproverEdit: false);
+ var pe = BuildPe(PurchaseEvaluationPhase.ChoDuyet, wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-G-006");
+ db.PurchaseEvaluations.Add(pe);
+ await db.SaveChangesAsync(CancellationToken.None);
+
+ var admin = new FakeCurrentUser { UserId = Guid.NewGuid(), Roles = new[] { AppRoles.Admin } };
+
+ var result = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
+ db, pe.Id, admin, CancellationToken.None);
+
+ result.Id.Should().Be(pe.Id);
+ }
+
+ // ===== Other phase locked =====
+
+ [Fact]
+ public async Task DaDuyet_AnyCaller_Throws()
+ {
+ // Phase terminal (DaDuyet/TuChoi/DaPhatHanh) — không ai edit được, kể cả admin trừ khi
+ // tạo helper bypass riêng. Hiện EnsureEditable không có admin bypass cho non-ChoDuyet.
+ using var fix = new IdentityFixture();
+ var db = fix.Services.GetRequiredService();
+ var pe = BuildPe(PurchaseEvaluationPhase.DaDuyet, code: "PE-G-007");
+ db.PurchaseEvaluations.Add(pe);
+ await db.SaveChangesAsync(CancellationToken.None);
+
+ var admin = new FakeCurrentUser { UserId = Guid.NewGuid(), Roles = new[] { AppRoles.Admin } };
+
+ var act = async () => await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
+ db, pe.Id, admin, CancellationToken.None);
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*Phase=DaDuyet*");
+ }
+}
diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationWorkflowServiceReturnModeTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationWorkflowServiceReturnModeTests.cs
new file mode 100644
index 0000000..9061759
--- /dev/null
+++ b/tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationWorkflowServiceReturnModeTests.cs
@@ -0,0 +1,378 @@
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.DependencyInjection;
+using SolutionErp.Application.Common.Exceptions;
+using SolutionErp.Application.Notifications;
+using SolutionErp.Application.PurchaseEvaluations.Services; // WorkflowReturnMode enum
+using SolutionErp.Domain.ApprovalWorkflowsV2;
+using SolutionErp.Domain.Common;
+using SolutionErp.Domain.Contracts; // ApprovalDecision shared
+using SolutionErp.Domain.Identity;
+using SolutionErp.Domain.Notifications;
+using SolutionErp.Domain.PurchaseEvaluations;
+using SolutionErp.Infrastructure.Services;
+using SolutionErp.Infrastructure.Tests.Common;
+
+namespace SolutionErp.Infrastructure.Tests.Services;
+
+// Plan C task 1-2 catch-up cho S21 t4-t5 feature:
+// - ApplyReturnModeAsync 4 mode đọc level.Allow* per-NV (Mig 29 refactor từ workflow-level Mig 28)
+// - skipToFinal đọc user.AllowDrafterSkipToFinal per-Drafter (Mig 29 split scope theo role)
+//
+// Focus: defensive boundary check + admin bypass invariant. KHÔNG cover toàn bộ
+// edge case (Bước 1 Cấp 1 fallback, Assignee runtime pick, V1 legacy fallback) —
+// defer khi UAT lộ regression cụ thể.
+public class PurchaseEvaluationWorkflowServiceReturnModeTests
+{
+ private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix, TestApplicationDbContext db, FixedDateTime dt)
+ CreateService()
+ {
+ var fix = new IdentityFixture();
+ var db = fix.Services.GetRequiredService();
+ var um = fix.Services.GetRequiredService>();
+ var dt = new FixedDateTime(new DateTime(2026, 5, 13, 0, 0, 0, DateTimeKind.Utc));
+ var notify = new NoOpNotificationService();
+ var svc = new PurchaseEvaluationWorkflowService(db, dt, notify, um);
+ return (svc, fix, db, dt);
+ }
+
+ // Workflow setup: 1 Bước (1 Step) — 2 Cấp (2 Levels) — mỗi Cấp 1 Approver.
+ // Mặc định mọi Allow* = false trên Level slot (admin opt-in pattern Mig 29).
+ // ApproverUserId mặc định = approverId truyền vào (caller có thể override).
+ private static async Task<(ApprovalWorkflow wf, ApprovalWorkflowStep step, ApprovalWorkflowLevel l1, ApprovalWorkflowLevel l2)>
+ SeedWorkflowAsync(
+ TestApplicationDbContext db,
+ Guid approver1UserId,
+ Guid approver2UserId,
+ bool allowReturnOneLevelL2 = false,
+ bool allowReturnToDrafterL2 = false,
+ bool allowApproverEditL2 = false)
+ {
+ var wf = new ApprovalWorkflow
+ {
+ Id = Guid.NewGuid(),
+ Code = "QT-TEST-001",
+ Version = 1,
+ Name = "Test Workflow per-NV",
+ ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
+ IsActive = true,
+ IsUserSelectable = true,
+ };
+ var step = new ApprovalWorkflowStep
+ {
+ Id = Guid.NewGuid(),
+ ApprovalWorkflowId = wf.Id,
+ Order = 1,
+ DepartmentId = null, // Step.DepartmentId là hint nullable — skip FK seeding Department trong test
+ Name = "Bước 1 CCM",
+ };
+ var l1 = new ApprovalWorkflowLevel
+ {
+ Id = Guid.NewGuid(),
+ ApprovalWorkflowStepId = step.Id,
+ Order = 1,
+ ApproverUserId = approver1UserId,
+ // L1 defaults: Allow* = false (test sad path easier)
+ };
+ var l2 = new ApprovalWorkflowLevel
+ {
+ Id = Guid.NewGuid(),
+ ApprovalWorkflowStepId = step.Id,
+ Order = 2,
+ ApproverUserId = approver2UserId,
+ AllowReturnOneLevel = allowReturnOneLevelL2,
+ AllowReturnToDrafter = allowReturnToDrafterL2,
+ AllowApproverEditDetails = allowApproverEditL2,
+ };
+ db.ApprovalWorkflows.Add(wf);
+ db.ApprovalWorkflowSteps.Add(step);
+ db.ApprovalWorkflowLevels.Add(l1);
+ db.ApprovalWorkflowLevels.Add(l2);
+ await db.SaveChangesAsync(CancellationToken.None);
+ return (wf, step, l1, l2);
+ }
+
+ private static PurchaseEvaluation BuildPeAtLevel2(Guid workflowId, Guid drafterId, string code = "PE-RM-001")
+ {
+ return new PurchaseEvaluation
+ {
+ Id = Guid.NewGuid(),
+ Type = PurchaseEvaluationType.DuyetNcc,
+ Phase = PurchaseEvaluationPhase.ChoDuyet,
+ MaPhieu = code,
+ TenGoiThau = "Test return mode",
+ ProjectId = Guid.NewGuid(),
+ DrafterUserId = drafterId,
+ ApprovalWorkflowId = workflowId,
+ CurrentWorkflowStepIndex = 0, // Bước 1 (Step Order=1)
+ CurrentApprovalLevelOrder = 2, // Cấp 2 đang chờ duyệt
+ };
+ }
+
+ // ============ Task 1: ApplyReturnModeAsync ============
+
+ private static async Task<(User a1, User a2)> SeedApproversAsync(IdentityFixture fix, string suffix)
+ {
+ var a1 = await fix.CreateUserAsync($"approver1-{suffix}@test.local", $"Approver1 {suffix}", departmentId: null, roles: new[] { AppRoles.CostControl });
+ var a2 = await fix.CreateUserAsync($"approver2-{suffix}@test.local", $"Approver2 {suffix}", departmentId: null, roles: new[] { AppRoles.CostControl });
+ return (a1, a2);
+ }
+
+ [Fact]
+ public async Task ReturnMode_Drafter_AllowedByLevel_SetsPhaseTraLaiAndClearsPointer()
+ {
+ var (svc, fix, db, _) = CreateService();
+ using (fix)
+ {
+ var (a1, a2) = await SeedApproversAsync(fix, "rm1");
+ var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id, allowReturnToDrafterL2: true);
+ var pe = BuildPeAtLevel2(wf.Id, drafterId: Guid.NewGuid());
+ db.PurchaseEvaluations.Add(pe);
+ await db.SaveChangesAsync(CancellationToken.None);
+
+ await svc.TransitionAsync(
+ evaluation: pe,
+ targetPhase: PurchaseEvaluationPhase.TraLai,
+ actorUserId: a2.Id,
+ actorRoles: new[] { AppRoles.CostControl },
+ decision: ApprovalDecision.Reject,
+ comment: "trả về drafter sửa",
+ returnMode: WorkflowReturnMode.Drafter,
+ ct: CancellationToken.None);
+
+ pe.Phase.Should().Be(PurchaseEvaluationPhase.TraLai);
+ pe.CurrentWorkflowStepIndex.Should().BeNull("Drafter mode clear step pointer");
+ pe.CurrentApprovalLevelOrder.Should().BeNull("Drafter mode clear level pointer");
+ pe.SlaDeadline.Should().BeNull();
+ }
+ }
+
+ [Fact]
+ public async Task ReturnMode_Drafter_DeniedByLevel_NonAdmin_Throws()
+ {
+ // Level Allow*=false (default) + non-admin → throw ConflictException.
+ // Pattern: admin Designer KHÔNG tick flag cho Level slot này → Approver
+ // không được trả về Drafter mode (phải dùng mode khác hoặc Trả lại admin).
+ var (svc, fix, db, _) = CreateService();
+ using (fix)
+ {
+ var (a1, a2) = await SeedApproversAsync(fix, "rm2");
+ var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id, allowReturnToDrafterL2: false);
+ var pe = BuildPeAtLevel2(wf.Id, drafterId: Guid.NewGuid(), code: "PE-RM-002");
+ db.PurchaseEvaluations.Add(pe);
+ await db.SaveChangesAsync(CancellationToken.None);
+
+ var act = async () => await svc.TransitionAsync(
+ evaluation: pe,
+ targetPhase: PurchaseEvaluationPhase.TraLai,
+ actorUserId: a2.Id,
+ actorRoles: new[] { AppRoles.CostControl },
+ decision: ApprovalDecision.Reject,
+ comment: "test denied",
+ returnMode: WorkflowReturnMode.Drafter,
+ ct: CancellationToken.None);
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*Cấp Approver hiện tại không bật mode*Drafter*");
+ pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "Guard chặn trước mutate");
+ }
+ }
+
+ [Fact]
+ public async Task ReturnMode_OneLevel_AllowedByLevel_LowersLevelPointer()
+ {
+ // Level 2 → Level 1 trong cùng Step (peer review chain).
+ var (svc, fix, db, _) = CreateService();
+ using (fix)
+ {
+ var (a1, a2) = await SeedApproversAsync(fix, "rm3");
+ var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id, allowReturnOneLevelL2: true);
+ var pe = BuildPeAtLevel2(wf.Id, drafterId: Guid.NewGuid(), code: "PE-RM-003");
+ db.PurchaseEvaluations.Add(pe);
+ await db.SaveChangesAsync(CancellationToken.None);
+
+ await svc.TransitionAsync(
+ evaluation: pe,
+ targetPhase: PurchaseEvaluationPhase.TraLai,
+ actorUserId: a2.Id,
+ actorRoles: new[] { AppRoles.CostControl },
+ decision: ApprovalDecision.Reject,
+ comment: "trả về 1 Cấp",
+ returnMode: WorkflowReturnMode.OneLevel,
+ ct: CancellationToken.None);
+
+ pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
+ "OneLevel giữ ChoDuyet (peer review chain), KHÔNG về TraLai");
+ pe.CurrentApprovalLevelOrder.Should().Be(1, "Lùi 1 Cấp (từ 2 → 1)");
+ pe.CurrentWorkflowStepIndex.Should().Be(0, "Giữ Step hiện tại");
+ }
+ }
+
+ [Fact]
+ public async Task ReturnMode_OneLevel_DeniedByLevel_AdminBypass_Succeeds()
+ {
+ // Admin override workflow.Allow* check — vẫn được trả lại dù slot disabled.
+ var (svc, fix, db, _) = CreateService();
+ using (fix)
+ {
+ var (a1, a2) = await SeedApproversAsync(fix, "rm4");
+ var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id, allowReturnOneLevelL2: false);
+ var pe = BuildPeAtLevel2(wf.Id, drafterId: Guid.NewGuid(), code: "PE-RM-004");
+ db.PurchaseEvaluations.Add(pe);
+ await db.SaveChangesAsync(CancellationToken.None);
+
+ await svc.TransitionAsync(
+ evaluation: pe,
+ targetPhase: PurchaseEvaluationPhase.TraLai,
+ actorUserId: a2.Id,
+ actorRoles: new[] { AppRoles.Admin },
+ decision: ApprovalDecision.Reject,
+ comment: "admin override",
+ returnMode: WorkflowReturnMode.OneLevel,
+ ct: CancellationToken.None);
+
+ pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
+ pe.CurrentApprovalLevelOrder.Should().Be(1,
+ "Admin bypass Allow* flag — vẫn lùi pointer");
+ }
+ }
+
+ // ============ Task 2: skipToFinal (Drafter trình branch) ============
+
+ [Fact]
+ public async Task SkipToFinal_DrafterAllowed_SetsPointerToFinalLevel()
+ {
+ // Drafter user có AllowDrafterSkipToFinal=true → init pointer cuối step + cuối level.
+ var (svc, fix, db, _) = CreateService();
+ using (fix)
+ {
+ var (a1, a2) = await SeedApproversAsync(fix, "skip1");
+ var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id);
+ var drafter = await fix.CreateUserAsync(
+ "drafter.skip@test.local", "Drafter Skip", departmentId: null,
+ roles: new[] { AppRoles.Drafter });
+ drafter.AllowDrafterSkipToFinal = true;
+ await fix.Services.GetRequiredService>().UpdateAsync(drafter);
+
+ var pe = new PurchaseEvaluation
+ {
+ Id = Guid.NewGuid(),
+ Type = PurchaseEvaluationType.DuyetNcc,
+ Phase = PurchaseEvaluationPhase.DangSoanThao,
+ MaPhieu = "PE-SKIP-001",
+ TenGoiThau = "Skip to final",
+ ProjectId = Guid.NewGuid(),
+ DrafterUserId = drafter.Id,
+ ApprovalWorkflowId = wf.Id,
+ };
+ db.PurchaseEvaluations.Add(pe);
+ await db.SaveChangesAsync(CancellationToken.None);
+
+ await svc.TransitionAsync(
+ evaluation: pe,
+ targetPhase: PurchaseEvaluationPhase.ChoDuyet,
+ actorUserId: drafter.Id,
+ actorRoles: new[] { AppRoles.Drafter },
+ decision: ApprovalDecision.Approve,
+ comment: "gửi thẳng cấp cuối",
+ skipToFinal: true,
+ ct: CancellationToken.None);
+
+ pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
+ pe.CurrentWorkflowStepIndex.Should().Be(0, "Step duy nhất (chỉ 1 Step) = index 0");
+ pe.CurrentApprovalLevelOrder.Should().Be(2,
+ "Final Level Order = 2 (Cấp cuối Bước cuối)");
+ }
+ }
+
+ [Fact]
+ public async Task SkipToFinal_DrafterDenied_NonAdmin_Throws()
+ {
+ // Drafter user có AllowDrafterSkipToFinal=false (default) + non-admin → throw.
+ var (svc, fix, db, _) = CreateService();
+ using (fix)
+ {
+ var (a1, a2) = await SeedApproversAsync(fix, "skip2");
+ var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id);
+ var drafter = await fix.CreateUserAsync(
+ "drafter.noskip@test.local", "Drafter NoSkip", departmentId: null,
+ roles: new[] { AppRoles.Drafter });
+ // drafter.AllowDrafterSkipToFinal = false (default)
+
+ var pe = new PurchaseEvaluation
+ {
+ Id = Guid.NewGuid(),
+ Type = PurchaseEvaluationType.DuyetNcc,
+ Phase = PurchaseEvaluationPhase.DangSoanThao,
+ MaPhieu = "PE-SKIP-002",
+ TenGoiThau = "Skip denied",
+ ProjectId = Guid.NewGuid(),
+ DrafterUserId = drafter.Id,
+ ApprovalWorkflowId = wf.Id,
+ };
+ db.PurchaseEvaluations.Add(pe);
+ await db.SaveChangesAsync(CancellationToken.None);
+
+ var act = async () => await svc.TransitionAsync(
+ evaluation: pe,
+ targetPhase: PurchaseEvaluationPhase.ChoDuyet,
+ actorUserId: drafter.Id,
+ actorRoles: new[] { AppRoles.Drafter },
+ decision: ApprovalDecision.Approve,
+ comment: "test denied",
+ skipToFinal: true,
+ ct: CancellationToken.None);
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*không được phép gửi thẳng Cấp cuối*");
+
+ // Service mutate Phase=ChoDuyet TRƯỚC khi validate skipToFinal flag,
+ // throw chặn SaveChangesAsync → DB không persist. Test focus contract
+ // throw, không assert in-memory rollback (note: nếu future refactor
+ // move validate trước mutate, test này vẫn pass).
+ pe.CurrentWorkflowStepIndex.Should().BeNull("Skip flow throw trước khi init pointer");
+ pe.CurrentApprovalLevelOrder.Should().BeNull("Pointer chưa init khi throw");
+ }
+ }
+
+ [Fact]
+ public async Task SkipToFinal_AdminBypass_Succeeds()
+ {
+ // Admin role bypass user.AllowDrafterSkipToFinal flag check.
+ var (svc, fix, db, _) = CreateService();
+ using (fix)
+ {
+ var (a1, a2) = await SeedApproversAsync(fix, "skip3");
+ var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id);
+ var adminUser = await fix.CreateUserAsync(
+ "admin.skip@test.local", "Admin Skip", departmentId: null,
+ roles: new[] { AppRoles.Admin });
+
+ var pe = new PurchaseEvaluation
+ {
+ Id = Guid.NewGuid(),
+ Type = PurchaseEvaluationType.DuyetNcc,
+ Phase = PurchaseEvaluationPhase.DangSoanThao,
+ MaPhieu = "PE-SKIP-003",
+ TenGoiThau = "Admin skip",
+ ProjectId = Guid.NewGuid(),
+ DrafterUserId = adminUser.Id,
+ ApprovalWorkflowId = wf.Id,
+ };
+ db.PurchaseEvaluations.Add(pe);
+ await db.SaveChangesAsync(CancellationToken.None);
+
+ await svc.TransitionAsync(
+ evaluation: pe,
+ targetPhase: PurchaseEvaluationPhase.ChoDuyet,
+ actorUserId: adminUser.Id,
+ actorRoles: new[] { AppRoles.Admin },
+ decision: ApprovalDecision.Approve,
+ comment: "admin gửi thẳng",
+ skipToFinal: true,
+ ct: CancellationToken.None);
+
+ pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
+ pe.CurrentApprovalLevelOrder.Should().Be(2, "Admin bypass + skip → pointer cuối");
+ }
+ }
+}