[CLAUDE] Contract: YC-030 Q3+Q5 — port finalize KHKK vào máy duyệt HĐ (Mig 73 EndedByLevelFinalize + ApproveV2 + body/DTO nở + tắt QT-HD-V2-001, test 697)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-12 21:57:57 +07:00
parent 5facab2f48
commit 698603625b
11 changed files with 7713 additions and 25 deletions

View File

@ -0,0 +1,463 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Common;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;
// `NoOpNotificationService` là stub `internal` sống ở
// `Services/PurchaseEvaluationWorkflowServiceGuardTests.cs:188` — dùng lại thay vì chép bản thứ hai
// (bản sao thứ hai của một no-op là thứ sẽ lệch nhau lúc `INotificationService` nở thêm hàm).
using SolutionErp.Infrastructure.Tests.Services;
namespace SolutionErp.Infrastructure.Tests.Application;
// [WQ3-TEST · YC-030 Q3 — S190 2026-08-12] KẾT THÚC TẠI CẤP (level-finalize) của Hợp đồng V2.
//
// Đối tượng đo: nhánh `ContractWorkflowService.ApproveV2Async:527-535` (cờ `EndedByLevelFinalize`
// + `FinalizeToDaPhatHanhAsync:576`). Đây là **test-bù** (mã BE đã land trước, chưa commit) —
// KHÔNG phải test-before; nếu ca nào đỏ thì đỏ đó là phát hiện về mã, không được nới assert.
//
// ── VÌ SAO QUY TRÌNH PHẢI CÓ ĐUÔI (điều kiện SỐNG CÒN của phép đo, bài S164-K3) ──────────────
// Đặt cờ finalize ở slot CUỐI thì "hợp đồng phát hành + có mã" xảy ra ở CẢ HAI thế giới (có
// nhánh finalize / không có nhánh finalize) ⇒ F-1 xanh mà chứng minh 0. Nên cả 4 ca dùng chung
// một hình: **2 Bước** — Bước 1 có **2 Cấp**, Bước 2 có 1 Cấp — và người bấm Duyệt luôn là
// **Bước 1 Cấp 1**, tức còn NGUYÊN 2 chặng phía sau. Gỡ nhánh finalize khỏi production ⇒ F-1 rơi
// về "sang Cấp 2 cùng Bước 1" ⇒ ĐỎ. Hình này được chốt bằng `AssertFinalizeSlotIsNotTheLastAsync`
// gọi ngay trước mỗi lượt bấm, để một lần "gọn hoá" seed sau này không âm thầm làm rỗng cả file.
//
// ── MA TRẬN 3 CA CHỨNG `&&` (cùng ruột, khác ĐÚNG 1 biến) ────────────────────────────────────
// F-1: cờ Cấp = true · ý người duyệt = MẶC ĐỊNH (không truyền tham số) ⇒ PHÁT HÀNH
// F-2: cờ Cấp = true · applyLevelFinalize = false ⇒ đi tiếp bình thường
// F-3: cờ Cấp = false · applyLevelFinalize = true ⇒ đi tiếp bình thường
// F-1 giữ vế dương. F-2/F-3 mỗi ca tắt MỘT vế khác nhau — cài `||` thay `&&` (`:527`) sẽ làm ĐỎ
// cả hai; bỏ đọc tham số ⇒ chỉ F-2 đỏ; bỏ đọc cờ cấu hình ⇒ chỉ F-3 đỏ. Ba ca không thay thế nhau.
// 🔴 F-1 CỐ Ý KHÔNG truyền `applyLevelFinalize`: đó là phép đo DUY NHẤT cho ngữ nghĩa OPT-OUT
// (`IContractWorkflowService.cs:25` mặc định `true`). Truyền tay `true` là bỏ mất vế đó.
//
// ── F-4: bất biến sau khi đã phát hành ───────────────────────────────────────────────────────
// Đọc mã trước khi chọn assert (lệnh cấm nới): sau finalize `Phase=DaPhatHanh` ⇒ lượt bấm Duyệt
// kế tiếp trượt hết mọi nhánh của `TransitionAsync` (reject `:55` / trình `:96` / duyệt `:159`
// đều đòi phase khác) và rơi xuống câu cuối `:268` `ConflictException("Transition … không hỗ
// trợ.")`. Vậy assert đúng là **409 + không đổi gì**, KHÔNG phải "im lặng bỏ qua".
//
// ── 2 quyết định hạ tầng ─────────────────────────────────────────────────────────────────────
// (1) `IdentityFixture` (KHÔNG phải fixture riêng có `AuditingInterceptor` kiểu
// `ContractDeleteGuardTests.cs:69`). Service đòi `UserManager<User>` thật, còn interceptor chỉ
// phục vụ ô audit/xoá-mềm — file này không đo ô nào trong số đó. Bê interceptor vào đây là
// nghi thức thừa, lại phải dựng lại toàn bộ Identity wiring. Khuôn theo file cùng đối tượng đo:
// `Services/ContractWorkflowServiceApproveV2Tests.cs:25`.
// (2) Mỗi ca một fixture ⇒ một DB SQLite in-memory riêng ⇒ `ContractCodeSequences` không dính
// nhau; mã HĐ mong đợi luôn là `/01`. Vẫn đặt Code NCC/dự án khác nhau từng ca cho dễ đọc log.
public class ContractApproveFinalizeTests
{
private static readonly DateTime Clock2026 = new(2026, 8, 12, 3, 0, 0, DateTimeKind.Utc);
// Dựng service đúng 6 dep như prod (`ContractWorkflowService.cs:28-34`), changelog THẬT để
// còn đọc được dòng nhật ký phân biệt finalize ⟂ advance thường.
private static (ContractWorkflowService svc, TestApplicationDbContext db) WireService(
IdentityFixture fix, Guid actorId, params string[] actorRoles)
{
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var dt = new FixedDateTime(Clock2026);
var currentUser = new TestCurrentUser { UserId = actorId, Roles = actorRoles };
var changelog = new ChangelogService(db, currentUser, um);
var svc = new ContractWorkflowService(
db, new ContractCodeGenerator(db, dt), dt, new NoOpNotificationService(), changelog, um);
return (svc, db);
}
// Quy trình 2 BƯỚC: Bước 1 (Cấp 1 + Cấp 2) → Bước 2 (Cấp 1). Cờ finalize chỉ gắn ở slot
// Bước 1 Cấp 1 — đúng chỗ người duyệt sẽ bấm, và là slot còn 2 chặng phía sau.
private static async Task<(ApprovalWorkflow wf, ApprovalWorkflowStep s1, ApprovalWorkflowLevel s1l1)>
SeedTwoStepWorkflowAsync(
TestApplicationDbContext db,
Guid s1l1Approver,
Guid s1l2Approver,
Guid s2l1Approver,
bool allowFinalizeAtS1L1,
string code)
{
var wf = new ApprovalWorkflow
{
Id = Guid.NewGuid(),
Code = code,
Version = 1,
Name = "Quy trình duyệt HĐ — 2 Bước",
ApplicableType = ApprovalWorkflowApplicableType.Contract,
IsActive = true,
IsUserSelectable = true,
};
var s1 = new ApprovalWorkflowStep
{
Id = Guid.NewGuid(),
ApprovalWorkflowId = wf.Id,
Order = 1,
DepartmentId = null,
Name = "Bước 1 Phòng Mua hàng",
};
var s2 = new ApprovalWorkflowStep
{
Id = Guid.NewGuid(),
ApprovalWorkflowId = wf.Id,
Order = 2,
DepartmentId = null,
Name = "Bước 2 Ban Giám đốc",
};
var s1l1 = new ApprovalWorkflowLevel
{
Id = Guid.NewGuid(),
ApprovalWorkflowStepId = s1.Id,
Order = 1,
ApproverUserId = s1l1Approver,
AllowApproverFinalize = allowFinalizeAtS1L1,
};
var s1l2 = new ApprovalWorkflowLevel
{
Id = Guid.NewGuid(),
ApprovalWorkflowStepId = s1.Id,
Order = 2,
ApproverUserId = s1l2Approver,
};
var s2l1 = new ApprovalWorkflowLevel
{
Id = Guid.NewGuid(),
ApprovalWorkflowStepId = s2.Id,
Order = 1,
ApproverUserId = s2l1Approver,
};
db.ApprovalWorkflows.Add(wf);
db.ApprovalWorkflowSteps.AddRange(s1, s2);
db.ApprovalWorkflowLevels.AddRange(s1l1, s1l2, s2l1);
await db.SaveChangesAsync(CancellationToken.None);
return (wf, s1, s1l1);
}
// TIỀN ĐỀ chống-rỗng: slot sắp bấm Duyệt (Bước 1 Cấp 1) phải còn ĐUÔI phía sau. Xem khối
// "VÌ SAO QUY TRÌNH PHẢI CÓ ĐUÔI" ở đầu file — thiếu vế này thì F-1 đúng ở mọi thế giới.
private static async Task AssertFinalizeSlotIsNotTheLastAsync(
TestApplicationDbContext db, Guid workflowId, Guid step1Id)
{
(await db.ApprovalWorkflowSteps.CountAsync(s => s.ApprovalWorkflowId == workflowId))
.Should().Be(2, "phải còn Bước 2 phía sau thì 'kết thúc sớm' mới khác 'đi hết quy trình'");
(await db.ApprovalWorkflowLevels.CountAsync(l => l.ApprovalWorkflowStepId == step1Id))
.Should().Be(2, "ngay trong Bước 1 cũng còn Cấp 2 chưa duyệt");
}
private static async Task<(Supplier sup, Project proj)> SeedSupplierProjectAsync(
TestApplicationDbContext db, string supplierCode, string projectCode)
{
var sup = new Supplier
{
Id = Guid.NewGuid(),
Code = supplierCode,
Name = $"NCC {supplierCode}",
Type = SupplierType.NhaThauPhu,
};
var proj = new Project
{
Id = Guid.NewGuid(),
Code = projectCode,
Name = $"Dự án {projectCode}",
};
db.Suppliers.Add(sup);
db.Projects.Add(proj);
await db.SaveChangesAsync(CancellationToken.None);
return (sup, proj);
}
// HĐ đang chờ duyệt, con-trỏ đứng ở Bước 1 (index 0) Cấp 1 — đúng hình sau khi người soạn trình.
private static Contract BuildContractAtStep0Level1(Guid workflowId, Guid supplierId, Guid projectId)
=> new()
{
Id = Guid.NewGuid(),
Type = ContractType.HopDongThauPhu,
Phase = ContractPhase.ChoDuyet,
SupplierId = supplierId,
ProjectId = projectId,
DrafterUserId = Guid.NewGuid(),
TenHopDong = "HĐ thầu phụ — ca level-finalize",
GiaTri = 800_000_000m,
ApprovalWorkflowId = workflowId,
CurrentWorkflowStepIndex = 0,
CurrentApprovalLevelOrder = 1,
};
private static async Task<(User a1, User a2, User a3)> SeedThreeApproversAsync(
IdentityFixture fix, string tag)
{
var a1 = await fix.CreateUserAsync($"b1c1-{tag}@test.local", $"NV Bước1 Cấp1 {tag}",
departmentId: null, roles: new[] { AppRoles.CostControl });
var a2 = await fix.CreateUserAsync($"b1c2-{tag}@test.local", $"NV Bước1 Cấp2 {tag}",
departmentId: null, roles: new[] { AppRoles.CostControl });
var a3 = await fix.CreateUserAsync($"b2c1-{tag}@test.local", $"NV Bước2 Cấp1 {tag}",
departmentId: null, roles: new[] { AppRoles.CostControl });
return (a1, a2, a3);
}
// =====================================================================================
// F-1 — Cấp có cờ + người duyệt KHÔNG bỏ tick (mặc định) ⇒ HĐ PHÁT HÀNH ngay tại đây.
// 5 vế đo, mỗi vế canh một tầng khác nhau của `FinalizeToDaPhatHanhAsync`:
// (a) Phase = DaPhatHanh → có kết thúc thật
// (b) EndedByLevelFinalize = true → kết thúc BẰNG ĐƯỜNG NÀO (cột runtime Mig 73)
// (c) MaHopDong sinh đúng RG-001 → không đẻ HĐ phát hành mà rỗng mã (luật choke-point)
// (d) con-trỏ Bước/Cấp + SLA = null → không kẹt trong hộp thư cấp đã xong việc
// (e) đọc lại từ DB → cờ được LƯU, không chỉ set trong bộ nhớ
// =====================================================================================
[Fact]
public async Task ApproveV2_LevelFinalizeFlag_WithDefaultApply_PublishesContractAndSetsRuntimeFlag()
{
var fix = new IdentityFixture();
using (fix)
{
var (a1, a2, a3) = await SeedThreeApproversAsync(fix, "f1");
var (svc, db) = WireService(fix, a1.Id, AppRoles.CostControl);
var (wf, s1, _) = await SeedTwoStepWorkflowAsync(
db, a1.Id, a2.Id, a3.Id, allowFinalizeAtS1L1: true, code: "QT-CT-FIN1");
var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "FIN1", projectCode: "PFIN1");
var contract = BuildContractAtStep0Level1(wf.Id, sup.Id, proj.Id);
db.Contracts.Add(contract);
await db.SaveChangesAsync(CancellationToken.None);
await AssertFinalizeSlotIsNotTheLastAsync(db, wf.Id, s1.Id);
// 🔴 KHÔNG truyền `applyLevelFinalize` — đo luôn mặc định OPT-OUT của hợp đồng dịch vụ.
await svc.TransitionAsync(
contract: contract,
targetPhase: ContractPhase.ChoDuyet,
actorUserId: a1.Id,
actorRoles: new[] { AppRoles.CostControl },
decision: ApprovalDecision.Approve,
comment: "đủ hồ sơ, phát hành luôn",
ct: CancellationToken.None);
contract.Phase.Should().Be(ContractPhase.DaPhatHanh,
"Cấp bật 'kết thúc tại cấp' bấm Duyệt ⇒ HĐ phát hành ngay, không trình tiếp Cấp 2 / Bước 2");
contract.EndedByLevelFinalize.Should().BeTrue(
"cột runtime phải nói HĐ kết thúc BẰNG ĐƯỜNG level-finalize, không suy từ cấu hình (bài #78)");
contract.MaHopDong.Should().Be("PFIN1/HĐTP/SOL&FIN1/01",
"kết thúc sớm vẫn phải đi qua đường terminal duy nhất ⇒ có mã RG-001 như HĐ đi hết quy trình");
contract.CurrentWorkflowStepIndex.Should().BeNull("terminal xoá con-trỏ Bước");
contract.CurrentApprovalLevelOrder.Should().BeNull("terminal xoá con-trỏ Cấp");
contract.SlaDeadline.Should().BeNull("hết hạn xử lý vì không còn ai phải xử lý");
// (e) cờ phải nằm trong DB — `TransitionAsync:168` SaveChanges sau nhánh V2.
var row = await db.Contracts.AsNoTracking().SingleAsync(c => c.Id == contract.Id);
row.EndedByLevelFinalize.Should().BeTrue("cờ được LƯU, không chỉ sống trong entity đang track");
row.Phase.Should().Be(ContractPhase.DaPhatHanh);
row.MaHopDong.Should().Be("PFIN1/HĐTP/SOL&FIN1/01");
// Vết nhật ký phân biệt hẳn với terminal thường (dòng terminal thường không mang chữ này).
var changelogs = await db.ContractChangelogs
.Where(c => c.ContractId == contract.Id && c.EntityType == ChangelogEntityType.Workflow)
.ToListAsync();
changelogs.Should().Contain(c => c.ContextNote != null && c.ContextNote.Contains("KẾT THÚC TẠI CẤP"),
"người đọc lịch sử phải phân biệt được 'phát hành vì hết quy trình' ⟂ 'phát hành vì cấp này chốt'");
// Ý kiến cấp duyệt vẫn được UPSERT trước khi finalize (`:442-469`) — kết thúc sớm
// KHÔNG được nuốt mất chữ ký của chính người bấm.
var opinions = await db.ContractLevelOpinions.Where(o => o.ContractId == contract.Id).ToListAsync();
opinions.Should().HaveCount(1);
opinions[0].SignedByUserId.Should().Be(a1.Id);
opinions[0].Comment.Should().Be("đủ hồ sơ, phát hành luôn");
}
}
// =====================================================================================
// F-2 — CÙNG seed F-1, đổi ĐÚNG một biến: `applyLevelFinalize=false` (người duyệt bỏ tick).
// Vế OPT-OUT: cờ cấu hình bật KHÔNG đủ để kết thúc. Kết quả phải là advance thường.
// =====================================================================================
[Fact]
public async Task ApproveV2_LevelFinalizeFlag_ButApproverOptsOut_AdvancesNormally()
{
var fix = new IdentityFixture();
using (fix)
{
var (a1, a2, a3) = await SeedThreeApproversAsync(fix, "f2");
var (svc, db) = WireService(fix, a1.Id, AppRoles.CostControl);
var (wf, s1, _) = await SeedTwoStepWorkflowAsync(
db, a1.Id, a2.Id, a3.Id, allowFinalizeAtS1L1: true, code: "QT-CT-FIN2");
var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "FIN2", projectCode: "PFIN2");
var contract = BuildContractAtStep0Level1(wf.Id, sup.Id, proj.Id);
db.Contracts.Add(contract);
await db.SaveChangesAsync(CancellationToken.None);
await AssertFinalizeSlotIsNotTheLastAsync(db, wf.Id, s1.Id);
await svc.TransitionAsync(
contract: contract,
targetPhase: ContractPhase.ChoDuyet,
actorUserId: a1.Id,
actorRoles: new[] { AppRoles.CostControl },
decision: ApprovalDecision.Approve,
comment: "duyệt nhưng để Cấp 2 xem tiếp",
applyLevelFinalize: false,
ct: CancellationToken.None);
contract.Phase.Should().Be(ContractPhase.ChoDuyet,
"bỏ tick 'kết thúc tại cấp' ⇒ HĐ đi tiếp như một lượt duyệt bình thường");
contract.CurrentApprovalLevelOrder.Should().Be(2, "sang Cấp 2 cùng Bước 1");
contract.CurrentWorkflowStepIndex.Should().Be(0, "vẫn trong Bước 1 vì còn Cấp 2");
contract.EndedByLevelFinalize.Should().BeFalse(
"không kết thúc sớm thì tuyệt đối không được gắn nhãn kết thúc sớm");
contract.MaHopDong.Should().BeNull("chưa phát hành thì chưa cấp mã HĐ");
contract.SlaDeadline.Should().NotBeNull("SLA đặt lại cho Cấp 2 nhận việc");
var row = await db.Contracts.AsNoTracking().SingleAsync(c => c.Id == contract.Id);
row.EndedByLevelFinalize.Should().BeFalse();
row.Phase.Should().Be(ContractPhase.ChoDuyet);
var changelogs = await db.ContractChangelogs
.Where(c => c.ContractId == contract.Id && c.EntityType == ChangelogEntityType.Workflow)
.ToListAsync();
changelogs.Should().Contain(c => c.ContextNote != null
&& c.ContextNote.Contains("Hoàn tất Cấp 1, sang Cấp 2 cùng Bước 1"));
changelogs.Should().NotContain(c => c.ContextNote != null && c.ContextNote.Contains("KẾT THÚC TẠI CẤP"),
"không được để lại vết 'kết thúc tại cấp' cho một lượt duyệt bình thường");
}
}
// =====================================================================================
// F-3 — Vế thứ hai của phép `&&`: Cấp KHÔNG có cờ, người duyệt gửi `applyLevelFinalize=true`.
// Ý người dùng KHÔNG tự cấp được quyền kết thúc — quyền đó do Admin gắn ở Designer.
// (Ca này là cửa chặn thật: `ApplyLevelFinalize` đến từ body request, ai cũng gửi được true.)
// =====================================================================================
[Fact]
public async Task ApproveV2_NoLevelFinalizeFlag_EvenWhenApplyRequested_AdvancesNormally()
{
var fix = new IdentityFixture();
using (fix)
{
var (a1, a2, a3) = await SeedThreeApproversAsync(fix, "f3");
var (svc, db) = WireService(fix, a1.Id, AppRoles.CostControl);
var (wf, s1, s1l1) = await SeedTwoStepWorkflowAsync(
db, a1.Id, a2.Id, a3.Id, allowFinalizeAtS1L1: false, code: "QT-CT-FIN3");
var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "FIN3", projectCode: "PFIN3");
var contract = BuildContractAtStep0Level1(wf.Id, sup.Id, proj.Id);
db.Contracts.Add(contract);
await db.SaveChangesAsync(CancellationToken.None);
await AssertFinalizeSlotIsNotTheLastAsync(db, wf.Id, s1.Id);
// Chốt biến đang bị tắt là ĐÚNG biến định tắt (seed sai cờ ⇒ ca này trùng F-2).
(await db.ApprovalWorkflowLevels.AsNoTracking().SingleAsync(l => l.Id == s1l1.Id))
.AllowApproverFinalize.Should().BeFalse("ca này tắt vế CẤU HÌNH, giữ nguyên vế ý-người-duyệt");
await svc.TransitionAsync(
contract: contract,
targetPhase: ContractPhase.ChoDuyet,
actorUserId: a1.Id,
actorRoles: new[] { AppRoles.CostControl },
decision: ApprovalDecision.Approve,
comment: "tự xin kết thúc dù Cấp không được phép",
applyLevelFinalize: true,
ct: CancellationToken.None);
contract.Phase.Should().Be(ContractPhase.ChoDuyet,
"Cấp không được Admin cho phép kết thúc ⇒ xin cũng không được, HĐ đi tiếp");
contract.CurrentApprovalLevelOrder.Should().Be(2);
contract.CurrentWorkflowStepIndex.Should().Be(0);
contract.EndedByLevelFinalize.Should().BeFalse();
contract.MaHopDong.Should().BeNull("không có đường nào phát hành ⇒ không có mã");
var row = await db.Contracts.AsNoTracking().SingleAsync(c => c.Id == contract.Id);
row.EndedByLevelFinalize.Should().BeFalse();
row.Phase.Should().Be(ContractPhase.ChoDuyet);
var changelogs = await db.ContractChangelogs
.Where(c => c.ContractId == contract.Id && c.EntityType == ChangelogEntityType.Workflow)
.ToListAsync();
changelogs.Should().NotContain(c => c.ContextNote != null && c.ContextNote.Contains("KẾT THÚC TẠI CẤP"));
}
}
// =====================================================================================
// F-4 — Sau khi đã kết thúc sớm, HĐ là BẤT BIẾN với mọi lượt bấm Duyệt tiếp theo.
// Hai người bấm, hai lý do đời thực khác nhau, cùng phải 409:
// (a) chính người vừa chốt bấm lại (double-click / gửi lại request)
// (b) NV Cấp 2 — người vẫn đang thấy HĐ trong hộp thư của mình lúc quy trình bị cắt ngang
// Vế "không đổi gì" quan trọng ngang vế ném lỗi: một cài đặt chặn-bằng-cách-ném-sau-khi-ghi
// vẫn xanh nếu chỉ assert exception. Nên đo cả mã HĐ, cả số vết duyệt.
// =====================================================================================
[Fact]
public async Task ApproveV2_AfterLevelFinalize_FurtherApproveThrowsConflict_AndChangesNothing()
{
var fix = new IdentityFixture();
using (fix)
{
var (a1, a2, a3) = await SeedThreeApproversAsync(fix, "f4");
var (svc, db) = WireService(fix, a1.Id, AppRoles.CostControl);
var (wf, s1, _) = await SeedTwoStepWorkflowAsync(
db, a1.Id, a2.Id, a3.Id, allowFinalizeAtS1L1: true, code: "QT-CT-FIN4");
var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "FIN4", projectCode: "PFIN4");
var contract = BuildContractAtStep0Level1(wf.Id, sup.Id, proj.Id);
db.Contracts.Add(contract);
await db.SaveChangesAsync(CancellationToken.None);
await AssertFinalizeSlotIsNotTheLastAsync(db, wf.Id, s1.Id);
// Lượt 1 — kết thúc tại cấp (tiền đề của cả ca; xanh ở đây trùng với F-1).
await svc.TransitionAsync(
contract: contract,
targetPhase: ContractPhase.ChoDuyet,
actorUserId: a1.Id,
actorRoles: new[] { AppRoles.CostControl },
decision: ApprovalDecision.Approve,
comment: "chốt tại Cấp 1",
ct: CancellationToken.None);
contract.Phase.Should().Be(ContractPhase.DaPhatHanh, "tiền đề của F-4");
var maSauFinalize = contract.MaHopDong;
maSauFinalize.Should().Be("PFIN4/HĐTP/SOL&FIN4/01");
// (a) chính người vừa chốt bấm lại.
var actAgainSamePerson = async () => await svc.TransitionAsync(
contract: contract,
targetPhase: ContractPhase.ChoDuyet,
actorUserId: a1.Id,
actorRoles: new[] { AppRoles.CostControl },
decision: ApprovalDecision.Approve,
comment: "bấm lại lần nữa",
ct: CancellationToken.None);
await actAgainSamePerson.Should().ThrowAsync<ConflictException>()
.WithMessage("*DaPhatHanh*không hỗ trợ*",
"HĐ đã phát hành không còn nhánh duyệt nào nhận nó (`ContractWorkflowService.cs:268`)");
// (b) NV Cấp 2 — người bị cắt lượt vẫn còn thấy nút Duyệt trên màn hình cũ.
var actNextLevelApprover = async () => await svc.TransitionAsync(
contract: contract,
targetPhase: ContractPhase.ChoDuyet,
actorUserId: a2.Id,
actorRoles: new[] { AppRoles.CostControl },
decision: ApprovalDecision.Approve,
comment: "Cấp 2 duyệt trên màn hình cũ",
ct: CancellationToken.None);
await actNextLevelApprover.Should().ThrowAsync<ConflictException>()
.WithMessage("*DaPhatHanh*không hỗ trợ*");
// Bất biến: không lượt bấm nào để lại dấu vết hay xê dịch trạng thái.
contract.Phase.Should().Be(ContractPhase.DaPhatHanh);
contract.MaHopDong.Should().Be(maSauFinalize, "không được cấp mã HĐ lần thứ hai");
contract.EndedByLevelFinalize.Should().BeTrue("nhãn kết thúc sớm giữ nguyên");
contract.CurrentWorkflowStepIndex.Should().BeNull("không được hồi sinh con-trỏ Bước");
contract.CurrentApprovalLevelOrder.Should().BeNull("không được hồi sinh con-trỏ Cấp");
var approvals = await db.ContractApprovals.Where(a => a.ContractId == contract.Id).ToListAsync();
approvals.Should().HaveCount(1, "đúng 1 vết duyệt — 2 lượt bấm sau bị chặn TRƯỚC khi ghi");
var opinions = await db.ContractLevelOpinions.Where(o => o.ContractId == contract.Id).ToListAsync();
opinions.Should().HaveCount(1, "không đẻ thêm ý kiến cấp duyệt cho lượt bấm bị chặn");
var row = await db.Contracts.AsNoTracking().SingleAsync(c => c.Id == contract.Id);
row.Phase.Should().Be(ContractPhase.DaPhatHanh);
row.MaHopDong.Should().Be(maSauFinalize);
row.EndedByLevelFinalize.Should().BeTrue();
}
}
}