[CLAUDE] Workflow: KHKK 8 panel nhóm mirror DuyetNcc + BE guards (0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m24s

YC-013 (S182/L13). 8 leaf "Duyệt KHKK — Nhóm 1..8" ra đúng khuôn trang
DuyetNcc: slice per-Code + lockedCode + Sửa-tại-chỗ/Tạo-version/Ghim/Xoá.
D3=C (YC-014): 6 ô chưa-wire disabled-giữ-state-thật + tooltip trên label
(Chromium không hiện title trên input disabled — MAJOR-1); ô #4 thêm chữ
nhìn thấy được; #7 giữ; #8 Finalize 0-chạm (prod 8/88 dựa nó); ô CEO
disable type-10 + POST null.

BE 5 guard (0 migration):
- W1a deactivate-per-Code cho type-10 (Code+Type+IsActive — cấm regex);
  Code normalize Trim().ToUpperInvariant() tại đường ghi + IgnoreCase belt
  trong GroupCodePattern (MAJOR-2: SQLite test case-sensitive nguoc prod
  CI_AS collation)
- W1b ContractSigningPlanWorkflowGuards (public static, Application) —
  rào nhóm 3 cửa Create/UpdateDraft/Submit, giữ cho-qua-code-ngoài-họ
- W1b2 (D6) pin-check IsActive+IsUserSelectable 2 cửa, 2 message tách
- W1c changelog PUT type-10 vào ContractSigningPlanChangelogs
- W1d DELETE usage-check 409 đích danh (8 module pin, IgnoreQueryFilters
  cho PE/Contract/KHKK; 5 module office pin loose-0FK — guard là rào duy
  nhất chống dangling) + 404 NotFoundException + D5 rào version-cuối nhóm
  (+ ApplicableType — MINOR-3)

Test 645 → 660 (+14 spec + T15 D6-Create MAJOR-3); MINOR-1 pin nguyên câu
thứ-tự 1 ca. File test thứ 3 (GroupAndCatalog) đổi 1 assertion theo câu
409 mới của W1b — spec-change, không phải nới bar.

Khai accepted (reviewer PASS_WITH_FIXES 0C/3M+6m, đã vá 3M+2m):
- MINOR-4: panel nhóm tạo-version-mới ⇒ 6 cờ khoá đóng băng default FE
  (vô hại — BE không đọc 6 cờ đó cho type-10)
- MINOR-5: bỏ ghim/bỏ active bản cuối nhóm = đóng cửa tạo phiếu nhóm đó
  (răng thật của nút Ghim — không rào đợt này)
- race 2-admin cùng Code → 2601/500 thô (UNIQUE đỡ data, chấp nhận)

Evidence: .claude/workflows/runs/2026-08-08-S182-khkk-awf-mirror-ncc/
(3 fable-invest + 2 fable-review + spec FINAL + 4 lane + precheck prod
12 phiếu/pin-lệch-0 + reviewer pre-commit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-08 12:14:37 +07:00
parent 18f8f76bac
commit 706134daa9
13 changed files with 1604 additions and 89 deletions

View File

@ -131,7 +131,13 @@ public class ContractSigningPlanApprovalTests
string code,
Guid[][][] steps,
int stepOrderStart = 1,
int stepOrderStride = 1)
int stepOrderStride = 1,
// [S182 W1 T7] 3 tham số đuôi cho rào D6 (pin-check IsActive ∧ IsUserSelectable) và
// cho việc dựng 2 version CÙNG Code (UNIQUE là (Code, Version)). Optional trailing ⇒
// mọi call-site cũ giữ nguyên chữ.
int version = 1,
bool isActive = true,
bool isUserSelectable = true)
{
await using var db = f.NewDb(TestCurrentUser.System());
@ -139,13 +145,13 @@ public class ContractSigningPlanApprovalTests
{
Id = Guid.NewGuid(),
Code = code,
Version = 1,
Version = version,
Name = "Quy trình duyệt Kế hoạch ký kết",
// Loại phải là ContractSigningPlan(10) — quy trình module khác bị rào Create
// của W2 chặn (`ContractSigningPlanFeatures.cs:218-219`).
ApplicableType = ApprovalWorkflowApplicableType.ContractSigningPlan,
IsActive = true,
IsUserSelectable = true,
IsActive = isActive,
IsUserSelectable = isUserSelectable,
};
db.ApprovalWorkflows.Add(wf);
@ -193,21 +199,29 @@ public class ContractSigningPlanApprovalTests
ContractSigningPlanPhase phase,
int? stepIndex,
int? levelOrder,
LineSeed[] lines)
LineSeed[] lines,
// [S182 W1 T4-T7] Nhóm duyệt của phiếu. Hạng mục fixture ĐI THEO nhóm này (cùng giá trị)
// ⇒ rào hạng mục `…WorkflowService.cs:182-187` luôn cho qua, và mọi 409 quan sát được
// trong T5 chỉ có thể đến từ rào NHÓM-QUY-TRÌNH. Trailing optional ⇒ 12 call-site cũ giữ nguyên.
int approvalGroup = 1)
{
// Seed dưới danh nghĩa NGƯỜI SOẠN ⇒ interceptor ghi `CreatedBy = drafter` (xem ghi chú (1)).
await using var db = f.NewDb(drafter);
// [K2 S164 — vá-5] SubmitAsync nay CHẶN dòng chưa gán hạng mục + hạng mục khác nhóm.
// Fixture gán MỌI line vào 1 hạng mục CÙNG NHÓM với phiếu (entity default ApprovalGroup=1)
// — mirror phiếu "đã gán đủ" trước trình; Code random né unique-filtered giữa các plan.
// Fixture gán MỌI line vào 1 hạng mục CÙNG NHÓM với phiếu — mirror phiếu "đã gán đủ"
// trước trình; Code random né unique-filtered giữa các plan.
// 🔴 [S182 W1] Hạng mục ĐI THEO `approvalGroup` của phiếu (không còn hằng 1): T5 seed
// phiếu nhóm 3, nếu hạng mục vẫn nhóm 1 thì `SubmitAsync` ném 409 của rào HẠNG MỤC
// (`ContractSigningPlanWorkflowService.cs:182-187`) và test sẽ "xanh vì nhầm cửa" —
// đo đúng chữ 409 nhưng sai hoàn toàn cơ chế.
var catalog = new ContractCatalogEntry
{
Id = Guid.NewGuid(),
Code = "T-" + Guid.NewGuid().ToString("N")[..8],
TenVi = "Hạng mục test W3",
GroupCode = "B1",
ApprovalGroup = 1,
ApprovalGroup = approvalGroup,
SignerRole = "TP.PRO",
IsActive = true,
SortOrder = 1,
@ -225,6 +239,7 @@ public class ContractSigningPlanApprovalTests
DepartmentId = Guid.NewGuid(),
DrafterUserId = drafter.UserId!.Value,
Phase = phase,
ApprovalGroup = approvalGroup,
ApprovalWorkflowId = workflowId,
CurrentWorkflowStepIndex = stepIndex,
CurrentApprovalLevelOrder = levelOrder,
@ -1138,4 +1153,299 @@ public class ContractSigningPlanApprovalTests
.Select(l => new LevelFlag(l.Order, l.ApproverUserId, l.AllowApproverFinalize))
.ToListAsync(CancellationToken.None);
}
// ###################################################################
// [S182 W1 — YC-013, 2026-08-08] T4-T7: rào NHÓM-QUY-TRÌNH 3-site + pin-check D6.
// SPEC: `.claude/workflows/runs/2026-08-08-S182-khkk-awf-mirror-ncc/
// spec-khkk-awf-mirror-ncc-08-08-2026.md` MỤC 2 W1 fix (b) + (b2).
//
// 🔴 VẤN ĐỀ: rào "phiếu nhóm N{g} chỉ pin được quy trình `KHKK-N{g}`" hôm nay chỉ đứng ở
// ĐÚNG MỘT CỬA — `CreateContractSigningPlanCommandHandler` (`Features.cs:479-486`).
// Hai cửa còn lại đi thẳng: UpdateDraft `:603-607` chỉ gọi `EnsureWorkflowTypeAsync`,
// và `SubmitAsync` (`ContractSigningPlanWorkflowService.cs:176-189`) không hỏi câu này
// bao giờ. Hệ quả không phải "409 muộn" mà là KÝ SAI DÂY CHUYỀN: phiếu nhóm 3 chạy trạm
// của nhóm 5 ⇒ hồ sơ tới tay người không có thẩm quyền ký, im lặng, đúng luồng UI.
// W1b gom logic vào `ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync`
// và cắm đủ 3 cửa.
//
// ⛔ KHÔNG nhân bản cửa Create ở đây. Cửa đó ĐÃ có lưới đủ răng từ K3/S164 —
// `ContractSigningPlanGroupAndCatalogTests.Create_PinQuyTrinhKhacNhomDuyet_ThiBiChan_VaKhongDotMa`
// (chặn sai-nhóm + cho-qua-ngoài-họ + không-đốt-mã), đang XANH ở baseline. Nó chính là
// chứng-nhân "phép dời logic sang class dùng chung KHÔNG đổi ngữ nghĩa cửa cũ"; chép lại
// 60 dòng seed PE vào file này chỉ đẻ bản sao đo cùng một thứ. T4-T7 chỉ viết phần nó
// KHÔNG đo: 2 cửa mới + rào D6.
//
// 🔎 Chữ trong thông điệp 409: spec đưa RA HAI bản (giữ nguyên câu Create `:483-486`, hoặc
// câu mới W1b "Phiếu nhóm N{g} đang gắn quy trình N{k}…"). Cả hai đều hợp lệ ⇒ test pin
// cái BẤT BIẾN giữa hai bản: thông điệp phải nêu CẢ nhóm-của-phiếu LẪN nhóm-của-quy-trình,
// vì thiếu một trong hai thì người dùng đọc xong vẫn không biết phải đổi cái gì.
// (Nếu BE chọn câu mới nguyên văn ⇒ nó BỎ mã quy trình và làm ĐỎ assert `Contain("KHKK-N5")`
// của test K3 nói trên — đã báo lead ở §8 artifact, KHÔNG tự nới test cũ.)
// ###################################################################
private static async Task UpdateDraftAsync(
KhkkWorkflowFixture f, TestCurrentUser actor, Guid planId,
Guid? awId = null, string? ghiChu = null)
{
await using var db = f.NewDb(actor);
var handler = new UpdateContractSigningPlanDraftCommandHandler(db, actor);
await handler.Handle(
new UpdateContractSigningPlanDraftCommand(planId, GhiChu: ghiChu, HoSoLink: null,
ApprovalWorkflowId: awId),
CancellationToken.None);
}
/// Đọc pin TỪ ĐĨA (context mới). Mọi vế "409 rồi thì không đổi gì" phải đo ở đây chứ không
/// đo trên entity trong bộ nhớ — handler gán `plan.ApprovalWorkflowId` TRƯỚC khi
/// `SaveChangesAsync` chạy, nên bản in-memory nói dối được.
private static async Task<Guid?> ReadPinAsync(KhkkWorkflowFixture f, Guid planId)
{
await using var db = f.NewDb(TestCurrentUser.System());
return await db.ContractSigningPlans.AsNoTracking()
.Where(p => p.Id == planId)
.Select(p => p.ApprovalWorkflowId)
.SingleAsync(CancellationToken.None);
}
private static async Task<int> ChangelogCountAsync(KhkkWorkflowFixture f, Guid planId)
{
await using var db = f.NewDb(TestCurrentUser.System());
return await db.ContractSigningPlanChangelogs.AsNoTracking()
.CountAsync(c => c.ContractSigningPlanId == planId, CancellationToken.None);
}
private static async Task ArchiveWorkflowAsync(KhkkWorkflowFixture f, Guid workflowId)
{
await using var db = f.NewDb(TestCurrentUser.System());
var wf = await db.ApprovalWorkflows.SingleAsync(w => w.Id == workflowId, CancellationToken.None);
wf.IsActive = false;
await db.SaveChangesAsync(CancellationToken.None);
}
// ===================================================================
// (T4) Cửa UpdateDraft — re-pin sang quy trình LỆCH NHÓM ⇒ 409.
//
// Đây là cửa NGUY HIỂM NHẤT trong ba: Create bị chặn thì người dùng thấy ngay và chọn lại;
// còn ở đây phiếu ĐÃ hợp lệ rồi mới bị đổi ruột — lần sau nhìn vào chỉ thấy một phiếu Nháp
// bình thường, không có dấu vết nào nói nó vừa bị chuyển dây chuyền ký.
//
// Ba vế, mỗi vế bịt một kiểu xanh-giả:
// (1) chặn + thông điệp nêu ĐỦ hai nhóm;
// (2) ĐĨA bất động: pin cũ còn nguyên VÀ 0 dòng changelog — handler gán pin rồi mới
// `SaveChanges` ở cuối, nên chỉ assert "có ném" là không phân biệt được "chặn kịp"
// với "chặn sau khi đã ghi";
// (3) CONTROL DƯƠNG cùng lượt: re-pin sang bản v02 CÙNG NHÓM ⇒ qua, và changelog nhảy
// 0 → 1. Thiếu vế này thì một rào chặn-tuốt (hoặc lỗi seed làm mọi UpdateDraft ném)
// vẫn cho (1)+(2) xanh.
// ===================================================================
[Fact]
public async Task T4_UpdateDraft_RePinQuyTrinhLechNhom_ThiBiChan_VaGiuNguyenPinCu()
{
using var f = new KhkkWorkflowFixture();
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
var approver = await AddUserAsync(f, "NV duyệt");
var n3v1 = await SeedWorkflowAsync(f, "KHKK-N3", steps: [[[approver.Id]]]);
var n3v2 = await SeedWorkflowAsync(f, "KHKK-N3", steps: [[[approver.Id]]], version: 2);
var n5 = await SeedWorkflowAsync(f, "KHKK-N5", steps: [[[approver.Id]]]);
var planId = await SeedPlanAsync(f, drafter, n3v1, "KHKK/2026/301",
ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 90m)],
approvalGroup: 3);
// ---- (1) Re-pin sang quy trình của nhóm KHÁC.
var act = async () => await UpdateDraftAsync(f, drafter, planId, awId: n5);
var ex = await act.Should().ThrowAsync<ConflictException>(
"sửa nháp là cửa thứ hai vào cùng một chỗ — rào chỉ đứng ở Create thì nó vô hiệu "
+ "bằng đúng một request PUT hợp lệ về mọi mặt khác");
ex.Which.Message.Should().Contain("N5").And.Contain("N3",
"thông điệp phải nêu CẢ nhóm của quy trình lẫn nhóm của phiếu — thiếu một vế thì "
+ "người dùng biết mình sai nhưng không biết sai ở đâu");
// ---- (2) Đĩa bất động.
(await ReadPinAsync(f, planId)).Should().Be(n3v1,
"409 mà pin đã đổi = rào ném SAU khi ghi; phiếu vẫn chạy sai dây chuyền, chỉ thêm một lỗi đỏ");
(await ChangelogCountAsync(f, planId)).Should().Be(0,
"lệnh bị chặn không được để lại dòng 'Cập nhật thông tin kế hoạch ký kết' — vết ma "
+ "làm người đọc lịch sử tin rằng đã có thay đổi");
// ---- (3) CONTROL DƯƠNG: đổi đúng MỘT biến (nhóm của quy trình đích) ⇒ kết quả trái ngược.
await UpdateDraftAsync(f, drafter, planId, awId: n3v2, ghiChu: "Đổi sang bản v02 cùng nhóm");
(await ReadPinAsync(f, planId)).Should().Be(n3v2,
"cùng cửa, cùng người, cùng phiếu — chỉ khác nhóm của quy trình đích ⇒ rào SO KHỚP "
+ "chứ không phải chặn mọi lệnh re-pin");
(await ChangelogCountAsync(f, planId)).Should().Be(1,
"và phép đếm changelog ở (2) là phép đo SỐNG: nó biết tăng khi lệnh thật sự chạy");
}
// ===================================================================
// (T5) Cửa Submit — phiếu pin lệch nhóm TỪ TRƯỚC (data cũ / lách API) ⇒ 409 lúc trình.
//
// Vì sao cửa này bắt buộc phải có dù đã chặn ở Create + UpdateDraft: prod HÔM NAY có thể
// đang mang phiếu pin lệch (spec W0 §0.4 chạy hẳn một query audit để đếm trước khi vá).
// Rào ở hai cửa GHI không dọn được thứ đã nằm sẵn trong bảng — chỉ cửa TRÌNH mới chặn được
// phiếu cũ trước khi nó bước vào dây chuyền ký sai.
// Seed thẳng DbContext là CỐ Ý: đó là cách duy nhất dựng lại trạng thái "đã lệch từ trước"
// sau khi rào Create đóng.
//
// 🔴 Điều kiện để phép đo có nghĩa: `SubmitAsync` có SẴN 3 cửa 409 khác (phase, hạng mục
// chưa gán, hạng mục sai nhóm) ⇒ bắt được `ConflictException` là chưa nói lên gì.
// Fixture đã kéo hạng mục theo `approvalGroup` của phiếu, nên hai rào hạng mục im;
// và phiếu CHỨNG NHÂN pin đúng nhóm trình LỌT trong cùng một lượt chứng minh phần còn
// lại của đường trình vẫn thông.
// ===================================================================
[Fact]
public async Task T5_Submit_PhieuPinLechNhomTuTruoc_ThiBiChan_ChuKhongTrinhQua()
{
using var f = new KhkkWorkflowFixture();
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
var approver = await AddUserAsync(f, "NV duyệt");
var n3 = await SeedWorkflowAsync(f, "KHKK-N3", steps: [[[approver.Id]]]);
var n5 = await SeedWorkflowAsync(f, "KHKK-N5", steps: [[[approver.Id]]]);
// Phiếu nhóm 3 nhưng pin quy trình nhóm 5 — dựng thẳng, mô phỏng phiếu có từ trước rào.
var lech = await SeedPlanAsync(f, drafter, n5, "KHKK/2026/310",
ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 90m)],
approvalGroup: 3);
// Chứng nhân: y hệt, khác đúng một biến — pin ĐÚNG nhóm.
var dung = await SeedPlanAsync(f, drafter, n3, "KHKK/2026/311",
ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 90m)],
approvalGroup: 3);
var act = async () => await ActAsync(f, drafter, lech, "submit");
var ex = await act.Should().ThrowAsync<ConflictException>(
"phiếu lệch nhóm phải bị chặn Ở CỬA TRÌNH — hai rào kia không với tới dữ liệu đã nằm sẵn trong bảng");
ex.Which.Message.Should().Contain("N5").And.Contain("N3",
"thông điệp phải phân biệt được với 3 câu 409 khác của SubmitAsync (phase / hạng mục "
+ "chưa gán / hạng mục sai nhóm), nếu không người dùng đi sửa nhầm chỗ");
var blocked = await ReadPlanAsync(f, lech);
ShouldBeAt(blocked, ContractSigningPlanPhase.DangSoanThao, null, null,
"bị chặn thì phiếu phải Ở NGUYÊN nháp — sang ChoDuyet nửa vời là phiếu treo trong "
+ "hộp thư của người không có thẩm quyền ký");
// CHỨNG NHÂN cùng lượt: đường trình vẫn thông cho phiếu pin đúng nhóm ⇒ 409 ở trên là
// của rào NHÓM, không phải một rào khác của SubmitAsync vô tình nổ.
await ActAsync(f, drafter, dung, "submit");
var ok = await ReadPlanAsync(f, dung);
ShouldBeAt(ok, ContractSigningPlanPhase.ChoDuyet, 0, 1,
"cùng fixture, cùng hạng mục, cùng quy trình 1-Bước-1-Cấp — chỉ khác nhóm của quy "
+ "trình được pin ⇒ hai kết quả trái ngược");
}
// ===================================================================
// (T6) Vế ÂM — mã NGOÀI họ `KHKK-N[1-8]` (quy trình chung/legacy type-10) vẫn đi lọt ở
// CẢ HAI cửa mới. Ngữ nghĩa này là CHỦ ĐÍCH của rào cũ (`Features.cs:477-478`) và spec W1b
// ra lệnh giữ nguyên khi dời sang class dùng chung — siết thêm ở đây là khoá cứng mọi phiếu
// đang pin quy trình chung, mà đường sửa duy nhất lại chính là cửa vừa bị khoá.
//
// 🔴 Vế âm một mình là XANH-MÙ: hôm nay rào chưa tồn tại nên "đi lọt" đúng ở cả thế giới
// chưa-vá. Vì vậy chân (2) — CÙNG cửa, CÙNG phiếu, chỉ đổi mã đích sang trong-họ ⇒ 409 —
// là thứ chứng minh rào ĐANG CHẠY trong chính lượt này; thiếu nó thì T6 không đo gì.
// ===================================================================
[Fact]
public async Task T6_MaNgoaiHoNhom_UpdateDraftVaSubmit_VanDiLot()
{
using var f = new KhkkWorkflowFixture();
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
var approver = await AddUserAsync(f, "NV duyệt");
var chung01 = await SeedWorkflowAsync(f, "QT-KHKK-V2-001", steps: [[[approver.Id]]]);
var chung02 = await SeedWorkflowAsync(f, "QT-KHKK-V2-002", steps: [[[approver.Id]]]);
var n7 = await SeedWorkflowAsync(f, "KHKK-N7", steps: [[[approver.Id]]]);
var planId = await SeedPlanAsync(f, drafter, chung01, "KHKK/2026/320",
ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 90m)],
approvalGroup: 4);
// ---- (1) Cửa UpdateDraft: mã ngoài họ ⇒ KHÔNG bị hỏi nhóm.
await UpdateDraftAsync(f, drafter, planId, awId: chung02);
(await ReadPinAsync(f, planId)).Should().Be(chung02,
"quy trình chung không mang số nhóm trong mã ⇒ không có gì để so khớp; chặn ở đây là "
+ "siết oan và phiếu mất luôn đường tự sửa");
// ---- (2) CHỨNG NHÂN rào đang sống: cùng cửa, cùng phiếu, chỉ đổi mã đích sang TRONG họ.
var act = async () => await UpdateDraftAsync(f, drafter, planId, awId: n7);
await act.Should().ThrowAsync<ConflictException>(
"phiếu nhóm 4 pin `KHKK-N7` phải bị chặn — nếu vế này cũng đi lọt thì (1) và (3) chỉ "
+ "đang mô tả một thế giới CHƯA CÓ rào, không chứng minh ngữ nghĩa cho-qua nào cả");
(await ReadPinAsync(f, planId)).Should().Be(chung02, "409 thì pin giữ nguyên");
// ---- (3) Cửa Submit: vẫn mã ngoài họ ⇒ trình lọt.
await ActAsync(f, drafter, planId, "submit");
var after = await ReadPlanAsync(f, planId);
ShouldBeAt(after, ContractSigningPlanPhase.ChoDuyet, 0, 1,
"rào mới cắm vào SubmitAsync không được nuốt phiếu đang chạy quy trình chung");
}
// ===================================================================
// (T7) D6 — re-pin sang bản ĐÃ NGƯNG HOẠT ĐỘNG / CHƯA GHIM ⇒ 409 (mirror vá-3
// `CreateContractFromSigningPlanFeatures.cs:141-147`, CÙNG một lỗ ở bridge type-3).
//
// Vì sao đáng chặn: sau W1a, "tạo phiên bản mới" hạ `IsActive` bản cũ. Phiếu pin bản đã
// nghỉ vẫn chạy được (service resolve theo Id, không đọc cờ) nên sai KHÔNG lộ ra — nó lộ ở
// chỗ khác: người duyệt của bản cũ đã bị thay trong bản mới.
//
// Bốn chân trên CÙNG một bộ seed (cả 4 bản đều mã `KHKK-N2` ⇒ rào nhóm im, mọi 409 quan
// sát được chỉ có thể đến từ D6):
// (1) IsActive=false ⇒ 409;
// (2) IsUserSelectable=false ⇒ 409, và THÔNG ĐIỆP PHẢI KHÁC (1) — spec đòi 2 câu tách,
// vì hai nguyên nhân này sửa ở hai nút khác nhau trong Designer; gộp thành một câu
// `!IsActive || !IsUserSelectable` là admin đi bấm nhầm nút;
// (3) CONTROL DƯƠNG: bản active + đã ghim ⇒ re-pin qua;
// (4) 🔴 GIỮ-NGUYÊN-PIN KHÔNG BỊ HỎI: phiếu cũ đang pin bản đã nghỉ vẫn phải sửa được
// GhiChu. Đây là vế phân biệt "check trong nhánh đổi-pin" với "check mọi lần PUT" —
// bản vá đặt guard ngoài nhánh sẽ khoá cứng đúng những phiếu cần cứu nhất.
// ===================================================================
[Fact]
public async Task T7_RePinBanDaNgungHoacChuaGhim_ThiBiChan_ConGiuNguyenPinThiVanSuaDuoc()
{
using var f = new KhkkWorkflowFixture();
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
var approver = await AddUserAsync(f, "NV duyệt");
var dangDung = await SeedWorkflowAsync(f, "KHKK-N2", steps: [[[approver.Id]]]);
var daNghi = await SeedWorkflowAsync(f, "KHKK-N2", steps: [[[approver.Id]]],
version: 2, isActive: false);
var chuaGhim = await SeedWorkflowAsync(f, "KHKK-N2", steps: [[[approver.Id]]],
version: 3, isUserSelectable: false);
var banMoi = await SeedWorkflowAsync(f, "KHKK-N2", steps: [[[approver.Id]]], version: 4);
var planId = await SeedPlanAsync(f, drafter, dangDung, "KHKK/2026/330",
ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 90m)],
approvalGroup: 2);
// ---- (1) Bản đã ngưng hoạt động.
var actNghi = async () => await UpdateDraftAsync(f, drafter, planId, awId: daNghi);
var exNghi = await actNghi.Should().ThrowAsync<ConflictException>(
"pin bản đã nghỉ = phiếu chạy dàn người duyệt của một phiên bản không còn hiệu lực");
(await ReadPinAsync(f, planId)).Should().Be(dangDung);
// ---- (2) Bản chưa ghim cho người dùng chọn.
var actGhim = async () => await UpdateDraftAsync(f, drafter, planId, awId: chuaGhim);
var exGhim = await actGhim.Should().ThrowAsync<ConflictException>(
"cờ ghim là cách Admin quyết quy trình nào người dùng được chọn — bỏ qua nó ở đường "
+ "PUT thì nút Ghim/Bỏ ghim chỉ còn là trang trí");
(await ReadPinAsync(f, planId)).Should().Be(dangDung);
exGhim.Which.Message.Should().NotBe(exNghi.Which.Message,
"hai nguyên nhân sửa ở hai nút khác nhau ⇒ phải hai câu khác nhau; một câu gộp "
+ "`!IsActive || !IsUserSelectable` là đẩy admin đi mò");
// ---- (3) CONTROL DƯƠNG: bản active + đã ghim.
await UpdateDraftAsync(f, drafter, planId, awId: banMoi, ghiChu: "Chuyển sang bản v04");
(await ReadPinAsync(f, planId)).Should().Be(banMoi,
"khác đúng một biến (2 cờ trạng thái của quy trình đích) ⇒ hai kết quả trái ngược");
// ---- (4) Bản đang pin bị cho nghỉ SAU đó ⇒ phiếu vẫn phải sửa được nội dung.
await ArchiveWorkflowAsync(f, banMoi);
await UpdateDraftAsync(f, drafter, planId, awId: banMoi, ghiChu: "Ghi chú sau khi quy trình nghỉ");
var plan = await ReadPlanAsync(f, planId);
plan.GhiChu.Should().Be("Ghi chú sau khi quy trình nghỉ",
"GIỮ NGUYÊN pin thì không có gì để kiểm — đặt guard ngoài nhánh `awId != pin hiện tại` "
+ "là khoá cứng mọi phiếu đang pin bản đã nghỉ, đúng nhóm phiếu cần sửa nhất");
plan.ApprovalWorkflowId.Should().Be(banMoi, "và pin không được tự đổi trong lượt sửa GhiChu");
}
}

View File

@ -142,18 +142,25 @@ public class ContractSigningPlanGroupAndCatalogTests
KhkkGroupFixture f,
string code,
Guid[][][] steps,
ApprovalWorkflowApplicableType type = ApprovalWorkflowApplicableType.ContractSigningPlan)
ApprovalWorkflowApplicableType type = ApprovalWorkflowApplicableType.ContractSigningPlan,
// [S182 vá MAJOR-3] 3 tham số ĐUÔI cho rào D6 (`EnsureWorkflowPinnableAsync`): cần
// dựng nhiều version CÙNG một Code (UNIQUE là (Code, Version) —
// `ApprovalWorkflowConfiguration.cs:23`) rồi lật TỪNG cờ một. Optional TRAILING ⇒ mọi
// call-site cũ giữ nguyên chữ. Cùng khuôn `ContractSigningPlanApprovalTests.cs:135-140`.
int version = 1,
bool isActive = true,
bool isUserSelectable = true)
{
await using var db = f.NewDb(TestCurrentUser.System());
var wf = new ApprovalWorkflow
{
Id = Guid.NewGuid(),
Code = code,
Version = 1,
Version = version,
Name = "Quy trình duyệt Kế hoạch ký kết",
ApplicableType = type,
IsActive = true,
IsUserSelectable = true,
IsActive = isActive,
IsUserSelectable = isUserSelectable,
};
db.ApprovalWorkflows.Add(wf);
@ -855,9 +862,21 @@ public class ContractSigningPlanGroupAndCatalogTests
var wrongGroup = async () => await CreateAsync(f, bch, pe.PeId, wfN5, approvalGroup: 3);
var ex = await wrongGroup.Should().ThrowAsync<ConflictException>(
"quy trình họ KHKK-N{n} chỉ dùng được cho phiếu đúng nhóm n — sai nhóm là ký sai dây chuyền");
ex.Which.Message.Should().Contain("KHKK-N5").And.Contain("N3",
"thông báo phải chỉ ra CẢ quy trình đang pin lẫn nhóm của phiếu, nếu không người dùng "
+ "chỉ thấy '409' và không biết đổi cái gì");
// [S182 W1b — spec change, KHÔNG phải nới bar] Thân rào dời sang
// `ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync` dùng chung cho 3 cửa
// (tạo / sửa nháp / trình), và spec chốt MỘT câu 409 duy nhất cho cả ba:
// "Phiếu nhóm N{g} đang gắn quy trình N{k} — vào phiếu (Nháp/Trả lại) đổi lại quy trình
// đúng nhóm trước khi trình."
// Câu mới bỏ MÃ quy trình, giữ SỐ NHÓM. Không mất thông tin: rào chỉ nổ cho mã TRONG họ
// `KHKK-N[1-8]` nên "N5" xác định duy nhất mã "KHKK-N5" (mã ngoài họ đi thẳng, vế (3)).
// ⇒ pin cái BẤT BIẾN qua cả hai bản chữ: thông điệp phải nêu ĐỦ HAI nhóm.
// [S182 MINOR-1] Pin NGUYÊN CỤM có thứ tự ở ĐÚNG 1 ca này (các ca khác giữ Contain 2 vế):
// `Contain(N5) ∧ Contain(N3)` vẫn xanh nếu ai đó ĐẢO 2 đối số ở call-site
// (`EnsureWorkflowGroupMatchAsync(db, awId, approvalGroup, ct)` — 2 tham số kề nhau,
// 3 call-site). Cụm đầy đủ dưới đây chết ngay khi đảo ⇒ bất-biến thứ-tự có chứng nhân.
ex.Which.Message.Should().Contain("Phiếu nhóm N3 đang gắn quy trình N5",
"thông báo phải nêu ĐỦ và ĐÚNG CHIỀU: nhóm của phiếu (N3) trước, nhóm của quy trình "
+ "đang pin (N5) sau — đảo đối số ở bất kỳ call-site nào là câu này đổi chiều");
// Bị chặn thì KHÔNG được để lại dấu vết nào.
(await ReadPlanScopesAsync(f)).Should().BeEmpty("rào ném trước khi `Add` ⇒ không có phiếu nửa vời");
@ -901,6 +920,90 @@ public class ContractSigningPlanGroupAndCatalogTests
.ToListAsync(CancellationToken.None);
}
// ===================================================================
// [S182 — vá MAJOR-3 reviewer pre-commit] Rào D6 Ở ĐƯỜNG TẠO —
// `ContractSigningPlanFeatures.cs:507` gọi `ContractSigningPlanScope
// .EnsureWorkflowPinnableAsync` (thân `:331-346`).
//
// Vì sao ca này phải tồn tại RIÊNG: D6 cắm ở HAI site (Create `:507` + UpdateDraft `:642`),
// nhưng ca duy nhất phủ D6 — T7 `ContractSigningPlanApprovalTests.cs:1402` — gọi
// `UpdateDraftAsync` ở CẢ BỐN chân. Tức là write-path mà MỌI phiếu KHKK đi qua đang có 0
// răng đo: ai dời/đảo/gỡ guard khỏi Create thì cả suite vẫn xanh.
//
// Ba chân trên CÙNG một bộ seed. Ba quy trình khác nhau ĐÚNG ở Version + MỘT cờ; Code đều
// là `KHKK-N1` và phiếu cũng nhóm 1 ⇒ rào (v) `:515` khớp nên IM LẶNG, mọi 409 quan sát
// được chỉ có thể đến từ D6:
// (1) IsActive=false ⇒ 409 "… đã ngừng hoạt động";
// (2) IsUserSelectable=false ⇒ 409 "… chưa được ghim", và THÔNG ĐIỆP PHẢI KHÁC (1) — đó
// đúng là lý do prod tách 2 câu (`:328-330`: hai nguyên nhân sửa ở hai nút khác nhau
// trong Designer). 🔴 Bản này BUỘC giữ IsActive=true, nếu không vế `!IsActive` nổ
// trước và chân (2) đang đo lại chân (1) — hai cờ cùng false = phép đo rỗng.
// (3) CONTROL DƯƠNG: bản active + đã ghim ⇒ tạo được, VÀ mã ra `001`. Hai lần 409 ở trên
// đứng TRƯỚC `codeGen.GenerateAsync` (`:524`), mà codegen TỰ COMMIT trong transaction
// riêng (`ContractSigningPlanCodeGenerator.cs:23-43`) ⇒ đảo thứ tự hai bước này là số
// thứ tự bốc hơi vĩnh viễn (mã sẽ ra `003`) và dải mã KHKK thủng lỗ.
// ===================================================================
[Fact]
public async Task Create_PinQuyTrinhDaNgungHoacChuaGhim_ThiBiChan_VaKhongDotMa()
{
using var f = new KhkkGroupFixture();
var bch = As(await AddUserAsync(f, "BCH lập kế hoạch"));
var approver = await AddUserAsync(f, "NV duyệt");
var pe = await SeedPeForCreateAsync(f);
var dangDung = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]);
var daNghi = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]],
version: 2, isActive: false);
// IsActive VẪN true — xem ghi chú chân (2) ở khối trên.
var chuaGhim = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]],
version: 3, isUserSelectable: false);
// ---- (1) Pin bản ĐÃ NGƯNG HOẠT ĐỘNG.
var actNghi = async () => await CreateAsync(f, bch, pe.PeId, daNghi, approvalGroup: 1);
var exNghi = await actNghi.Should().ThrowAsync<ConflictException>(
"'tạo phiên bản mới' hạ IsActive bản cũ; pin được bản đã nghỉ thì phiếu chạy dàn "
+ "người duyệt của một phiên bản không còn hiệu lực — sai IM LẶNG, chỉ lộ khi hồ sơ "
+ "tới tay người đã bị thay trong bản mới");
exNghi.Which.Message.Should().Contain("ngừng hoạt động");
// ---- (2) Pin bản CHƯA GHIM cho người dùng chọn.
var actGhim = async () => await CreateAsync(f, bch, pe.PeId, chuaGhim, approvalGroup: 1);
var exGhim = await actGhim.Should().ThrowAsync<ConflictException>(
"FE chỉ hiện quy trình active+ghim, nhưng POST thẳng thì bỏ qua FE ⇒ cờ ghim phải "
+ "có răng ở BE, không thì nút Ghim/Bỏ ghim của Admin chỉ là trang trí");
exGhim.Which.Message.Should().Contain("ghim");
exGhim.Which.Message.Should().NotBe(exNghi.Which.Message,
"hai nguyên nhân sửa ở HAI NÚT khác nhau trong Designer ⇒ phải hai câu khác nhau; "
+ "gộp thành một câu `!IsActive || !IsUserSelectable` là đẩy admin đi mò");
// ---- Bị chặn thì KHÔNG để lại gì: không phiếu nửa vời, và mã CHƯA TỪNG được cấp.
(await ReadPlanScopesAsync(f)).Should().BeEmpty(
"D6 ném trước `db.ContractSigningPlans.Add` (`:577`) ⇒ không phiếu nào ra đời");
await using (var probe = f.NewDb(bch))
{
probe.WorkflowAppCodeSequences.Any(s => s.Prefix == "KHKK/2026").Should().BeFalse(
"hai lần 409 xảy ra TRƯỚC codegen ⇒ hàng sequence còn chưa được tạo lần nào");
}
// ---- (3) CONTROL DƯƠNG: cùng bộ seed, chỉ đổi trạng thái 2 cờ của quy trình đích.
var ok = await CreateAsync(f, bch, pe.PeId, dangDung, approvalGroup: 1);
ok.MaKeHoach.Should().Be("KHKK/2026/001",
"ra 002/003 tức là hai lần bị chặn đã TIÊU số thứ tự cho phiếu chưa từng tồn tại — "
+ "codegen tự commit nên số đó mất vĩnh viễn, không rollback theo request");
ok.LineCount.Should().Be(1, "PE seed có đúng 1 NCC trúng thầu ⇒ 1 dòng");
(await ReadPlanScopesAsync(f)).Should().BeEquivalentTo(new[]
{
new PlanScope("KHKK/2026/001", 1, dangDung),
}, "đúng MỘT phiếu ra đời và pin đúng bản đang dùng — nếu D6 chặn-tuốt thì chân này đỏ, "
+ "còn nếu D6 bị gỡ khỏi Create thì hai chân trên xanh giả; cần cả ba mới phân biệt được");
await using var db = f.NewDb(bch);
db.WorkflowAppCodeSequences.Single(s => s.Prefix == "KHKK/2026").LastSeq.Should().Be(1,
"đúng một lần cấp mã cho đúng một phiếu");
}
// ===================================================================
// [S168 K8-blocker] Lines-editor `UpdateContractSigningPlanLineCommand` — mảnh comment
// auto-gen HẸN cho K4b nhưng chưa từng land, dry-run K8 bắt: submit-guard vá-5 chặn

View File

@ -4,9 +4,10 @@ using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.ApprovalWorkflowsV2;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.ContractSigningPlans; // [S182 W1] KHKK plan + changelog (T1-T3, T8-T14)
using SolutionErp.Domain.Contracts; // ApprovalDecision + ChangelogAction (shared HĐ/PE)
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Office; // Proposal + ProposalLevelOpinion (F-16 blast radius)
using SolutionErp.Domain.Office; // Proposal + ProposalLevelOpinion (F-16 blast radius) + LeaveRequest (T12)
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;
@ -67,12 +68,15 @@ public class UpdateAwDefinitionTests
ApprovalWorkflowApplicableType type,
(int LevelOrder, Guid User)[][] stepSpecs,
decimal? ceoThreshold = null,
string code = "QT-UPD-V2")
string code = "QT-UPD-V2",
// [S182 W1] T14 cần 2 version CÙNG Code (UNIQUE là (Code, Version) —
// `ApprovalWorkflowConfiguration.cs:26`). Optional trailing ⇒ 14 call-site cũ không đổi.
int version = 1)
{
var wf = new ApprovalWorkflow
{
Code = code,
Version = 1,
Version = version,
ApplicableType = type,
Name = "QT test sửa tại chỗ",
IsActive = true,
@ -969,4 +973,561 @@ public class UpdateAwDefinitionTests
"vết phải đọc được bằng TÊN, không phải GUID");
}
}
// ###################################################################
// [S182 W1 — YC-013, 2026-08-08] 8 panel "Duyệt KHKK — Nhóm 1..8" mirror trang Duyệt NCC.
// SPEC FINAL: `.claude/workflows/runs/2026-08-08-S182-khkk-awf-mirror-ncc/
// spec-khkk-awf-mirror-ncc-08-08-2026.md` MỤC 2 W1.
//
// 10 lưới dưới đây (T1-T3 + T8-T14) đo 3 fix của W1 trên `ApprovalWorkflowV2AdminFeatures.cs`.
// Viết TRƯỚC khi BE land (test-before cho a/b, test-after cho c/d) ⇒ ca nào ĐỎ hôm nay là
// ĐỎ HỢP LỆ; hình dạng "đỏ vì chưa làm" đã ghi trong `sub-test-specialist-2.md §7`.
//
// 🔴 MỆNH ĐỀ TRUNG TÂM (R-6, spec `:14`): với `ApplicableType = ContractSigningPlan` (10),
// tập bị tắt khi tạo version mới là **THEO Code**, không theo Type:
// Where(w.Code == request.Code && w.ApplicableType == typeEnum && w.IsActive)
// Hôm nay `:340-343` lọc THEO TYPE ⇒ POST một nhóm = tắt IsActive của CẢ 8 nhóm prod.
// T1/T3 là hai nửa của cùng một mệnh đề; T2 giữ cho bản vá KHÔNG nới sang type khác.
// ⚠️ CẤM biến-thể regex `^KHKK-N[1-8]$` (R-6): mã type-10 ngoài họ vẫn phải per-Code —
// vế đó chính là chân thứ hai của T3.
// ###################################################################
private const ApprovalWorkflowApplicableType Khkk = ApprovalWorkflowApplicableType.ContractSigningPlan;
/// 8 quy trình nhóm `KHKK-N1..N8` (type-10, v01, IsActive) — hình dạng prod SAU seeder K3.
/// Một người duyệt dùng chung cho cả 8: rào trùng chỉ áp trong CÙNG một Cấp của CÙNG một Bước.
private static async Task<Dictionary<string, Guid>> SeedEightKhkkGroupsAsync(
TestApplicationDbContext db, Guid approverId)
{
var map = new Dictionary<string, Guid>();
for (var n = 1; n <= 8; n++)
{
var wf = await SeedWorkflowAsync(db, Khkk, [[(1, approverId)]], code: $"KHKK-N{n}");
map[$"KHKK-N{n}"] = wf.Id;
}
return map;
}
/// Lệnh POST "Tạo phiên bản mới" tối thiểu-nhưng-hợp-lệ (1 Bước × 1 Cấp).
private static CreateAwDefinitionCommand NewVersionCommand(
ApprovalWorkflowApplicableType type, string code, Guid approverId)
=> new(
ApplicableType: (int)type,
Code: code,
Name: $"Quy trình {code}",
Description: null,
Steps: [new CreateAwStepInput(1, "Bước 1", null, [new CreateAwLevelInput(1, "Cấp 1", approverId)])]);
private sealed record WfRow(string Code, int Version, bool IsActive);
private static Task<List<WfRow>> ReadWorkflowsAsync(
TestApplicationDbContext db, ApprovalWorkflowApplicableType type)
=> db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.ApplicableType == type)
.Select(w => new WfRow(w.Code, w.Version, w.IsActive))
.ToListAsync(CancellationToken.None);
/// 🔴 ĐƠN VỊ CỦA SỰ CỐ THẬT: "bao nhiêu NHÓM còn cửa tạo phiếu". FE auto-pin quy trình cho
/// phiếu mới bằng `find(w => w.isActive && code-match)` (spec W2g) ⇒ nhóm mất bản active là
/// nhóm KHÔNG lập được phiếu nữa. Đếm bằng đơn vị này thì thông điệp lỗi nói đúng thứ vỡ.
private static async Task<int> KhkkGroupsWithActiveVersionAsync(TestApplicationDbContext db)
{
var rows = await ReadWorkflowsAsync(db, Khkk);
return rows.Where(r => r.IsActive)
.Select(r => r.Code)
.Where(c => Enumerable.Range(1, 8).Any(n => c == $"KHKK-N{n}"))
.Distinct().Count();
}
private static async Task<ContractSigningPlan> SeedKhkkPlanAsync(
TestApplicationDbContext db,
Guid workflowId,
Guid drafterId,
string maKeHoach,
ContractSigningPlanPhase phase = ContractSigningPlanPhase.ChoDuyet,
int approvalGroup = 1,
bool isDeleted = false)
{
var plan = new ContractSigningPlan
{
Id = Guid.NewGuid(),
MaKeHoach = maKeHoach,
// 4 cột loose-Guid KHÔNG FK vật lý (`ContractSigningPlanConfiguration.cs:10-12`).
PurchaseEvaluationId = Guid.NewGuid(),
ProjectId = Guid.NewGuid(),
DepartmentId = Guid.NewGuid(),
DrafterUserId = drafterId,
Phase = phase,
ApprovalGroup = approvalGroup,
ApprovalWorkflowId = workflowId,
CurrentWorkflowStepIndex = phase == ContractSigningPlanPhase.ChoDuyet ? 0 : null,
CurrentApprovalLevelOrder = phase == ContractSigningPlanPhase.ChoDuyet ? 1 : null,
IsDeleted = isDeleted,
DeletedAt = isDeleted ? new DateTime(2026, 8, 7, 0, 0, 0, DateTimeKind.Utc) : null,
};
db.ContractSigningPlans.Add(plan);
await db.SaveChangesAsync(CancellationToken.None);
return plan;
}
/// Đơn nghỉ phép — module Văn phòng số pin **LOOSE, KHÔNG FK** (`WorkflowAppsConfiguration.cs:12-26`
/// không khai `HasOne<ApprovalWorkflow>`; `LeaveTypeId` cũng chỉ là Guid trần).
private static async Task<LeaveRequest> SeedLeaveRequestAsync(
TestApplicationDbContext db, Guid workflowId, Guid requesterId, string maDonTu)
{
var leave = new LeaveRequest
{
Id = Guid.NewGuid(),
MaDonTu = maDonTu,
RequesterUserId = requesterId,
RequesterFullName = "Người xin nghỉ",
LeaveTypeId = Guid.NewGuid(),
StartDate = new DateTime(2026, 8, 10, 0, 0, 0, DateTimeKind.Utc),
EndDate = new DateTime(2026, 8, 11, 0, 0, 0, DateTimeKind.Utc),
NumDays = 2m,
Reason = "Việc gia đình",
Status = WorkflowAppStatus.DaGuiDuyet,
ApprovalWorkflowId = workflowId,
CurrentApprovalLevelOrder = 1,
};
db.LeaveRequests.Add(leave);
await db.SaveChangesAsync(CancellationToken.None);
return leave;
}
private static Task DeleteWorkflowAsync(Harness h, Guid id)
=> new DeleteAwDefinitionCommandHandler(h.Db).Handle(
new DeleteAwDefinitionCommand(id), CancellationToken.None);
private static Task<int> WorkflowRowCountAsync(Harness h, Guid id)
=> h.Db.ApprovalWorkflows.AsNoTracking().CountAsync(w => w.Id == id, CancellationToken.None);
// ===================================================================
// (T1) 🔴 BOM ĐANG ARMED — tạo v02 cho MỘT nhóm KHÔNG được tắt 7 nhóm còn lại.
//
// Đo bằng đơn vị của sự cố: số NHÓM còn bản active. Hôm nay `:340-343` tắt theo TYPE ⇒
// sau một cú bấm "Tạo phiên bản mới" trên panel N1, con số đó tụt 8 → 1 và 7 folder KHKK
// mất cửa lập phiếu — im lặng, không lỗi, không có endpoint re-activate (spec R-2).
// ===================================================================
[Fact]
public async Task T1_CreateNewVersionForOneKhkkGroup_DeactivatesOnlyThatCode_OtherSevenGroupsStayActive()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
await SeedEightKhkkGroupsAsync(h.Db, alice.Id);
// Tiền đề đo: 8 nhóm, TẤT CẢ đang có bản active. Thiếu chốt này thì một seed hỏng
// (vd 1 nhóm đã tắt sẵn) vẫn cho assert phía dưới xanh ở thế giới sai.
(await KhkkGroupsWithActiveVersionAsync(h.Db)).Should().Be(8, "tiền đề: seeder K3 để cả 8 nhóm active");
await new CreateAwDefinitionCommandHandler(h.Db).Handle(
NewVersionCommand(Khkk, "KHKK-N1", alice.Id), CancellationToken.None);
var rows = await ReadWorkflowsAsync(h.Db, Khkk);
rows.Should().HaveCount(9, "POST đẻ ĐÚNG 1 dòng mới (v02 của N1) — không xoá/không thêm dòng nào khác");
rows.Single(r => r is { Code: "KHKK-N1", Version: 1 }).IsActive.Should().BeFalse(
"cùng Code thì bản cũ PHẢI nhường chỗ — đây là vế 'ratchet một chiều' của chính nhóm N1");
rows.Single(r => r is { Code: "KHKK-N1", Version: 2 }).IsActive.Should().BeTrue(
"version vừa tạo là bản đang dùng của nhóm N1");
rows.Where(r => r.Code != "KHKK-N1").Should().OnlyContain(r => r.IsActive,
"🔴 R-6: tập bị tắt lọc theo CODE, không theo TYPE. Lọc theo type thì một cú bấm trên "
+ "panel N1 tắt luôn N2..N8 — 7 nhóm mất bản active, và KHÔNG có endpoint bật lại");
(await KhkkGroupsWithActiveVersionAsync(h.Db)).Should().Be(8,
"số NHÓM còn cửa lập phiếu phải BẤT BIẾN qua thao tác của một nhóm khác; tụt xuống 1 "
+ "là đúng hình vụ nổ mà spec R-1 gọi là 'bom đã armed'");
}
}
// ===================================================================
// (T2) HỒI QUY cho các loại KHÁC: bản vá W1a chỉ được đổi hành vi của type-10.
// Type-1 (Duyệt NCC) tạo quy trình mã MỚI vẫn phải tắt mã CŨ cùng type (1-active-per-type).
// Kèm chứng nhân xuyên-type: 8 nhóm KHKK không được suy suyển vì một lệnh của type-1.
// ===================================================================
[Fact]
public async Task T2_CreateNewCodeForType1_StillDeactivatesOldCodeOfSameType_AndLeavesKhkkGroupsAlone()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id)]], code: "QT-DN-V2-001");
await SeedEightKhkkGroupsAsync(h.Db, alice.Id);
// Mã KHÁC, cùng type-1 — đúng ca mà per-Code (nếu áp cho mọi type) sẽ để LỌT 2 bản active.
await new CreateAwDefinitionCommandHandler(h.Db).Handle(
NewVersionCommand(ApprovalWorkflowApplicableType.DuyetNcc, "QT-DN-V2-002", alice.Id),
CancellationToken.None);
var type1 = await ReadWorkflowsAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc);
type1.Single(r => r.Code == "QT-DN-V2-001").IsActive.Should().BeFalse(
"type-1 GIỮ ngữ nghĩa 1-active-per-type: mã cũ phải tắt khi có quy trình mới của cùng loại");
type1.Single(r => r.Code == "QT-DN-V2-002").IsActive.Should().BeTrue();
type1.Count(r => r.IsActive).Should().Be(1,
"vá W1a mà nới per-Code cho MỌI type thì trang Duyệt NCC có 2 quy trình cùng active — "
+ "phiếu mới không biết pin cái nào");
(await KhkkGroupsWithActiveVersionAsync(h.Db)).Should().Be(8,
"chứng nhân xuyên-type: lệnh của type-1 không được chạm 8 nhóm type-10");
}
}
// ===================================================================
// (T3) F4 — mã type-10 NGOÀI họ `KHKK-N[1-8]`. Hai chân, mỗi chân giết một bản vá sai:
// (a) tạo `QT-KHKK-V2-001` ⇒ 8 nhóm BẤT BIẾN → giết bản vá "giữ per-type cho code lạ"
// (đúng cái mà công thức regex FE-invest sẽ làm — R-6 CẤM);
// (b) tạo LẠI CHÍNH mã đó ⇒ v01 phải tắt → giết bản vá lười "type-10 thì đừng tắt gì cả"
// (chân (a) một mình sẽ XANH với bản vá đó).
// ===================================================================
[Fact]
public async Task T3_CreateType10_WithOutOfFamilyCode_LeavesEightGroupsActive_AndStillRatchetsItsOwnCode()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
await SeedEightKhkkGroupsAsync(h.Db, alice.Id);
var handler = new CreateAwDefinitionCommandHandler(h.Db);
// ---- (a) mã type-10 chưa từng có ⇒ KHÔNG có bản cũ nào để tắt.
await handler.Handle(NewVersionCommand(Khkk, "QT-KHKK-V2-001", alice.Id), CancellationToken.None);
(await KhkkGroupsWithActiveVersionAsync(h.Db)).Should().Be(8,
"🔴 F4: quy trình type-10 mã ngoài họ (legacy/chung) tạo mới KHÔNG được đụng 8 nhóm. "
+ "Công thức regex `^KHKK-N[1-8]$` cho mã này rơi về nhánh per-type ⇒ NUKE cả 8 (R-6 cấm)");
var afterA = await ReadWorkflowsAsync(h.Db, Khkk);
afterA.Single(r => r.Code == "QT-KHKK-V2-001").IsActive.Should().BeTrue();
afterA.Count(r => r.IsActive).Should().Be(9, "8 nhóm + 1 quy trình chung, cùng sống song song");
// ---- (b) CHÍNH mã đó, lần thứ hai ⇒ ratchet per-Code vẫn phải chạy.
await handler.Handle(NewVersionCommand(Khkk, "QT-KHKK-V2-001", alice.Id), CancellationToken.None);
var afterB = await ReadWorkflowsAsync(h.Db, Khkk);
afterB.Single(r => r is { Code: "QT-KHKK-V2-001", Version: 1 }).IsActive.Should().BeFalse(
"per-Code áp cho MỌI mã type-10, kể cả ngoài họ — 'type-10 thì không tắt gì' là bản vá "
+ "làm 2 version cùng mã cùng active, FE auto-pin bốc ngẫu nhiên");
afterB.Single(r => r is { Code: "QT-KHKK-V2-001", Version: 2 }).IsActive.Should().BeTrue();
(await KhkkGroupsWithActiveVersionAsync(h.Db)).Should().Be(8, "8 nhóm vẫn bất biến sau lượt 2");
}
}
// ===================================================================
// (T8) Fix (c) — PUT sửa-tại-chỗ quy trình type-10 phải ghi VẾT vào phiếu KHKK đang treo.
// Dialog PUT đang HỨA nguyên văn "ghi vào lịch sử phiếu đang chạy" (`ApprovalWorkflowsV2Page.tsx:963-977`)
// trong khi cổng `:863-865` chỉ mở cho DuyetNcc/DuyetNccPhuongAn ⇒ lời hứa rỗng cho KHKK.
//
// Đo SHAPE chứ không chỉ đếm: một dòng vết thiếu `EntityId`/`PhaseAtChange` thì màn lịch sử
// phiếu hiện một dòng "ai đó sửa gì đó" — vô dụng đúng lúc cần nhất.
// ===================================================================
[Fact]
public async Task T8_UpdateType10Workflow_WithPendingKhkkPlan_WritesExactlyOneChangelogRowWithFullShape()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Lê Văn Bình", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị hệ thống", null, [AppRoles.Admin]);
var drafter = await h.Fix.CreateUserAsync("bch@t.vn", "BCH lập kế hoạch", null, [AppRoles.Drafter]);
var wf = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "KHKK-N3");
var plan = await SeedKhkkPlanAsync(h.Db, wf.Id, drafter.Id, "KHKK/2026/101",
ContractSigningPlanPhase.ChoDuyet, approvalGroup: 3);
var lvlAlice = wf.Steps.Single().Levels.Single();
var cmd = WithLevels(BuildEchoCommand(wf, reason: "Anh Chương nghỉ phép — bổ sung người ký thay"), 0,
[EchoLevel(lvlAlice), NewLevel(1, bob.Id)]);
await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
// Sanity chống rỗng-nghĩa: lệnh THẬT SỰ đổi cấu hình (⇒ `changes` khác rỗng).
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.Steps.Single().Levels.Should().HaveCount(2, "không có thay đổi thì không có vết để đo");
// Đọc bằng IgnoreQueryFilters: đọc THIẾU trông y hệt "chưa ghi" (bài S155).
var logs = await h.Db.ContractSigningPlanChangelogs.IgnoreQueryFilters().AsNoTracking()
.ToListAsync(CancellationToken.None);
logs.Should().ContainSingle("đúng 1 phiếu đang treo ⇒ đúng 1 dòng vết");
var log = logs.Single();
log.ContractSigningPlanId.Should().Be(plan.Id);
log.EntityType.Should().Be(ContractSigningPlanEntityType.Workflow, "enum slot 4 đã có sẵn — không đẻ slot mới");
log.Action.Should().Be(ChangelogAction.Update);
log.EntityId.Should().Be(wf.Id, "trỏ về quy trình bị sửa — thiếu thì không lần ngược được");
log.PhaseAtChange.Should().Be(ContractSigningPlanPhase.ChoDuyet);
log.UserId.Should().Be(admin.Id);
log.UserName.Should().Be("Quản trị hệ thống", "người đọc lịch sử cần TÊN, không phải GUID");
log.Summary.Should().Contain("KHKK-N3").And.Contain("giữ nguyên phiên bản",
"phân biệt 'sửa tại chỗ' với 'tạo version mới' — hai việc khác hẳn nhau về hệ quả");
log.ContextNote.Should().Contain("Lý do: Anh Chương nghỉ phép");
log.ContextNote.Should().Contain("THÊM NV duyệt Lê Văn Bình",
"nội dung đổi phải đọc được bằng tên người, mirror khối PE `:874-891`");
(await h.Db.PurchaseEvaluationChangelogs.AsNoTracking().CountAsync(CancellationToken.None))
.Should().Be(0, "quy trình type-10 KHÔNG được rải vết sang changelog của module PE");
}
}
// ===================================================================
// (T9) Vế ÂM của cùng cơ chế — và có CONTROL DƯƠNG TRONG CÙNG MỘT LƯỢT.
// Nếu chỉ seed phiếu đã kết thúc rồi assert "0 dòng" thì ca này XANH cả trong thế giới
// fix (c) chưa từng được viết. Vì vậy 4 phiếu cùng một quy trình:
// • ChoDuyet SỐNG → PHẢI có vết (chứng cơ chế đang chạy)
// • DaDuyet / TuChoi → KHÔNG (đã đóng, sửa quy trình không còn ảnh hưởng)
// • ChoDuyet XOÁ MỀM → KHÔNG (spec `:34`: query-filter tự loại — vết cho phiếu đã xoá là rác)
// ===================================================================
[Fact]
public async Task T9_UpdateType10Workflow_ChangelogGoesOnlyToLivePendingPlan_NotFinishedNorSoftDeleted()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Lê Văn Bình", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị hệ thống", null, [AppRoles.Admin]);
var drafter = await h.Fix.CreateUserAsync("bch@t.vn", "BCH lập kế hoạch", null, [AppRoles.Drafter]);
var wf = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "KHKK-N5");
var pending = await SeedKhkkPlanAsync(h.Db, wf.Id, drafter.Id, "KHKK/2026/110",
ContractSigningPlanPhase.ChoDuyet, approvalGroup: 5);
var approved = await SeedKhkkPlanAsync(h.Db, wf.Id, drafter.Id, "KHKK/2026/111",
ContractSigningPlanPhase.DaDuyet, approvalGroup: 5);
var rejected = await SeedKhkkPlanAsync(h.Db, wf.Id, drafter.Id, "KHKK/2026/112",
ContractSigningPlanPhase.TuChoi, approvalGroup: 5);
var softDeleted = await SeedKhkkPlanAsync(h.Db, wf.Id, drafter.Id, "KHKK/2026/113",
ContractSigningPlanPhase.ChoDuyet, approvalGroup: 5, isDeleted: true);
var lvlAlice = wf.Steps.Single().Levels.Single();
var cmd = WithLevels(BuildEchoCommand(wf, reason: "Bổ sung người ký"), 0,
[EchoLevel(lvlAlice), NewLevel(1, bob.Id)]);
await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
var logs = await h.Db.ContractSigningPlanChangelogs.IgnoreQueryFilters().AsNoTracking()
.ToListAsync(CancellationToken.None);
logs.Select(l => l.ContractSigningPlanId).Should().BeEquivalentTo(new[] { pending.Id },
$"chỉ phiếu CHƯA kết thúc và CHƯA xoá mới chịu ảnh hưởng — DaDuyet {approved.MaKeHoach}, "
+ $"TuChoi {rejected.MaKeHoach} và phiếu xoá mềm {softDeleted.MaKeHoach} đều phải bị loại; "
+ "riêng dòng của phiếu ChoDuyet sống là CONTROL DƯƠNG chứng cơ chế đang chạy thật");
}
}
// ===================================================================
// (T10) Fix (d)(i) — XOÁ quy trình còn phiếu KHKK pin ⇒ 409 LỊCH SỰ, không phải 500 thô.
// Mã dùng ở đây là NGOÀI họ `KHKK-N{n}` để rào D5 (T14) không xen vào: 409 ở đây chỉ có thể
// do usage-check. Kèm 2 vế bắt buộc:
// • CONTROL DƯƠNG cùng lượt: quy trình 0-usage vẫn xoá được ⇒ guard không phải "chặn tuốt";
// • CHỨNG-NHÂN: FK Restrict là thật — không có guard thì người dùng nhận DbUpdateException (547).
// ===================================================================
[Fact]
public async Task T10_DeleteWorkflow_WithPinnedKhkkPlan_Blocked409_WhileUnusedOneStillDeletes()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var drafter = await h.Fix.CreateUserAsync("bch@t.vn", "BCH lập kế hoạch", null, [AppRoles.Drafter]);
var used = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "QT-KHKK-V2-001");
var free = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "QT-KHKK-V2-002");
await SeedKhkkPlanAsync(h.Db, used.Id, drafter.Id, "KHKK/2026/120");
var act = async () => await DeleteWorkflowAsync(h, used.Id);
var ex = (await act.Should().ThrowAsync<ConflictException>(
"còn phiếu pin thì phải chặn LỊCH SỰ — để rơi xuống FK là người dùng nhận 500 không đọc được")).Which;
ex.Message.Should().Contain("QT-KHKK-V2-001",
"phải gọi ĐÍCH DANH quy trình: admin bấm xoá từ một danh sách nhiều dòng, câu 409 "
+ "không nêu tên thì không biết dòng nào vừa bị chặn");
(await WorkflowRowCountAsync(h, used.Id)).Should().Be(1, "409 rồi thì quy trình phải còn nguyên");
// Thông điệp phải MANG SỐ ĐẾM THẬT (rào quét 8 module + 8 bảng chữ ký), không phải
// một câu hằng. Đo bằng VI-SAI để không pin chữ: thêm đúng 1 hồ sơ pin nữa rồi gọi
// LẠI CÙNG một lệnh — câu trả về phải KHÁC. Cách này miễn nhiễm với việc BE đổi
// "phiếu"/"hồ sơ"/"bản ghi" (rào phủ cả đơn nghỉ phép lẫn hợp đồng, "phiếu" là chữ
// của riêng KHKK), nhưng vẫn giết được bản vá trả câu cố định.
await SeedKhkkPlanAsync(h.Db, used.Id, drafter.Id, "KHKK/2026/121");
var ex2 = (await act.Should().ThrowAsync<ConflictException>()).Which;
ex2.Message.Should().NotBe(ex.Message,
"1 hồ sơ đang gắn và 2 hồ sơ đang gắn mà ra CÙNG một câu ⇒ con số trong câu là "
+ "chữ chết; admin không biết còn bao nhiêu thứ phải gỡ trước khi xoá được");
// CONTROL DƯƠNG cùng lượt — cùng loại, cùng hình dạng, khác đúng một biến: có/không phiếu pin.
await DeleteWorkflowAsync(h, free.Id);
(await WorkflowRowCountAsync(h, free.Id)).Should().Be(0,
"quy trình 0-usage vẫn phải xoá được — nếu ca này cũng 409 thì guard đang chặn tuốt "
+ "và ca trên không chứng minh gì");
// CHỨNG-NHÂN: đây là thứ người dùng nhận nếu KHÔNG có guard (FK Restrict
// `ContractSigningPlanConfiguration.cs:50-53`). Dọn tracker trước — bài F-14.
h.Db.ChangeTracker.Clear();
var stillTracked = await h.Db.ApprovalWorkflows.FirstAsync(w => w.Id == used.Id, CancellationToken.None);
h.Db.ApprovalWorkflows.Remove(stillTracked);
var raw = async () => await h.Db.SaveChangesAsync(CancellationToken.None);
await raw.Should().ThrowAsync<DbUpdateException>(
"guard 409 đang đỡ đúng quả nổ này — không có nó thì UI hiện 500 và admin đoán mò");
}
}
// ===================================================================
// (T11) F2 — phiếu KHKK ĐÃ XOÁ MỀM vẫn phải chặn xoá quy trình.
// Xoá mềm = row VẪN NẰM TRONG BẢNG ⇒ FK Restrict vẫn nổ; nhưng guard đọc qua global filter
// (`ContractSigningPlanConfiguration.cs:55`) sẽ ĐẾM ĐƯỢC 0 và cho đi tiếp — "vắng mặt đọc
// thành sạch". Vậy nên guard BẮT BUỘC `IgnoreQueryFilters()`.
// 🔴 Nếu ca này ĐỎ với `DbUpdateException` thay vì `ConflictException` thì đó ĐÚNG là lỗ
// F2 — BÁO guard thiếu, KHÔNG nới test.
// ===================================================================
[Fact]
public async Task T11_DeleteWorkflow_PinnedOnlyBySoftDeletedKhkkPlan_StillBlocked409_NotFkCrash()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var drafter = await h.Fix.CreateUserAsync("bch@t.vn", "BCH lập kế hoạch", null, [AppRoles.Drafter]);
var used = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "QT-KHKK-V2-001");
var free = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "QT-KHKK-V2-002");
await SeedKhkkPlanAsync(h.Db, used.Id, drafter.Id, "KHKK/2026/130", isDeleted: true);
// Tiền đề: phiếu KHÔNG còn nhìn thấy qua đường thường, nhưng VẪN nằm trong bảng.
(await h.Db.ContractSigningPlans.AsNoTracking()
.CountAsync(p => p.ApprovalWorkflowId == used.Id, CancellationToken.None))
.Should().Be(0, "global filter che phiếu xoá mềm — đây chính là cái bẫy");
(await h.Db.ContractSigningPlans.IgnoreQueryFilters().AsNoTracking()
.CountAsync(p => p.ApprovalWorkflowId == used.Id, CancellationToken.None))
.Should().Be(1, "row vẫn còn ⇒ FK Restrict vẫn sẽ nổ nếu guard bỏ qua nó");
var act = async () => await DeleteWorkflowAsync(h, used.Id);
await act.Should().ThrowAsync<ConflictException>(
"F2: phiếu xoá mềm là phiếu KHÔI PHỤC ĐƯỢC (màn 'Đã xoá') — guard phải đọc bằng "
+ "IgnoreQueryFilters. Nhận DbUpdateException ở đây = guard đang mù trước soft-delete");
(await WorkflowRowCountAsync(h, used.Id)).Should().Be(1);
await DeleteWorkflowAsync(h, free.Id);
(await WorkflowRowCountAsync(h, free.Id)).Should().Be(0,
"control dương: khác đúng một biến (có/không phiếu xoá-mềm pin) ⇒ hai kết quả trái ngược");
}
}
// ===================================================================
// (T12) F3 — phiếu Văn phòng số (nghỉ phép) pin quy trình ⇒ cũng phải 409.
// 🔴 Đây là ca DATA-INTEGRITY, không phải UX: 5 module Office pin **LOOSE, 0 FK** ⇒ hôm nay
// DELETE trả 200 và đơn nghỉ phép trỏ vào một quy trình KHÔNG CÒN TỒN TẠI (dangling, im lặng).
// Guard usage-check là RÀO DUY NHẤT — chứng minh bằng chính DB ở cuối test.
// ===================================================================
[Fact]
public async Task T12_DeleteWorkflow_PinnedByLeaveRequest_Blocked409_BecauseGuardIsTheOnlyFence()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var staff = await h.Fix.CreateUserAsync("nv@t.vn", "Nhân viên xin nghỉ", null, []);
var used = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.LeaveRequest,
[[(1, alice.Id)]], code: "QT-NP-V2-001");
var witness = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.LeaveRequest,
[[(1, alice.Id)]], code: "QT-NP-V2-002");
var leave = await SeedLeaveRequestAsync(h.Db, used.Id, staff.Id, "DT/LR/2026/001");
var leaveWitness = await SeedLeaveRequestAsync(h.Db, witness.Id, staff.Id, "DT/LR/2026/002");
var act = async () => await DeleteWorkflowAsync(h, used.Id);
await act.Should().ThrowAsync<ConflictException>(
"đơn nghỉ phép đang pin quy trình này — xoá là đơn treo trỏ vào hư không");
(await WorkflowRowCountAsync(h, used.Id)).Should().Be(1);
(await h.Db.LeaveRequests.AsNoTracking().FirstAsync(x => x.Id == leave.Id, CancellationToken.None))
.ApprovalWorkflowId.Should().Be(used.Id, "đơn vẫn pin nguyên — 409 không được đụng dữ liệu");
// 🔴 CHỨNG-NHÂN "rào duy nhất": xoá THẲNG quy trình thứ hai qua DbContext.
// Với PE/HĐ/KHKK thì câu này nổ FK; với module Office nó ĐI LỌT — và đơn nghỉ phép
// thành mồ côi. Đó là lý do guard này là DATA-INTEGRITY chứ không phải làm đẹp UX.
h.Db.ChangeTracker.Clear();
var tracked = await h.Db.ApprovalWorkflows.FirstAsync(w => w.Id == witness.Id, CancellationToken.None);
h.Db.ApprovalWorkflows.Remove(tracked);
await h.Db.SaveChangesAsync(CancellationToken.None); // KHÔNG ném — 0 FK bảo vệ
(await WorkflowRowCountAsync(h, witness.Id)).Should().Be(0);
var orphan = await h.Db.LeaveRequests.AsNoTracking()
.FirstAsync(x => x.Id == leaveWitness.Id, CancellationToken.None);
orphan.ApprovalWorkflowId.Should().Be(witness.Id,
"đơn vẫn giữ Id của quy trình đã biến mất = dangling pointer THẬT trong DB — "
+ "đúng thứ mà guard 409 ở trên tồn tại để chặn");
}
}
// ===================================================================
// (T13) Fix (d)(iii) — id không tồn tại phải là 404, không phải 500 thô.
// `KeyNotFoundException` là BCL, `GlobalExceptionMiddleware` KHÔNG map ⇒ rơi 500.
// Control dương cùng lượt: id THẬT (0 usage, ngoài họ) xoá trơn ⇒ 404 là do id, không phải
// do handler hỏng.
// ===================================================================
[Fact]
public async Task T13_DeleteWorkflow_UnknownId_ThrowsNotFound_NotRawBclKeyNotFound()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var real = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "QT-KHKK-V2-001");
var act = async () => await DeleteWorkflowAsync(h, Guid.NewGuid());
await act.Should().ThrowAsync<NotFoundException>(
"middleware chỉ map NotFoundException→404; KeyNotFoundException (BCL) rơi ra 500 thô");
await DeleteWorkflowAsync(h, real.Id);
(await WorkflowRowCountAsync(h, real.Id)).Should().Be(0,
"control dương: đường xoá vẫn chạy bình thường ⇒ ngoại lệ trên là VỀ ID, không phải handler chết");
}
}
// ===================================================================
// (T14) D5 — nhóm `KHKK-N{n}` phải luôn còn ≥1 version.
// Xoá bản CUỐI của một nhóm = đóng cửa lập phiếu nhóm đó, và đường mở lại duy nhất là
// seeder chạy khi boot (M-1) — tức người dùng không tự cứu được từ UI.
// Ba chân trên CÙNG một bộ seed, mỗi chân bịt một kiểu vá sai:
// (1) xoá v01 khi còn v02 ⇒ OK (vá "cấm xoá mọi bản của nhóm" sẽ ĐỎ ở đây)
// (2) xoá v02 khi nó là bản cuối ⇒ 409
// (3) xoá quy trình NGOÀI họ chỉ có 1 version ⇒ OK (vá "cấm xoá bản-duy-nhất" sẽ ĐỎ ở đây)
// ===================================================================
[Fact]
public async Task T14_DeleteLastVersionOfKhkkGroup_Blocked409_ButNonLastAndOutOfFamilyDeleteFine()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var n2v1 = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "KHKK-N2", version: 1);
var n2v2 = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "KHKK-N2", version: 2);
var legacy = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "QT-KHKK-V2-001");
// Tiền đề: KHÔNG phiếu nào pin ⇒ 409 (nếu có) chỉ có thể đến từ D5, không phải usage-check.
(await h.Db.ContractSigningPlans.IgnoreQueryFilters().AsNoTracking()
.CountAsync(CancellationToken.None)).Should().Be(0, "0 usage ⇒ tách bạch D5 với rào (i)");
// ---- (1) còn v02 đứng sau ⇒ xoá v01 hợp lệ.
await DeleteWorkflowAsync(h, n2v1.Id);
(await WorkflowRowCountAsync(h, n2v1.Id)).Should().Be(0,
"version cũ 0-usage KHÔNG phải bản cuối thì vẫn dọn được — rào D5 không được nuốt luôn ca này");
(await WorkflowRowCountAsync(h, n2v2.Id)).Should().Be(1);
// ---- (2) nay v02 là bản CUỐI của nhóm N2 ⇒ chặn.
var act = async () => await DeleteWorkflowAsync(h, n2v2.Id);
var ex = (await act.Should().ThrowAsync<ConflictException>(
"xoá bản cuối = nhóm N2 mất quy trình, mà đường tái sinh duy nhất là seeder lúc boot")).Which;
ex.Message.Should().Contain("N2", "thông điệp phải chỉ đích danh nhóm đang bị đóng cửa");
(await WorkflowRowCountAsync(h, n2v2.Id)).Should().Be(1, "409 thì bản cuối phải còn nguyên");
// ---- (3) ngoài họ, chỉ 1 version ⇒ KHÔNG dính D5.
await DeleteWorkflowAsync(h, legacy.Id);
(await WorkflowRowCountAsync(h, legacy.Id)).Should().Be(0,
"D5 chỉ áp cho họ KHKK-N[1-8]; chặn cả 'bản duy nhất' của quy trình chung là siết oan");
}
}
}