[CLAUDE] Workflow: W1 gỡ 2 bom per-type type-3 — deactivate per-Code (R-6 tập tường minh) + overview family; test-before RED→GREEN +4 test (672/672)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-10 18:20:40 +07:00
parent 5a77f4f127
commit 7bad4ae186
2 changed files with 361 additions and 14 deletions

View File

@ -82,7 +82,22 @@ public record AwDefinitionDto(
public record AwTypeSummaryDto(
int ApplicableType,
string ApplicableTypeLabel,
// `Active` = bản đang áp dụng ĐẦU TIÊN của loại (giữ nguyên nghĩa từ Mig 22 — FE đọc
// thẳng ô này: ApprovalWorkflowsV2Page.tsx thẻ landing + panel "đang áp dụng").
// ⚠️ Với loại per-Code-family, đây chỉ là MỘT trong N bản đang áp dụng (bản Version cao
// nhất gặp trước), KHÔNG đại diện cho `Code` nào cả ⇒ panel per-Code phải tự cắt theo
// `Code` (KHKK đang làm vậy: ApprovalWorkflowsV2Page.tsx:321-339), đừng đọc ô này.
AwDefinitionDto? Active,
// [S187 W1b] Các bản ĐANG ÁP DỤNG CÒN LẠI của loại — KHÔNG lặp lại bản trong `Active`.
// Tập đầy đủ những bản đang áp dụng = [Active] ActiveOthers.
//
// Vì sao cần: loại per-Code-family có NHIỀU quy trình cùng IsActive trong CÙNG một
// ApplicableType (type-10 KHKK 8 nhóm N1..N8 · type-3 HĐ 7 loại). Trước bản này overview
// chỉ trả 1 và N-1 bản còn lại chỉ còn đường ra qua `History` — mà `History` là KHO PHIÊN
// BẢN, FE dựng mục "phiên bản cũ" bằng `history.filter(d => !d.isActive)` ⇒ chúng KHÔNG
// xuất hiện ở đâu trên màn hình. Loại 1-active-per-type thì danh sách này RỖNG (không đổi gì).
List<AwDefinitionDto> ActiveOthers,
// KHO PHIÊN BẢN — MỌI version của loại, kể cả bản đang áp dụng (không lọc `!IsActive`).
List<AwDefinitionDto> History);
public record AwAdminOverviewDto(List<AwTypeSummaryDto> Types);
@ -206,10 +221,26 @@ public class GetAwAdminOverviewQueryHandler(
.Select(type =>
{
var versions = definitions.Where(d => d.ApplicableType == type).Select(ToDto).ToList();
// 🔴 [S187 W1b] Trả ĐỦ mọi bản đang áp dụng của loại, không chỉ bản đầu.
//
// Loại per-Code-family (type-10 KHKK 8 nhóm · type-3 HĐ 7 loại) có N bản cùng
// IsActive; bản trước lấy `versions.FirstOrDefault(v => v.IsActive)` ⇒ N-1 bản kia
// rơi hết vào `History`, nơi FE lọc `!isActive` để dựng danh sách phiên bản cũ ⇒
// chúng biến mất khỏi UI, im lặng.
//
// Chia 2 ô thay vì gộp 1: `Active` PHẢI giữ nguyên giá trị cũ (FE đang đọc thẳng),
// nên `ActiveOthers` gánh phần còn lại — hợp của 2 ô = trọn tập, KHÔNG phần tử nào
// xuất hiện hai lần. Không hỏi ApplicableType ở đây là CỐ Ý: luật là "có mấy bản
// đang áp dụng thì trả bấy nhiêu", tự đúng cho loại family thêm sau này; loại
// 1-active-per-type giữ y hệt hành vi cũ (`ActiveOthers` rỗng).
var actives = versions.Where(v => v.IsActive).ToList();
return new AwTypeSummaryDto(
(int)type,
AwLabels.Type.GetValueOrDefault(type, type.ToString()),
versions.FirstOrDefault(v => v.IsActive),
actives.FirstOrDefault(),
actives.Skip(1).ToList(),
versions);
})
.ToList();
@ -328,6 +359,22 @@ public class CreateAwDefinitionCommandValidator : AbstractValidator<CreateAwDefi
public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
: IRequestHandler<CreateAwDefinitionCommand, Guid>
{
// 🔴 [S187 W1a] Những loại tắt bản active theo phạm vi **per-CODE** — TẬP TYPE TƯỜNG MINH.
//
// Tiêu chí vào danh sách: loại đó có NHIỀU quy trình sống song song trong CÙNG một
// ApplicableType, mỗi cái một `Code` riêng, và mỗi cái phải tự giữ bản active của mình:
// • type-10 `ContractSigningPlan` (KHKK) — 8 nhóm duyệt `KHKK-N1..N8`
// (`DbInitializer.SeedKhkkGroupWorkflowsAsync`, seed cả 8 với IsActive=1);
// • type-3 `Contract` (HĐ) — 7 loại hợp đồng, mỗi loại một quy trình riêng.
//
// Thêm loại vào đây = quyết định về NGỮ NGHĨA của loại đó, không phải về hình dạng mã:
// mọi `Code` của loại đều đi nhánh per-Code, kể cả mã ngoài họ (R-6).
private static readonly ApprovalWorkflowApplicableType[] PerCodeDeactivationTypes =
[
ApprovalWorkflowApplicableType.ContractSigningPlan,
ApprovalWorkflowApplicableType.Contract,
];
public async Task<Guid> Handle(CreateAwDefinitionCommand request, CancellationToken ct)
{
var typeEnum = (ApprovalWorkflowApplicableType)request.ApplicableType;
@ -353,21 +400,26 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
.MaxAsync(w => (int?)w.Version, ct) ?? 0;
nextVersion++;
// Tắt bản đang active — PHẠM VI phụ thuộc loại (bản trước: LUÔN per-type).
// Tắt bản đang active — PHẠM VI phụ thuộc loại (bản gốc Mig 22: LUÔN per-type).
//
// • type-10 `ContractSigningPlan` (KHKK): **per-CODE**. Loại này có 8 quy trình
// SỐNG SONG SONG cùng type mỗi nhóm duyệt N1..N8 một `Code` riêng
// (`DbInitializer.SeedKhkkGroupWorkflowsAsync:609`, seed cả 8 với IsActive=1).
// Tắt theo type ⇒ tạo version mới cho MỘT nhóm sẽ hạ IsActive của CẢ 7 nhóm còn
// lại (mỗi nhóm mất quy trình active ⇒ FE hết auto-pin ⇒ không tạo được phiếu).
// • các loại khác: **per-TYPE** như cũ — 1 active/type là bất biến của chúng.
// • Loại nằm trong `PerCodeDeactivationTypes` (khai báo + tiêu chí ở ĐẦU class này):
// **per-CODE**. Chúng có NHIỀU quy trình SỐNG SONG SONG trong cùng một type, mỗi
// cái một `Code`. Tắt theo type ⇒ tạo version mới cho MỘT cái sẽ hạ IsActive của
// TẤT CẢ những cái còn lại (mỗi cái mất quy trình active ⇒ FE hết auto-pin ⇒
// không lập được phiếu), im lặng và KHÔNG có endpoint bật lại.
// • các loại còn lại: **per-TYPE** như cũ — với CHÚNG, 1 active/type vẫn là bất biến.
// 🔴 Đây là câu tự-khai từng SAI: bản trước viết "các loại khác: per-TYPE như cũ —
// 1 active/type là bất biến của chúng" khi tập per-Code chỉ có type-10, rồi type-3
// mọc thêm 7 quy trình `HD-*` sống song song ⇒ câu đó bao luôn type-3 và thành lời
// khai sai. Nay câu này neo vào TẬP TYPE tường minh, không neo vào con số.
//
// 🔴 [S182 R-6] Vị ngữ type-10 KHOÁ đúng 3 vế `Code == request.Code &&
// ApplicableType == typeEnum && IsActive`. CẤM thay bằng biến-thể regex
// `^KHKK-N[1-8]$`: `Code` type-10 nằm ngoài họ đó (vd `QT-KHKK-V2-001`) sẽ rơi
// lại nhánh per-type và nuke đúng 8 nhóm mà rào này sinh ra để cứu.
// (`nextVersion` `:334-337` vốn đã tính per-Code ⇒ nhánh này làm 2 vế khớp nhau.)
var actives = typeEnum == ApprovalWorkflowApplicableType.ContractSigningPlan
// 🔴 [S182 R-6 · giữ nguyên hiệu lực @S187] Vị ngữ per-Code KHOÁ đúng 3 vế `Code == code
// && ApplicableType == typeEnum && IsActive`. CẤM thay bằng biến-thể regex trên `Code`
// (`^KHKK-N[1-8]$`, `^HD-...$`): mã nằm ngoài họ (vd `QT-KHKK-V2-001`, `QT-HD-V2-001`)
// sẽ rơi lại nhánh per-type và nuke đúng tập mà rào này sinh ra để cứu. Phân loại theo
// ApplicableType, KHÔNG theo hình dạng mã. (`nextVersion` phía trên vốn đã tính
// per-Code toàn cục ⇒ nhánh này làm 2 vế khớp nhau.)
var actives = PerCodeDeactivationTypes.Contains(typeEnum)
? await db.ApprovalWorkflows
.Where(w => w.Code == code && w.ApplicableType == typeEnum && w.IsActive)
.ToListAsync(ct)

View File

@ -1530,4 +1530,299 @@ public class UpdateAwDefinitionTests
"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");
}
}
// ###################################################################
// [S187 W1 — YC-023, 2026-08-10] Cùng HAI quả bom, nay cho `ApplicableType = Contract` (3).
// SPEC: `.claude/workflows/runs/2026-08-10-S187-duyet-hd-tu-khkk/spec-duyet-hd-10-08-2026.md`
// MỤC 2 W1 (+ `sub-plan-review-fable.md` §6 FLAG-5, §7 hàng W1).
//
// 4 lưới dưới đây viết TRƯỚC khi BE land ⇒ ca nào ĐỎ hôm nay là ĐỎ HỢP LỆ; hình dạng
// "đỏ vì chưa làm" ghi trong `sub-test-specialist-0.md §4`.
//
// 🔴 VÌ SAO type-3 lặp lại đúng vết type-10: W3 sắp seed 7 quy trình `HD-*` SỐNG SONG SONG
// cùng `ApplicableType = 3` (mỗi loại HĐ một Code). Kể từ lúc đó, câu tự khai ở
// `ApprovalWorkflowV2AdminFeatures.cs:363` — "các loại khác: per-TYPE như cũ, 1 active/type
// là bất biến của chúng" — thành SAI với type-3, và:
// • bom-1 `:370-377`: ternary chỉ mở nhánh per-Code cho type-10 ⇒ một cú "Tạo phiên bản
// mới" trên panel loại HĐ X tắt IsActive của 6 loại còn lại, im lặng, không endpoint
// bật lại (đúng hình vụ nổ mà T1 đã tả cho KHKK).
// • bom-2 `:205-215`: `versions.FirstOrDefault(v => v.IsActive)` mỗi type ⇒ 7 active
// cùng type-3 chỉ hiện 1. "Giấu 6" là THẬT chứ không phải thẩm mỹ: FE render thẻ đang
// áp dụng từ ô `active` (`ApprovalWorkflowsV2Page.tsx:381-389`) còn danh sách lịch sử
// thì lọc `history.filter(d => !d.isActive)` (`:485-492`) ⇒ 6 bản active còn lại
// KHÔNG xuất hiện ở đâu trên màn hình.
//
// ⚠️ R-6 (`:365-368`) CẤM biến-thể regex `^HD-...$`: mã type-3 ngoài họ vẫn phải per-Code —
// đó là chân (a) của ca thứ hai.
// 🔸 VÙNG KHÔNG PHỦ ĐƯỢC (khai, không giả vờ phủ): vế `ApplicableType == typeEnum` trong nhánh
// per-Code không dựng được ca phân biệt — cần 2 workflow CÙNG Code KHÁC type, mà
// `ApprovalWorkflowConfiguration.cs:23` `HasIndex(Code, Version).IsUnique()` là UNIQUE TOÀN
// CỤC (không kèm type) và `nextVersion` `:351-353` cũng tính per-Code toàn cục.
// 🔸 KHÔNG viết lại ca "type-1 tạo mã mới vẫn tắt mã cũ cùng type": T2 (`:1152`) đã phủ y hệt
// — nó là rào chống bản vá "nới per-Code cho MỌI type", nhân bản cho type-3 không thêm sức
// phân biệt nào.
// ###################################################################
private const ApprovalWorkflowApplicableType Hd = ApprovalWorkflowApplicableType.Contract;
/// 7 loại HĐ = 7 Code type-3 sống song song, v01 IsActive — hình dạng prod SAU seeder W3.
/// 🔴 Chuỗi mã ở đây chỉ là DỮ LIỆU của ca đo (W3 mới chốt mã thật, FLAG-6 ASCII UPPER):
/// thứ đang đo là "phạm vi tắt = theo CODE trong type-3", không phải một chuỗi cụ thể.
/// Viết HOA sẵn vì handler chuẩn hoá `ToUpperInvariant` (`:348`) còn SQLite so `=` kiểu
/// BINARY (ngược prod `*_CI_AS`) — seed thường sẽ đo nhầm cơ chế.
private static readonly string[] ContractFamilyCodes =
["HD-TP", "HD-GK", "HD-NCC", "HD-DV", "HD-MB", "HD-NT-NCC", "HD-NT-DV"];
private static async Task SeedSevenContractWorkflowsAsync(TestApplicationDbContext db, Guid approverId)
{
foreach (var code in ContractFamilyCodes)
await SeedWorkflowAsync(db, Hd, [[(1, approverId)]], code: code);
}
/// 🔴 ĐƠN VỊ CỦA SỰ CỐ THẬT (mirror `KhkkGroupsWithActiveVersionAsync`): "bao nhiêu LOẠI HĐ
/// còn quy trình để pin". Loại mất bản active là loại không lập được phiếu duyệt nữa.
private static async Task<int> ContractCodesWithActiveVersionAsync(TestApplicationDbContext db)
{
var rows = await ReadWorkflowsAsync(db, Hd);
return rows.Where(r => r.IsActive).Select(r => r.Code).Distinct().Count();
}
/// Handler overview hỏi `UserManager` 3 nhóm quyền (`ApprovalWorkflowV2AdminFeatures.cs:166-168`)
/// để bật cờ hiển thị CanEditPro/CanEditCcmBudget. Role chưa có trong store là chuyện HẠ TẦNG
/// test chứ không phải hành vi đang đo ⇒ tạo sẵn cho khỏi lẫn nguyên nhân ĐỎ.
private static async Task EnsureBudgetEditorRolesAsync(Harness h)
{
var rm = h.Fix.Services.GetRequiredService<RoleManager<Role>>();
foreach (var role in new[] { AppRoles.Admin, AppRoles.Procurement, AppRoles.CostControl })
{
if (!await rm.RoleExistsAsync(role))
await rm.CreateAsync(new Role { Id = Guid.NewGuid(), Name = role });
}
}
/// 🔴 "KÊNH ĐƯƠNG NHIỆM" của một mục overview = MỌI ngả của `AwTypeSummaryDto` trỏ tới
/// `AwDefinitionDto` (đơn hoặc tập hợp), **TRỪ `History`**.
///
/// Trừ `History` vì nó là KHO PHIÊN BẢN — chứa cả bản đã tắt — và FE render nó bằng
/// `history.filter(d => !d.isActive)` (`ApprovalWorkflowsV2Page.tsx:485-492`) ⇒ một quy trình
/// ĐANG ACTIVE mà chỉ nằm trong `History` thì không lên màn hình ở đâu cả. Nói cách khác:
/// có mặt trong `History` KHÔNG phải là "được trả về như bản đang áp dụng".
///
/// Đọc bằng reflection để ca đo KHÔNG ép hình dạng bản vá: nó XANH với cả (a) thêm một trường
/// tập hợp mới cạnh `Active`, lẫn (b) tách mỗi Code thành một mục riêng cùng `applicableType`.
/// Hôm nay reflection chỉ thấy đúng ô `Active` đơn ⇒ ĐỎ vì HÀNH VI, không phải vì biên dịch.
private static IEnumerable<AwDefinitionDto> SurfacedAsCurrent(AwTypeSummaryDto summary)
{
foreach (var prop in typeof(AwTypeSummaryDto).GetProperties())
{
if (prop.Name == nameof(AwTypeSummaryDto.History)) continue;
switch (prop.GetValue(summary))
{
case AwDefinitionDto one:
yield return one;
break;
case IEnumerable<AwDefinitionDto> many:
foreach (var def in many) yield return def;
break;
}
}
}
private static List<AwDefinitionDto> ActiveChannel(
AwAdminOverviewDto overview, ApprovalWorkflowApplicableType type)
=> overview.Types.Where(t => t.ApplicableType == (int)type)
.SelectMany(SurfacedAsCurrent).ToList();
private static List<AwDefinitionDto> HistoryChannel(
AwAdminOverviewDto overview, ApprovalWorkflowApplicableType type)
=> overview.Types.Where(t => t.ApplicableType == (int)type)
.SelectMany(t => t.History).ToList();
// ===================================================================
// (W1a-1) 🔴 BOM ĐANG ARMED cho type-3 — tạo v02 cho MỘT loại HĐ không được tắt 6 loại kia.
//
// Đo bằng đơn vị của sự cố: số LOẠI HĐ còn quy trình active. Hôm nay `:374-376` tắt theo TYPE
// ⇒ sau một cú bấm "Tạo phiên bản mới" trên panel HĐ Mua bán, con số đó tụt 7 → 1.
// ===================================================================
[Fact]
public async Task W1a_CreateNewVersionForOneContractCode_DeactivatesOnlyThatCode_SixOtherContractTypesStayActive()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
await SeedSevenContractWorkflowsAsync(h.Db, alice.Id);
// Tiền đề đo: 7 loại, TẤT CẢ đang có bản active. Thiếu chốt này thì một seed hỏng
// (vd 1 loại đã tắt sẵn) vẫn cho assert phía dưới xanh ở thế giới sai.
(await ContractCodesWithActiveVersionAsync(h.Db)).Should().Be(7,
"tiền đề: seeder W3 để cả 7 loại HĐ có quy trình active");
await new CreateAwDefinitionCommandHandler(h.Db).Handle(
NewVersionCommand(Hd, "HD-MB", alice.Id), CancellationToken.None);
var rows = await ReadWorkflowsAsync(h.Db, Hd);
rows.Should().HaveCount(8, "POST đẻ ĐÚNG 1 dòng mới (v02 của HD-MB) — không xoá/không thêm dòng nào khác");
rows.Single(r => r is { Code: "HD-MB", Version: 1 }).IsActive.Should().BeFalse(
"cùng Code thì bản cũ PHẢI nhường chỗ — vế 'ratchet một chiều' của chính loại Mua bán");
rows.Single(r => r is { Code: "HD-MB", Version: 2 }).IsActive.Should().BeTrue(
"version vừa tạo là bản đang dùng của loại Mua bán");
rows.Where(r => r.Code != "HD-MB").Should().OnlyContain(r => r.IsActive,
"🔴 tập bị tắt phải lọc theo CODE trong type-3, không theo TYPE. Lọc theo type thì một cú "
+ "bấm trên panel Mua bán tắt luôn 6 loại HĐ còn lại — và KHÔNG có endpoint bật lại");
(await ContractCodesWithActiveVersionAsync(h.Db)).Should().Be(7,
"số LOẠI HĐ còn quy trình để pin phải BẤT BIẾN qua thao tác của một loại khác; tụt xuống 1 "
+ "là đúng hình vụ nổ đã xảy ra với 8 nhóm KHKK (T1)");
}
}
// ===================================================================
// (W1a-2) R-6 — mã type-3 NGOÀI họ `HD-*` (vd quy trình HĐ chung/legacy). Hai chân, mỗi chân
// giết một bản vá sai:
// (a) tạo `QT-HD-V2-001` ⇒ 7 loại BẤT BIẾN → giết bản vá regex `^HD-...$` (mã ngoài họ rơi
// lại nhánh per-type và NUKE đúng 7 loại mà rào này sinh ra để cứu — 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-3 thì đừng tắt gì cả"
// (chân (a) một mình sẽ XANH với bản vá đó).
// ===================================================================
[Fact]
public async Task W1a_CreateType3_WithOutOfFamilyCode_LeavesSevenContractCodesActive_AndStillRatchetsItsOwnCode()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
await SeedSevenContractWorkflowsAsync(h.Db, alice.Id);
var handler = new CreateAwDefinitionCommandHandler(h.Db);
// ---- (a) mã type-3 chưa từng có ⇒ KHÔNG có bản cũ nào để tắt.
await handler.Handle(NewVersionCommand(Hd, "QT-HD-V2-001", alice.Id), CancellationToken.None);
(await ContractCodesWithActiveVersionAsync(h.Db)).Should().Be(8,
"🔴 R-6: quy trình type-3 mã ngoài họ (chung/legacy) tạo mới KHÔNG được đụng 7 loại HĐ. "
+ "Công thức regex cho mã này rơi về nhánh per-type ⇒ NUKE cả 7");
var afterA = await ReadWorkflowsAsync(h.Db, Hd);
afterA.Single(r => r.Code == "QT-HD-V2-001").IsActive.Should().BeTrue();
afterA.Where(r => ContractFamilyCodes.Contains(r.Code)).Should().OnlyContain(r => r.IsActive,
"7 loại + 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(Hd, "QT-HD-V2-001", alice.Id), CancellationToken.None);
var afterB = await ReadWorkflowsAsync(h.Db, Hd);
afterB.Single(r => r is { Code: "QT-HD-V2-001", Version: 1 }).IsActive.Should().BeFalse(
"per-Code áp cho MỌI mã type-3, kể cả ngoài họ — 'type-3 thì không tắt gì' là bản vá làm "
+ "2 version cùng mã cùng active, phía pin quy trình bốc ngẫu nhiên");
afterB.Single(r => r is { Code: "QT-HD-V2-001", Version: 2 }).IsActive.Should().BeTrue();
(await ContractCodesWithActiveVersionAsync(h.Db)).Should().Be(8, "8 mã vẫn bất biến sau lượt 2");
}
}
// ===================================================================
// (W1b-1) 🔴 BOM-2 — overview type-3 phải trả ĐỦ mọi quy trình đang áp dụng của loại đó.
// Hôm nay `:205-215` chọn `FirstOrDefault(v => v.IsActive)` ⇒ trả về 1, 6 cái còn lại chỉ
// nằm trong `History` (nơi FE lọc `!isActive` để dựng danh sách lịch sử ⇒ biến mất khỏi UI).
//
// Đo CẢ HAI nhánh `typesToReturn` (`:201-203`): có lọc `applicableType` (đường deep-link
// Designer, `ApprovalWorkflowsV2Page.tsx:311`) và không lọc (lưới landing `:378`) — bản vá
// chỉ đụng một nhánh sẽ ĐỎ ở nhánh kia.
// ===================================================================
[Fact]
public async Task W1b_OverviewForContractType_SurfacesEveryActiveWorkflowOfThatType_NotJustTheFirst()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
await EnsureBudgetEditorRolesAsync(h);
await SeedSevenContractWorkflowsAsync(h.Db, alice.Id);
// HD-MB đã qua một lần "Tạo phiên bản mới": v01 tắt, v02 đang áp dụng. Có cặp này thì
// ca đo phân biệt được "trả về BẢN ĐANG ÁP DỤNG" với "trả về bừa một bản của Code đó".
await SeedWorkflowAsync(h.Db, Hd, [[(1, alice.Id)]], code: "HD-MB", version: 2);
var mbV1 = await h.Db.ApprovalWorkflows
.FirstAsync(w => w.Code == "HD-MB" && w.Version == 1, CancellationToken.None);
mbV1.IsActive = false;
await h.Db.SaveChangesAsync(CancellationToken.None);
(await ContractCodesWithActiveVersionAsync(h.Db)).Should().Be(7,
"tiền đề: đúng 7 loại HĐ đang có bản áp dụng (HD-MB đang ở v02)");
var handler = new GetAwAdminOverviewQueryHandler(h.Db, h.Um);
// ---- Nhánh 1: lọc theo type (đường Designer per-type mở từ deep-link).
var filtered = await handler.Handle(
new GetAwAdminOverviewQuery(ApplicableType: (int)Hd), CancellationToken.None);
var filteredChannel = ActiveChannel(filtered, Hd);
filteredChannel.Select(d => d.Code).Should().BeEquivalentTo(ContractFamilyCodes,
"🔴 overview phải trả ĐỦ mọi quy trình đang áp dụng của type-3. Trả 1 thì 6 loại HĐ còn "
+ "lại không có thẻ 'đang áp dụng' nào, mà danh sách lịch sử thì lọc `!isActive` ⇒ chúng "
+ "biến mất khỏi màn hình Designer, im lặng");
filteredChannel.Should().OnlyContain(d => d.IsActive,
"kênh đương nhiệm chỉ được chứa bản ĐANG áp dụng — nhét cả bản đã tắt vào là đổi nghĩa ô này");
filteredChannel.Single(d => d.Code == "HD-MB").Version.Should().Be(2,
"với loại đã có 2 version, phải trả bản v02 đang áp dụng chứ không phải bản đầu tiên gặp");
HistoryChannel(filtered, Hd).Select(d => (d.Code, d.Version)).Distinct().Should().HaveCount(8,
"kho phiên bản KHÔNG được teo lại: 7 loại + 1 bản HD-MB v01 đã tắt — màn 'phiên bản cũ' "
+ "sống bằng đúng danh sách này");
// ---- Nhánh 2: không lọc (lưới landing map qua mọi ApplicableType).
var all = await handler.Handle(new GetAwAdminOverviewQuery(), CancellationToken.None);
ActiveChannel(all, Hd).Select(d => d.Code).Should().BeEquivalentTo(ContractFamilyCodes,
"hai nhánh `typesToReturn` (:201-203) đi hai đường khác nhau — vá một nhánh là để lọt nhánh kia");
}
}
// ===================================================================
// (W1b-2) HỒI QUY cho các loại 1-active-per-type: bản vá W1b chỉ được đổi hành vi ở chỗ có
// NHIỀU bản cùng áp dụng. Type-1 (Duyệt NCC) vẫn phải chỉ ra ĐÚNG MỘT bản đang áp dụng.
//
// Ca này XANH hôm nay và phải XANH sau bản vá — nó giết bản vá lười "đổ nguyên `History` vào
// kênh đương nhiệm" (type-1 sẽ hiện cả v01 đã tắt) và bản vá "gộp mọi type vào một rổ".
// ===================================================================
[Fact]
public async Task W1b_OverviewForSingleActiveType_KeepsExactlyOneCurrentDesignation_AndDoesNotLeakOtherTypes()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
await EnsureBudgetEditorRolesAsync(h);
await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id)]], code: "QT-DN-V2-001", version: 1);
await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id)]], code: "QT-DN-V2-001", version: 2);
var dnV1 = await h.Db.ApprovalWorkflows
.FirstAsync(w => w.Code == "QT-DN-V2-001" && w.Version == 1, CancellationToken.None);
dnV1.IsActive = false;
await h.Db.SaveChangesAsync(CancellationToken.None);
// Chứng nhân rò rỉ: 2 type khác cùng tồn tại trong DB, mỗi type có bản active riêng.
await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNccPhuongAn,
[[(1, alice.Id)]], code: "QT-DNPA-V2-001");
await SeedSevenContractWorkflowsAsync(h.Db, alice.Id);
var all = await new GetAwAdminOverviewQueryHandler(h.Db, h.Um)
.Handle(new GetAwAdminOverviewQuery(), CancellationToken.None);
var channel = ActiveChannel(all, ApprovalWorkflowApplicableType.DuyetNcc);
channel.Should().ContainSingle(
"type-1 GIỮ ngữ nghĩa 1-active-per-type — nới kênh đương nhiệm cho MỌI type sẽ hiện "
+ "cả bản đã tắt, và trang Duyệt NCC có 2 thẻ 'đang áp dụng'");
channel.Single().Code.Should().Be("QT-DN-V2-001");
channel.Single().Version.Should().Be(2, "bản đang áp dụng là v02, không phải v01 đã tắt");
channel.Should().OnlyContain(d => d.ApplicableType == (int)ApprovalWorkflowApplicableType.DuyetNcc,
"mục của type-1 không được lẫn quy trình của type khác");
HistoryChannel(all, ApprovalWorkflowApplicableType.DuyetNcc)
.Select(d => d.Version).Should().BeEquivalentTo(new[] { 1, 2 },
"kho phiên bản của type-1 vẫn đủ 2 bản");
}
}
}