[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
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:
@ -41,6 +41,12 @@ public class CreatePurchaseEvaluationCommandValidator : AbstractValidator<Create
|
|||||||
// async-db; mirror S43 CreateLeaveRequestHandler FK-invariant guard).
|
// async-db; mirror S43 CreateLeaveRequestHandler FK-invariant guard).
|
||||||
RuleFor(x => x.WorkItemId).NotEmpty()
|
RuleFor(x => x.WorkItemId).NotEmpty()
|
||||||
.WithMessage("Phải chọn hạng mục công việc.");
|
.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.DiaDiem).MaximumLength(500);
|
||||||
RuleFor(x => x.MoTa).MaximumLength(2000);
|
RuleFor(x => x.MoTa).MaximumLength(2000);
|
||||||
// [HoSoLink] MaxLength MATCH EF config HasMaxLength(1000) (S35 lesson).
|
// [HoSoLink] MaxLength MATCH EF config HasMaxLength(1000) (S35 lesson).
|
||||||
|
|||||||
@ -215,6 +215,18 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
+ string.Join(" · ", missing) + ".");
|
+ 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;
|
evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet;
|
||||||
|
|
||||||
// Mig 31 (S23 t1 Plan K) — F2 Drafter-skip-from-Nháp semantic deprecated.
|
// Mig 31 (S23 t1 Plan K) — F2 Drafter-skip-from-Nháp semantic deprecated.
|
||||||
|
|||||||
@ -74,7 +74,7 @@ public class PeWorkItemGuardTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static CreatePurchaseEvaluationCommand BuildCreateCommand(
|
private static CreatePurchaseEvaluationCommand BuildCreateCommand(
|
||||||
Guid projectId, Guid? workItemId)
|
Guid projectId, Guid? workItemId, Guid? approvalWorkflowId = null)
|
||||||
=> new(
|
=> new(
|
||||||
Type: PurchaseEvaluationType.DuyetNcc,
|
Type: PurchaseEvaluationType.DuyetNcc,
|
||||||
TenGoiThau: "Gói thầu test",
|
TenGoiThau: "Gói thầu test",
|
||||||
@ -84,7 +84,7 @@ public class PeWorkItemGuardTests
|
|||||||
MoTa: null,
|
MoTa: null,
|
||||||
PaymentTerms: null,
|
PaymentTerms: null,
|
||||||
BudgetPeriodAmount: null,
|
BudgetPeriodAmount: null,
|
||||||
ApprovalWorkflowId: null,
|
ApprovalWorkflowId: approvalWorkflowId,
|
||||||
WorkItemId: workItemId);
|
WorkItemId: workItemId);
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@ -127,10 +127,10 @@ public class PeWorkItemGuardTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Validator_WorkItemIdPresent_NoErrorOnWorkItemId()
|
public void Validator_WorkItemIdPresent_NoErrorOnWorkItemId()
|
||||||
{
|
{
|
||||||
// Chỉ assert rule WorkItemId pass — command còn lại đã hợp lệ ở BuildCreateCommand
|
// 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.
|
// [S83] +ApprovalWorkflowId (cũng NotEmpty từ S83) để command FULL-valid → IsValid=true.
|
||||||
var validator = new CreatePurchaseEvaluationCommandValidator();
|
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);
|
var result = validator.Validate(cmd);
|
||||||
|
|
||||||
@ -138,6 +138,38 @@ public class PeWorkItemGuardTests
|
|||||||
result.IsValid.Should().BeTrue();
|
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
|
// 2. CREATE HANDLER — FK-invariant guard
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@ -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
|
// 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).
|
// 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
|
public class PeSubmitGuardAndBypassTests
|
||||||
{
|
{
|
||||||
private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix,
|
private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix,
|
||||||
@ -337,12 +343,13 @@ public class PeSubmitGuardAndBypassTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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) →
|
// (7) [S83 spec change] Đủ 4 Section 3 NHƯNG KHÔNG pin ApprovalWorkflowId →
|
||||||
// submit OK Phase=ChoDuyet. V1/no-workflow: pointer StepIdx=0, Level null
|
// submit BỊ CHẶN. Trước S83: set ChoDuyet với level pointer null (V1-legacy);
|
||||||
// (line 208 — chỉ init level=1 nếu V2).
|
// từ S83: require-workflow guard (SAU Section-3, TRƯỚC mutate) → Conflict,
|
||||||
var (svc, fix, db, clock) = CreateService();
|
// 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)
|
using (fix)
|
||||||
{
|
{
|
||||||
var pe = BuildPeNhap(budgetPeriodAmount: 750_000m);
|
var pe = BuildPeNhap(budgetPeriodAmount: 750_000m);
|
||||||
@ -353,12 +360,12 @@ public class PeSubmitGuardAndBypassTests
|
|||||||
SeedComparisonAttachment(db, pe);
|
SeedComparisonAttachment(db, pe);
|
||||||
await db.SaveChangesAsync(CancellationToken.None);
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
await SubmitAsync(svc, pe, Guid.NewGuid());
|
var act = () => SubmitAsync(svc, pe, Guid.NewGuid());
|
||||||
|
|
||||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
|
var ex = await act.Should().ThrowAsync<ConflictException>();
|
||||||
pe.CurrentWorkflowStepIndex.Should().Be(0);
|
ex.Which.Message.Should().Contain("quy trình duyệt");
|
||||||
pe.CurrentApprovalLevelOrder.Should().BeNull("phiếu không pin V2 → level pointer null");
|
pe.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao,
|
||||||
pe.SlaDeadline.Should().Be(clock.UtcNow.AddDays(7));
|
"guard require-workflow chặn TRƯỚC mutate phase");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -541,10 +548,12 @@ public class PeSubmitGuardAndBypassTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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),
|
// (13) [S83 spec change] Trước S83: phiếu V1 (ApprovalWorkflowId null) submit OK
|
||||||
// KHÔNG crash. Đủ 4 điều kiện Section 3 vẫn áp.
|
// (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();
|
var (svc, fix, db, _) = CreateService();
|
||||||
using (fix)
|
using (fix)
|
||||||
{
|
{
|
||||||
@ -557,15 +566,12 @@ public class PeSubmitGuardAndBypassTests
|
|||||||
SeedComparisonAttachment(db, pe);
|
SeedComparisonAttachment(db, pe);
|
||||||
await db.SaveChangesAsync(CancellationToken.None);
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
await SubmitAsync(svc, pe, drafter);
|
var act = () => SubmitAsync(svc, pe, drafter);
|
||||||
|
|
||||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
|
var ex = await act.Should().ThrowAsync<ConflictException>();
|
||||||
pe.CurrentApprovalLevelOrder.Should().BeNull("V1 → level pointer null, không bypass");
|
ex.Which.Message.Should().Contain("quy trình duyệt");
|
||||||
|
pe.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao,
|
||||||
var autoApprovals = await db.PurchaseEvaluationApprovals
|
"V1-submit deprecated S83 → giữ Nháp");
|
||||||
.Where(a => a.PurchaseEvaluationId == pe.Id
|
|
||||||
&& a.Decision == ApprovalDecision.AutoApprove).ToListAsync();
|
|
||||||
autoApprovals.Should().BeEmpty("V1 không bypass → 0 AutoApprove row");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user