From 7bad4ae1862c23024ef996e5532381792be70779 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Mon, 10 Aug 2026 18:20:40 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20Workflow:=20W1=20g=E1=BB=A1=202=20bo?= =?UTF-8?q?m=20per-type=20type-3=20=E2=80=94=20deactivate=20per-Code=20(R-?= =?UTF-8?q?6=20t=E1=BA=ADp=20t=C6=B0=E1=BB=9Dng=20minh)=20+=20overview=20f?= =?UTF-8?q?amily;=20test-before=20RED=E2=86=92GREEN=20+4=20test=20(672/672?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../ApprovalWorkflowV2AdminFeatures.cs | 80 ++++- .../Application/UpdateAwDefinitionTests.cs | 295 ++++++++++++++++++ 2 files changed, 361 insertions(+), 14 deletions(-) diff --git a/src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs b/src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs index f53fd6a..79303d3 100644 --- a/src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs +++ b/src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs @@ -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 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 History); public record AwAdminOverviewDto(List 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 { + // 🔴 [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 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) diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/UpdateAwDefinitionTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/UpdateAwDefinitionTests.cs index 7d0a7a3..e180764 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Application/UpdateAwDefinitionTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Application/UpdateAwDefinitionTests.cs @@ -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 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>(); + 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 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 many: + foreach (var def in many) yield return def; + break; + } + } + } + + private static List ActiveChannel( + AwAdminOverviewDto overview, ApprovalWorkflowApplicableType type) + => overview.Types.Where(t => t.ApplicableType == (int)type) + .SelectMany(SurfacedAsCurrent).ToList(); + + private static List 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"); + } + } }