[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
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:
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user