diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 02bfa04..b080165 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -29,6 +29,30 @@ jobs:
& 'C:\Program Files\nodejs\node.exe' --version
& 'C:\Program Files\nodejs\npm.cmd' --version
+ # ============== TEST GATE ==============
+ # Run unit tests TRƯỚC build/publish/deploy. Test fail → exit non-zero
+ # → toàn bộ job stop, KHÔNG deploy. Phase 1 chỉ Domain layer (~54 test
+ # phase machine + policy registry). Mở rộng Application/Infra/Api
+ # khi có nhu cầu (xem docs/changelog/migration-todos.md).
+ - name: Run unit tests (Domain)
+ shell: powershell
+ run: |
+ & 'C:\Program Files\dotnet\dotnet.exe' test tests/SolutionErp.Domain.Tests/SolutionErp.Domain.Tests.csproj `
+ --configuration Release `
+ --logger "trx;LogFileName=domain-tests.trx" `
+ --results-directory test-results
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+ - name: Upload test results
+ if: always()
+ continue-on-error: true # nếu Gitea runner chưa có upload-artifact action, skip không block deploy
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-results
+ path: test-results/*.trx
+ retention-days: 14
+
+ # ============== BUILD ==============
- name: Build backend
shell: powershell
run: |
diff --git a/SolutionErp.slnx b/SolutionErp.slnx
index 9e44d7a..d616e0c 100644
--- a/SolutionErp.slnx
+++ b/SolutionErp.slnx
@@ -6,4 +6,7 @@
+
+
+
diff --git a/tests/SolutionErp.Domain.Tests/Budgets/BudgetPolicyTests.cs b/tests/SolutionErp.Domain.Tests/Budgets/BudgetPolicyTests.cs
new file mode 100644
index 0000000..af71068
--- /dev/null
+++ b/tests/SolutionErp.Domain.Tests/Budgets/BudgetPolicyTests.cs
@@ -0,0 +1,134 @@
+using SolutionErp.Domain.Budgets;
+using SolutionErp.Domain.Identity;
+
+namespace SolutionErp.Domain.Tests.Budgets;
+
+// Tests cho BudgetPolicy (hardcoded simple 3-step Default).
+// Chống regression khi BudgetPhase enum thêm phase hoặc role mapping bị edit.
+
+public class BudgetPolicyTests
+{
+ [Fact]
+ public void Default_Drafter_DangSoanThao_To_ChoCCM_Allowed()
+ {
+ BudgetPolicies.Default
+ .IsTransitionAllowed(BudgetPhase.DangSoanThao, BudgetPhase.ChoCCM,
+ [AppRoles.Drafter])
+ .Should().BeTrue();
+ }
+
+ [Fact]
+ public void Default_DeptManager_DangSoanThao_To_ChoCCM_Allowed()
+ {
+ BudgetPolicies.Default
+ .IsTransitionAllowed(BudgetPhase.DangSoanThao, BudgetPhase.ChoCCM,
+ [AppRoles.DeptManager])
+ .Should().BeTrue();
+ }
+
+ [Fact]
+ public void Default_RandomRole_DangSoanThao_To_ChoCCM_Denied()
+ {
+ BudgetPolicies.Default
+ .IsTransitionAllowed(BudgetPhase.DangSoanThao, BudgetPhase.ChoCCM,
+ [AppRoles.Procurement])
+ .Should().BeFalse();
+ }
+
+ [Fact]
+ public void Default_CostControl_ChoCCM_To_ChoCEO_Allowed()
+ {
+ BudgetPolicies.Default
+ .IsTransitionAllowed(BudgetPhase.ChoCCM, BudgetPhase.ChoCEO,
+ [AppRoles.CostControl])
+ .Should().BeTrue();
+ }
+
+ [Fact]
+ public void Default_CostControl_ChoCCM_To_DangSoanThao_Allowed()
+ {
+ // Trả về Drafter
+ BudgetPolicies.Default
+ .IsTransitionAllowed(BudgetPhase.ChoCCM, BudgetPhase.DangSoanThao,
+ [AppRoles.CostControl])
+ .Should().BeTrue();
+ }
+
+ [Fact]
+ public void Default_Director_ChoCEO_To_DaDuyet_Allowed()
+ {
+ BudgetPolicies.Default
+ .IsTransitionAllowed(BudgetPhase.ChoCEO, BudgetPhase.DaDuyet,
+ [AppRoles.Director])
+ .Should().BeTrue();
+
+ BudgetPolicies.Default
+ .IsTransitionAllowed(BudgetPhase.ChoCEO, BudgetPhase.DaDuyet,
+ [AppRoles.AuthorizedSigner])
+ .Should().BeTrue();
+ }
+
+ [Fact]
+ public void Default_CCM_Cannot_Approve_To_DaDuyet()
+ {
+ // CCM chỉ chuyển đến ChoCEO, không tự duyệt thành DaDuyet
+ BudgetPolicies.Default
+ .IsTransitionAllowed(BudgetPhase.ChoCEO, BudgetPhase.DaDuyet,
+ [AppRoles.CostControl])
+ .Should().BeFalse();
+ }
+
+ [Fact]
+ public void Default_DaDuyet_NoFurtherTransitions()
+ {
+ BudgetPolicies.Default.NextPhasesFrom(BudgetPhase.DaDuyet)
+ .Should().BeEmpty("DaDuyet là terminal");
+ }
+
+ [Fact]
+ public void Default_TuChoi_NoFurtherTransitions()
+ {
+ BudgetPolicies.Default.NextPhasesFrom(BudgetPhase.TuChoi)
+ .Should().BeEmpty("TuChoi là terminal");
+ }
+
+ [Fact]
+ public void Default_ActivePhases_Includes_All5States()
+ {
+ BudgetPolicies.Default.ActivePhases.Should().BeEquivalentTo(new[]
+ {
+ BudgetPhase.DangSoanThao, BudgetPhase.ChoCCM,
+ BudgetPhase.ChoCEO, BudgetPhase.DaDuyet, BudgetPhase.TuChoi,
+ });
+ }
+
+ [Fact]
+ public void Default_NextPhasesFrom_DangSoanThao_Includes_ChoCCM_And_TuChoi()
+ {
+ var next = BudgetPolicies.Default.NextPhasesFrom(BudgetPhase.DangSoanThao);
+ next.Should().Contain(BudgetPhase.ChoCCM);
+ next.Should().Contain(BudgetPhase.TuChoi);
+ }
+
+ [Fact]
+ public void Default_NextPhasesFrom_ChoCEO_Includes_DaDuyet_And_DangSoanThao()
+ {
+ var next = BudgetPolicies.Default.NextPhasesFrom(BudgetPhase.ChoCEO);
+ next.Should().Contain(BudgetPhase.DaDuyet);
+ next.Should().Contain(BudgetPhase.DangSoanThao);
+ }
+
+ // SLA — chống regression khi đổi phase deadline accidentally
+ [Fact]
+ public void Default_SlaDeadlines_Match_Spec()
+ {
+ BudgetPolicies.Default.PhaseSla[BudgetPhase.DangSoanThao]
+ .Should().Be(TimeSpan.FromDays(5));
+ BudgetPolicies.Default.PhaseSla[BudgetPhase.ChoCCM]
+ .Should().Be(TimeSpan.FromDays(3));
+ BudgetPolicies.Default.PhaseSla[BudgetPhase.ChoCEO]
+ .Should().Be(TimeSpan.FromDays(2));
+ BudgetPolicies.Default.PhaseSla[BudgetPhase.DaDuyet]
+ .Should().BeNull("Terminal phase không có SLA");
+ }
+}
diff --git a/tests/SolutionErp.Domain.Tests/Contracts/WorkflowPolicyTests.cs b/tests/SolutionErp.Domain.Tests/Contracts/WorkflowPolicyTests.cs
new file mode 100644
index 0000000..3d3242f
--- /dev/null
+++ b/tests/SolutionErp.Domain.Tests/Contracts/WorkflowPolicyTests.cs
@@ -0,0 +1,227 @@
+using SolutionErp.Domain.Contracts;
+using SolutionErp.Domain.Identity;
+
+namespace SolutionErp.Domain.Tests.Contracts;
+
+// Tests for WorkflowPolicy state machine — pure functions, no DB / IO.
+// Mục tiêu: chống regression khi policy table bị edit nhầm hoặc khi ContractPhase
+// enum thêm phase mới mà quên update policy. ALL fast in-memory.
+
+public class WorkflowPolicyTests
+{
+ // ===== Standard policy (HĐ Thầu phụ / NCC / Giao khoán — full CCM) =====
+
+ [Fact]
+ public void Standard_Drafter_DangSoanThao_To_DangGopY_Allowed()
+ {
+ WorkflowPolicies.Standard
+ .IsTransitionAllowed(ContractPhase.DangSoanThao, ContractPhase.DangGopY,
+ [AppRoles.Drafter])
+ .Should().BeTrue();
+ }
+
+ [Fact]
+ public void Standard_DeptManager_DangSoanThao_To_DangGopY_Allowed()
+ {
+ WorkflowPolicies.Standard
+ .IsTransitionAllowed(ContractPhase.DangSoanThao, ContractPhase.DangGopY,
+ [AppRoles.DeptManager])
+ .Should().BeTrue();
+ }
+
+ [Fact]
+ public void Standard_RandomRole_DangSoanThao_To_DangGopY_Denied()
+ {
+ WorkflowPolicies.Standard
+ .IsTransitionAllowed(ContractPhase.DangSoanThao, ContractPhase.DangGopY,
+ [AppRoles.Procurement])
+ .Should().BeFalse();
+ }
+
+ [Fact]
+ public void Standard_HasCcmPhase_InActivePhases()
+ {
+ WorkflowPolicies.Standard.HasPhase(ContractPhase.DangKiemTraCCM)
+ .Should().BeTrue("Standard policy phải có CCM phase");
+ }
+
+ [Fact]
+ public void Standard_CcmRole_DangKiemTraCCM_To_DangTrinhKy_Allowed()
+ {
+ WorkflowPolicies.Standard
+ .IsTransitionAllowed(ContractPhase.DangKiemTraCCM, ContractPhase.DangTrinhKy,
+ [AppRoles.CostControl])
+ .Should().BeTrue();
+ }
+
+ [Fact]
+ public void Standard_DangTrinhKy_To_DangDongDau_RequiresDirectorOrSigner()
+ {
+ WorkflowPolicies.Standard
+ .IsTransitionAllowed(ContractPhase.DangTrinhKy, ContractPhase.DangDongDau,
+ [AppRoles.Director])
+ .Should().BeTrue();
+
+ WorkflowPolicies.Standard
+ .IsTransitionAllowed(ContractPhase.DangTrinhKy, ContractPhase.DangDongDau,
+ [AppRoles.AuthorizedSigner])
+ .Should().BeTrue();
+
+ WorkflowPolicies.Standard
+ .IsTransitionAllowed(ContractPhase.DangTrinhKy, ContractPhase.DangDongDau,
+ [AppRoles.CostControl])
+ .Should().BeFalse();
+ }
+
+ [Fact]
+ public void Standard_HrAdmin_DangDongDau_To_DaPhatHanh_Allowed()
+ {
+ WorkflowPolicies.Standard
+ .IsTransitionAllowed(ContractPhase.DangDongDau, ContractPhase.DaPhatHanh,
+ [AppRoles.HrAdmin])
+ .Should().BeTrue();
+ }
+
+ [Fact]
+ public void Standard_FromTerminal_DaPhatHanh_NoFurtherTransitions()
+ {
+ WorkflowPolicies.Standard.NextPhasesFrom(ContractPhase.DaPhatHanh)
+ .Should().BeEmpty("DaPhatHanh là terminal — không transition tiếp");
+ }
+
+ [Fact]
+ public void Standard_RejectFromCCM_GoesBackToDraft()
+ {
+ WorkflowPolicies.Standard
+ .IsTransitionAllowed(ContractPhase.DangKiemTraCCM, ContractPhase.DangSoanThao,
+ [AppRoles.CostControl])
+ .Should().BeTrue("CCM có quyền reject về Drafter");
+ }
+
+ [Fact]
+ public void Standard_NextPhasesFrom_Draft_Includes_DangGopY_And_TuChoi()
+ {
+ var next = WorkflowPolicies.Standard.NextPhasesFrom(ContractPhase.DangSoanThao);
+ next.Should().Contain(ContractPhase.DangGopY);
+ next.Should().Contain(ContractPhase.TuChoi);
+ }
+
+ // ===== SkipCcm policy (HĐ Dịch vụ / Mua bán / Nguyên tắc — bỏ phase CCM) =====
+
+ [Fact]
+ public void SkipCcm_DoesNotHave_DangKiemTraCCM_Phase()
+ {
+ WorkflowPolicies.SkipCcm.HasPhase(ContractPhase.DangKiemTraCCM)
+ .Should().BeFalse("SkipCcm policy bypass CCM phase");
+ }
+
+ [Fact]
+ public void SkipCcm_DangInKy_GoesDirectlyTo_DangTrinhKy()
+ {
+ WorkflowPolicies.SkipCcm
+ .IsTransitionAllowed(ContractPhase.DangInKy, ContractPhase.DangTrinhKy,
+ [AppRoles.Drafter])
+ .Should().BeTrue();
+ }
+
+ [Fact]
+ public void SkipCcm_NoTransition_To_DangKiemTraCCM()
+ {
+ WorkflowPolicies.SkipCcm
+ .IsTransitionAllowed(ContractPhase.DangInKy, ContractPhase.DangKiemTraCCM,
+ [AppRoles.Drafter])
+ .Should().BeFalse();
+ }
+
+ // ===== Registry mapping per ContractType =====
+
+ [Theory]
+ [InlineData(ContractType.HopDongThauPhu, "Standard")]
+ [InlineData(ContractType.HopDongGiaoKhoan, "Standard")]
+ [InlineData(ContractType.HopDongNhaCungCap, "Standard")]
+ [InlineData(ContractType.HopDongDichVu, "SkipCcm")]
+ [InlineData(ContractType.HopDongMuaBan, "SkipCcm")]
+ [InlineData(ContractType.HopDongNguyenTacNCC, "SkipCcm")]
+ [InlineData(ContractType.HopDongNguyenTacDichVu, "SkipCcm")]
+ public void Registry_DefaultPolicyName_MapsCorrectly(ContractType type, string expectedName)
+ {
+ WorkflowPolicyRegistry.DefaultPolicyNameFor(type)
+ .Should().Be(expectedName);
+ }
+
+ [Fact]
+ public void Registry_ForContract_BypassFlag_OverridesPolicy()
+ {
+ var contract = new Contract
+ {
+ Type = ContractType.HopDongThauPhu, // mặc định Standard
+ BypassProcurementAndCCM = true, // override → SkipCcm
+ };
+ WorkflowPolicyRegistry.ForContract(contract).Name
+ .Should().Be("SkipCcm");
+ }
+
+ // ===== FromDefinition (versioned admin-authored) =====
+
+ [Fact]
+ public void FromDefinition_BuildsPolicy_FromOrderedSteps()
+ {
+ var def = new WorkflowDefinition
+ {
+ Code = "QT-TEST",
+ Version = 1,
+ Name = "Test workflow",
+ ContractType = ContractType.HopDongMuaBan,
+ Steps =
+ [
+ new WorkflowStep { Order = 1, Phase = ContractPhase.DangSoanThao, Name = "Soạn",
+ Approvers = [new WorkflowStepApprover { Kind = WorkflowApproverKind.Role, AssignmentValue = AppRoles.Drafter }] },
+ new WorkflowStep { Order = 2, Phase = ContractPhase.DangGopY, Name = "Góp ý",
+ Approvers = [new WorkflowStepApprover { Kind = WorkflowApproverKind.Role, AssignmentValue = AppRoles.ProjectManager }] },
+ new WorkflowStep { Order = 3, Phase = ContractPhase.DaPhatHanh, Name = "Phát hành",
+ Approvers = [new WorkflowStepApprover { Kind = WorkflowApproverKind.Role, AssignmentValue = AppRoles.HrAdmin }] },
+ ],
+ };
+ var policy = WorkflowPolicyRegistry.FromDefinition(def);
+
+ policy.Name.Should().Be("QT-TEST-v01");
+ policy.IsTransitionAllowed(ContractPhase.DangSoanThao, ContractPhase.DangGopY, [AppRoles.ProjectManager])
+ .Should().BeTrue();
+ policy.IsTransitionAllowed(ContractPhase.DangGopY, ContractPhase.DaPhatHanh, [AppRoles.HrAdmin])
+ .Should().BeTrue();
+ policy.HasPhase(ContractPhase.TuChoi).Should().BeTrue("TuChoi luôn auto-thêm vào ActivePhases");
+ }
+
+ [Fact]
+ public void FromDefinition_UserKindApprover_PopulatesUserTransitions()
+ {
+ var userId = Guid.NewGuid().ToString();
+ var def = new WorkflowDefinition
+ {
+ Code = "QT-TEST",
+ Version = 1,
+ Name = "Test workflow user-kind",
+ ContractType = ContractType.HopDongMuaBan,
+ Steps =
+ [
+ new WorkflowStep { Order = 1, Phase = ContractPhase.DangSoanThao, Name = "Soạn", Approvers = [] },
+ new WorkflowStep { Order = 2, Phase = ContractPhase.DangGopY, Name = "Góp ý",
+ Approvers = [new WorkflowStepApprover { Kind = WorkflowApproverKind.User, AssignmentValue = userId }] },
+ ],
+ };
+ var policy = WorkflowPolicyRegistry.FromDefinition(def);
+
+ // Role nào cũng deny vì step 2 chỉ có User-kind
+ policy.IsTransitionAllowed(ContractPhase.DangSoanThao, ContractPhase.DangGopY,
+ [AppRoles.Drafter])
+ .Should().BeFalse();
+ // Đúng user-id → allow
+ policy.IsTransitionAllowed(ContractPhase.DangSoanThao, ContractPhase.DangGopY,
+ [AppRoles.Drafter], Guid.Parse(userId))
+ .Should().BeTrue();
+ // Sai user-id → deny
+ policy.IsTransitionAllowed(ContractPhase.DangSoanThao, ContractPhase.DangGopY,
+ [AppRoles.Drafter], Guid.NewGuid())
+ .Should().BeFalse();
+ }
+}
diff --git a/tests/SolutionErp.Domain.Tests/PurchaseEvaluations/PurchaseEvaluationPolicyTests.cs b/tests/SolutionErp.Domain.Tests/PurchaseEvaluations/PurchaseEvaluationPolicyTests.cs
new file mode 100644
index 0000000..17e36e1
--- /dev/null
+++ b/tests/SolutionErp.Domain.Tests/PurchaseEvaluations/PurchaseEvaluationPolicyTests.cs
@@ -0,0 +1,180 @@
+using SolutionErp.Domain.Contracts; // WorkflowApproverKind reuse
+using SolutionErp.Domain.Identity;
+using SolutionErp.Domain.PurchaseEvaluations;
+
+namespace SolutionErp.Domain.Tests.PurchaseEvaluations;
+
+// Tests cho PurchaseEvaluationPolicy — 2 quy trình A/B chính.
+// Mục tiêu: chống regression khi PEPhase thêm phase mới hoặc role mapping bị edit.
+
+public class PurchaseEvaluationPolicyTests
+{
+ // ===== A — DuyetNcc (NccOnly): 3-step (Drafter → Purchasing → CCM → CEO) =====
+
+ [Fact]
+ public void NccOnly_Drafter_DangSoanThao_To_ChoPurchasing_Allowed()
+ {
+ PurchaseEvaluationPolicies.NccOnly
+ .IsTransitionAllowed(PurchaseEvaluationPhase.DangSoanThao, PurchaseEvaluationPhase.ChoPurchasing,
+ [AppRoles.Drafter])
+ .Should().BeTrue();
+ }
+
+ [Fact]
+ public void NccOnly_Procurement_ChoPurchasing_To_ChoCCM_Allowed()
+ {
+ PurchaseEvaluationPolicies.NccOnly
+ .IsTransitionAllowed(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.ChoCCM,
+ [AppRoles.Procurement])
+ .Should().BeTrue();
+ }
+
+ [Fact]
+ public void NccOnly_CostControl_ChoCCM_GoesDirectlyTo_ChoCEODuyetNCC()
+ {
+ // A skip ChoDuAn + ChoCEODuyetPA — CCM đẩy thẳng CEO duyệt NCC
+ PurchaseEvaluationPolicies.NccOnly
+ .IsTransitionAllowed(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.ChoCEODuyetNCC,
+ [AppRoles.CostControl])
+ .Should().BeTrue();
+ }
+
+ [Fact]
+ public void NccOnly_DoesNotHave_ChoDuAn_Phase()
+ {
+ PurchaseEvaluationPolicies.NccOnly.HasPhase(PurchaseEvaluationPhase.ChoDuAn)
+ .Should().BeFalse("Quy trình A chỉ áp dụng khi không cần Dự án duyệt");
+ }
+
+ [Fact]
+ public void NccOnly_DoesNotHave_ChoCEODuyetPA_Phase()
+ {
+ PurchaseEvaluationPolicies.NccOnly.HasPhase(PurchaseEvaluationPhase.ChoCEODuyetPA)
+ .Should().BeFalse();
+ }
+
+ [Fact]
+ public void NccOnly_Director_FinalApproval_To_DaDuyet()
+ {
+ PurchaseEvaluationPolicies.NccOnly
+ .IsTransitionAllowed(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.DaDuyet,
+ [AppRoles.Director])
+ .Should().BeTrue();
+
+ PurchaseEvaluationPolicies.NccOnly
+ .IsTransitionAllowed(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.DaDuyet,
+ [AppRoles.AuthorizedSigner])
+ .Should().BeTrue();
+ }
+
+ // ===== B — DuyetNccPhuongAn (NccWithPlan): 5-step =====
+
+ [Fact]
+ public void NccWithPlan_Has_ChoDuAn_AndChoCEODuyetPA_Phases()
+ {
+ PurchaseEvaluationPolicies.NccWithPlan.HasPhase(PurchaseEvaluationPhase.ChoDuAn)
+ .Should().BeTrue();
+ PurchaseEvaluationPolicies.NccWithPlan.HasPhase(PurchaseEvaluationPhase.ChoCEODuyetPA)
+ .Should().BeTrue();
+ }
+
+ [Fact]
+ public void NccWithPlan_Procurement_ChoPurchasing_GoesTo_ChoDuAn_NotChoCCM()
+ {
+ // Khác với A — B đi qua Dự án trước CCM
+ PurchaseEvaluationPolicies.NccWithPlan
+ .IsTransitionAllowed(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.ChoDuAn,
+ [AppRoles.Procurement])
+ .Should().BeTrue();
+
+ PurchaseEvaluationPolicies.NccWithPlan
+ .IsTransitionAllowed(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.ChoCCM,
+ [AppRoles.Procurement])
+ .Should().BeFalse();
+ }
+
+ [Fact]
+ public void NccWithPlan_ProjectManager_ChoDuAn_To_ChoCCM_Allowed()
+ {
+ PurchaseEvaluationPolicies.NccWithPlan
+ .IsTransitionAllowed(PurchaseEvaluationPhase.ChoDuAn, PurchaseEvaluationPhase.ChoCCM,
+ [AppRoles.ProjectManager])
+ .Should().BeTrue();
+ }
+
+ [Fact]
+ public void NccWithPlan_CostControl_ChoCCM_GoesTo_ChoCEODuyetPA_NotDuyetNCC()
+ {
+ // B: CCM xong đi qua CEO duyệt PA trước, sau mới CEO duyệt NCC
+ PurchaseEvaluationPolicies.NccWithPlan
+ .IsTransitionAllowed(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.ChoCEODuyetPA,
+ [AppRoles.CostControl])
+ .Should().BeTrue();
+
+ PurchaseEvaluationPolicies.NccWithPlan
+ .IsTransitionAllowed(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.ChoCEODuyetNCC,
+ [AppRoles.CostControl])
+ .Should().BeFalse("CCM trong quy trình B không được skip CEO duyệt PA");
+ }
+
+ [Fact]
+ public void NccWithPlan_Director_ChoCEODuyetPA_To_ChoCEODuyetNCC()
+ {
+ PurchaseEvaluationPolicies.NccWithPlan
+ .IsTransitionAllowed(PurchaseEvaluationPhase.ChoCEODuyetPA, PurchaseEvaluationPhase.ChoCEODuyetNCC,
+ [AppRoles.Director])
+ .Should().BeTrue();
+ }
+
+ // ===== Reject paths chung cả 2 quy trình =====
+
+ [Theory]
+ [InlineData(nameof(PurchaseEvaluationPolicies.NccOnly))]
+ [InlineData(nameof(PurchaseEvaluationPolicies.NccWithPlan))]
+ public void BothPolicies_RejectFromCCM_GoesBackTo_DangSoanThao(string policyName)
+ {
+ var policy = policyName == nameof(PurchaseEvaluationPolicies.NccOnly)
+ ? PurchaseEvaluationPolicies.NccOnly
+ : PurchaseEvaluationPolicies.NccWithPlan;
+
+ policy.IsTransitionAllowed(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.DangSoanThao,
+ [AppRoles.CostControl])
+ .Should().BeTrue();
+ }
+
+ [Theory]
+ [InlineData(nameof(PurchaseEvaluationPolicies.NccOnly))]
+ [InlineData(nameof(PurchaseEvaluationPolicies.NccWithPlan))]
+ public void BothPolicies_DangSoanThao_To_TuChoi_DrafterOrDeptManager(string policyName)
+ {
+ var policy = policyName == nameof(PurchaseEvaluationPolicies.NccOnly)
+ ? PurchaseEvaluationPolicies.NccOnly
+ : PurchaseEvaluationPolicies.NccWithPlan;
+
+ policy.IsTransitionAllowed(PurchaseEvaluationPhase.DangSoanThao, PurchaseEvaluationPhase.TuChoi,
+ [AppRoles.Drafter])
+ .Should().BeTrue();
+ policy.IsTransitionAllowed(PurchaseEvaluationPhase.DangSoanThao, PurchaseEvaluationPhase.TuChoi,
+ [AppRoles.Procurement])
+ .Should().BeFalse();
+ }
+
+ // ===== Registry mapping per PEType =====
+
+ [Theory]
+ [InlineData(PurchaseEvaluationType.DuyetNcc, "NccOnly")]
+ [InlineData(PurchaseEvaluationType.DuyetNccPhuongAn, "NccWithPlan")]
+ public void Registry_DefaultPolicyName_MapsCorrectly(
+ PurchaseEvaluationType type, string expectedName)
+ {
+ PurchaseEvaluationPolicyRegistry.DefaultPolicyNameFor(type)
+ .Should().Be(expectedName);
+ }
+
+ [Fact]
+ public void Registry_For_TypeA_ReturnsNccOnlyPolicy()
+ {
+ PurchaseEvaluationPolicyRegistry.For(PurchaseEvaluationType.DuyetNcc).Name
+ .Should().Be("NccOnly");
+ }
+}
diff --git a/tests/SolutionErp.Domain.Tests/SolutionErp.Domain.Tests.csproj b/tests/SolutionErp.Domain.Tests/SolutionErp.Domain.Tests.csproj
new file mode 100644
index 0000000..7520290
--- /dev/null
+++ b/tests/SolutionErp.Domain.Tests/SolutionErp.Domain.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+