[CLAUDE] PurchaseEvaluation: require quy trinh duyet V2 o create+submit (dong lo hong quy-trinh-cu)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m58s

Validator NotEmpty(ApprovalWorkflowId) + submit guard (sau Section-3, truoc mutate phase). Dong lo hong validate FE-only -> phieu null-workflow ket nhanh V1-legacy "quy trinh cu" (khong Buoc/Cap, khong route duyet). Test-before (RED confirmed): +2 validator test (PeWorkItemGuardTests) + rewrite test 7/13 PeSubmitGuardAndBypass (V1-submit deprecated, data V1 wipe S59). 356 pass (45D+311I).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-22 17:06:46 +07:00
parent 2c7fd635b9
commit fc1f19db8c
4 changed files with 82 additions and 26 deletions

View File

@ -41,6 +41,12 @@ public class CreatePurchaseEvaluationCommandValidator : AbstractValidator<Create
// async-db; mirror S43 CreateLeaveRequestHandler FK-invariant guard).
RuleFor(x => 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).

View File

@ -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.

View File

@ -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
// ============================================================

View File

@ -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<ConflictException>();
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<ConflictException>();
ex.Which.Message.Should().Contain("quy trình duyệt");
pe.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao,
"V1-submit deprecated S83 → giữ Nháp");
}
}