diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs index c34c5e0..da162e5 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs @@ -41,6 +41,12 @@ public class CreatePurchaseEvaluationCommandValidator : AbstractValidator x.WorkItemId).NotEmpty() .WithMessage("Phải chọn hạng mục công việc."); + // [S83 bug fix] BE enforce quy trình duyệt V2 — đóng lỗ hổng validate + // FE-only (FE canSubmit bắt buộc nhưng BE chỉ validate-nếu-có-truyền → + // phiếu null-workflow lọt vào ChoDuyet = kẹt V1-legacy "quy trình cũ"). + // NotEmpty chặn cả null lẫn Guid.Empty. + RuleFor(x => x.ApprovalWorkflowId).NotEmpty() + .WithMessage("Phải chọn quy trình duyệt."); RuleFor(x => x.DiaDiem).MaximumLength(500); RuleFor(x => x.MoTa).MaximumLength(2000); // [HoSoLink] MaxLength MATCH EF config HasMaxLength(1000) (S35 lesson). diff --git a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs index 96236ba..b078ebe 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs @@ -215,6 +215,18 @@ public class PurchaseEvaluationWorkflowService( + string.Join(" · ", missing) + "."); } + // [S83 bug fix] Guard require-workflow: KHÔNG cho gửi duyệt phiếu chưa + // pin quy trình duyệt V2 (ApprovalWorkflowId null → kẹt nhánh V1-legacy: + // không Bước/Cấp, banner "quy trình cũ", không route duyệt được). FE bắt + // buộc lúc create (canSubmit) nhưng BE phải enforce defense-in-depth + // (validate FE-only = lỗ hổng, họ gotcha #44). Đặt SAU Section-3 + // (orthogonal — ưu tiên báo thiếu data) NHƯNG TRƯỚC mutate phase. Data + // V1 cũ đã wipe S59 → chặn V1-submit an toàn (mọi phiếu mới = V2). + if (evaluation.ApprovalWorkflowId is null) + throw new ConflictException( + "Phiếu chưa chọn quy trình duyệt — không thể gửi duyệt. " + + "Vui lòng mở Sửa, chọn \"Quy trình duyệt\" rồi gửi lại."); + evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet; // Mig 31 (S23 t1 Plan K) — F2 Drafter-skip-from-Nháp semantic deprecated. diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/PeWorkItemGuardTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/PeWorkItemGuardTests.cs index 75a40e5..c881325 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Application/PeWorkItemGuardTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Application/PeWorkItemGuardTests.cs @@ -74,7 +74,7 @@ public class PeWorkItemGuardTests } private static CreatePurchaseEvaluationCommand BuildCreateCommand( - Guid projectId, Guid? workItemId) + Guid projectId, Guid? workItemId, Guid? approvalWorkflowId = null) => new( Type: PurchaseEvaluationType.DuyetNcc, TenGoiThau: "Gói thầu test", @@ -84,7 +84,7 @@ public class PeWorkItemGuardTests MoTa: null, PaymentTerms: null, BudgetPeriodAmount: null, - ApprovalWorkflowId: null, + ApprovalWorkflowId: approvalWorkflowId, WorkItemId: workItemId); // ============================================================ @@ -127,10 +127,10 @@ public class PeWorkItemGuardTests [Fact] public void Validator_WorkItemIdPresent_NoErrorOnWorkItemId() { - // Chỉ assert rule WorkItemId pass — command còn lại đã hợp lệ ở BuildCreateCommand - // nên result.IsValid=true; nhưng narrow assert vào property để test đúng rule này. + // Chỉ assert rule WorkItemId pass — command còn lại đã hợp lệ ở BuildCreateCommand. + // [S83] +ApprovalWorkflowId (cũng NotEmpty từ S83) để command FULL-valid → IsValid=true. var validator = new CreatePurchaseEvaluationCommandValidator(); - var cmd = BuildCreateCommand(Guid.NewGuid(), workItemId: Guid.NewGuid()); + var cmd = BuildCreateCommand(Guid.NewGuid(), workItemId: Guid.NewGuid(), approvalWorkflowId: Guid.NewGuid()); var result = validator.Validate(cmd); @@ -138,6 +138,38 @@ public class PeWorkItemGuardTests result.IsValid.Should().BeTrue(); } + // ============================================================ + // 1b. VALIDATOR — RuleFor(ApprovalWorkflowId).NotEmpty() [S83 bug fix] + // ============================================================ + // Đóng lỗ hổng validate FE-only: phiếu null-workflow lọt vào ChoDuyet = kẹt nhánh + // V1-legacy ("quy trình cũ", không Bước/Cấp, không route duyệt). FE canSubmit bắt + // buộc chọn quy trình lúc create → BE validator phải mirror (defense-in-depth). + + [Fact] + public void Validator_ApprovalWorkflowIdNull_IsInvalid_WithErrorOnWorkflow() + { + var validator = new CreatePurchaseEvaluationCommandValidator(); + var cmd = BuildCreateCommand(Guid.NewGuid(), workItemId: Guid.NewGuid(), approvalWorkflowId: null); + + var result = validator.Validate(cmd); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain( + e => e.PropertyName == nameof(CreatePurchaseEvaluationCommand.ApprovalWorkflowId)); + } + + [Fact] + public void Validator_ApprovalWorkflowIdPresent_NoErrorOnWorkflow() + { + var validator = new CreatePurchaseEvaluationCommandValidator(); + var cmd = BuildCreateCommand(Guid.NewGuid(), workItemId: Guid.NewGuid(), approvalWorkflowId: Guid.NewGuid()); + + var result = validator.Validate(cmd); + + result.Errors.Should().NotContain( + e => e.PropertyName == nameof(CreatePurchaseEvaluationCommand.ApprovalWorkflowId)); + } + // ============================================================ // 2. CREATE HANDLER — FK-invariant guard // ============================================================ diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/PeSubmitGuardAndBypassTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/PeSubmitGuardAndBypassTests.cs index 4b0cf8d..8625c4a 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Services/PeSubmitGuardAndBypassTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Services/PeSubmitGuardAndBypassTests.cs @@ -34,6 +34,12 @@ namespace SolutionErp.Infrastructure.Tests.Services; // // LƯU Ý GUARD-FIRST: submit guard chạy TRƯỚC bypass → mọi test bypass phải dựng // PE ĐỦ 4 điều kiện Section 3 (winner + quote>0 + manual budget + comparison file). +// +// [S83 spec change] Thêm guard require-workflow (SAU Section-3, TRƯỚC mutate phase): +// phiếu KHÔNG pin ApprovalWorkflowId KHÔNG được gửi duyệt (đóng lỗ hổng "quy trình +// cũ" — phiếu null-workflow kẹt nhánh V1-legacy). Test (7) + (13) cập nhật từ +// "submit OK no-workflow" → "throws". V1-submit deprecated (data V1 cũ wipe S59, +// mọi phiếu mới = V2). Bypass tests (9-12,14) pin workflow → KHÔNG ảnh hưởng. public class PeSubmitGuardAndBypassTests { private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix, @@ -337,12 +343,13 @@ public class PeSubmitGuardAndBypassTests } [Fact] - public async Task Submit_AllFourMet_ManualBudget_NoWorkflow_SetsChoDuyet() + public async Task Submit_AllFourMet_NoWorkflow_ThrowsWorkflowRequired_S83() { - // (7) Đủ 4 (manual budget > 0, KHÔNG BudgetId, KHÔNG ApprovalWorkflowId) → - // submit OK Phase=ChoDuyet. V1/no-workflow: pointer StepIdx=0, Level null - // (line 208 — chỉ init level=1 nếu V2). - var (svc, fix, db, clock) = CreateService(); + // (7) [S83 spec change] Đủ 4 Section 3 NHƯNG KHÔNG pin ApprovalWorkflowId → + // submit BỊ CHẶN. Trước S83: set ChoDuyet với level pointer null (V1-legacy); + // từ S83: require-workflow guard (SAU Section-3, TRƯỚC mutate) → Conflict, + // phiếu giữ Nháp. Đóng lỗ hổng "quy trình cũ" (phiếu null-workflow kẹt V1). + var (svc, fix, db, _) = CreateService(); using (fix) { var pe = BuildPeNhap(budgetPeriodAmount: 750_000m); @@ -353,12 +360,12 @@ public class PeSubmitGuardAndBypassTests SeedComparisonAttachment(db, pe); await db.SaveChangesAsync(CancellationToken.None); - await SubmitAsync(svc, pe, Guid.NewGuid()); + var act = () => SubmitAsync(svc, pe, Guid.NewGuid()); - pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet); - pe.CurrentWorkflowStepIndex.Should().Be(0); - pe.CurrentApprovalLevelOrder.Should().BeNull("phiếu không pin V2 → level pointer null"); - pe.SlaDeadline.Should().Be(clock.UtcNow.AddDays(7)); + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().Contain("quy trình duyệt"); + pe.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao, + "guard require-workflow chặn TRƯỚC mutate phase"); } } @@ -541,10 +548,12 @@ public class PeSubmitGuardAndBypassTests } [Fact] - public async Task Submit_V1Phieu_NoApprovalWorkflowId_SubmitsOk_NoBypass_NoCrash() + public async Task Submit_V1Phieu_NoApprovalWorkflowId_NowBlocked_S83() { - // (13) Phiếu V1 (ApprovalWorkflowId null) → submit OK, KHÔNG bypass (V2-only), - // KHÔNG crash. Đủ 4 điều kiện Section 3 vẫn áp. + // (13) [S83 spec change] Trước S83: phiếu V1 (ApprovalWorkflowId null) submit OK + // (V1-legacy). Từ S83: require-workflow guard CHẶN — V1-submit deprecated (data + // V1 cũ đã wipe S59, mọi phiếu mới = V2). Đủ 4 Section 3 vẫn bị chặn ở bước + // workflow. Để gửi duyệt: mở Sửa chọn quy trình V2. var (svc, fix, db, _) = CreateService(); using (fix) { @@ -557,15 +566,12 @@ public class PeSubmitGuardAndBypassTests SeedComparisonAttachment(db, pe); await db.SaveChangesAsync(CancellationToken.None); - await SubmitAsync(svc, pe, drafter); + var act = () => SubmitAsync(svc, pe, drafter); - pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet); - pe.CurrentApprovalLevelOrder.Should().BeNull("V1 → level pointer null, không bypass"); - - var autoApprovals = await db.PurchaseEvaluationApprovals - .Where(a => a.PurchaseEvaluationId == pe.Id - && a.Decision == ApprovalDecision.AutoApprove).ToListAsync(); - autoApprovals.Should().BeEmpty("V1 không bypass → 0 AutoApprove row"); + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().Contain("quy trình duyệt"); + pe.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao, + "V1-submit deprecated S83 → giữ Nháp"); } }