using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
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) + LeaveRequest (T12)
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;
using SolutionErp.Infrastructure.Tests.Services; // NoOpNotificationService

namespace SolutionErp.Infrastructure.Tests.Application;

// ============================================================================
// S155 hạng mục F (2026-07-27) — lệnh UPDATE quy trình duyệt TẠI CHỖ.
// Test-before-merge: đụng CẢ security (tự thêm mình vào Cấp = duyệt phiếu người
// khác) LẪN financial (phiếu đang chạy đổi cấu hình giữa chừng) ⇒ hạng critical
// theo docs/rules.md §7.
//
// Đối tượng đo: `UpdateAwDefinitionCommandHandler`
//   (src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs:501-930)
//
// 🔴 MỆNH ĐỀ TRUNG TÂM (spec §F.0) mà F-12/F-15 tồn tại để chứng minh:
//   POST tạo version mới sinh **Id MỚI**, phiếu đang treo pin **Id CŨ** và đọc
//   workflow **SỐNG** (`PurchaseEvaluationWorkflowService.cs:671-675` — chỉ
//   `w.Id == awId`, không snapshot) ⇒ cấu hình tick hôm nay KHÔNG BAO GIỜ tới
//   được phiếu cần cứu. Chỉ sửa-TẠI-CHỖ (giữ nguyên Id) mới tới.
//   ⇒ mọi assert "phiếu thấy thay đổi" ở đây đều đi qua `ResolveLikeRunningPeAsync`
//     — bản sao Y HỆT đường resolve của service, không phải một query tiện tay.
//
// Ranh giới ĐỢT: file này KHÔNG test hành vi XOÁ phiếu khi đang duyệt (nút +
// endpoint xoá-khi-duyệt = ĐỢT 2, chưa tồn tại trên đĩa). F-15 chỉ đo tới mức
// "cờ F6 đọc được từ phía phiếu đang treo".
//
// Harness: IdentityFixture (BẮT BUỘC — `ApprovalWorkflowLevel.ApproverUserId` có
// FK Restrict sang User, `ApprovalWorkflowConfiguration.cs:70-74`, nên approver
// phải là user THẬT chứ không phải Guid ngẫu nhiên).
// ============================================================================
public class UpdateAwDefinitionTests
{
    private sealed record Harness(
        IdentityFixture Fix,
        TestApplicationDbContext Db,
        UserManager<User> Um,
        FixedDateTime Clock);

    private static Harness NewHarness()
    {
        var fix = new IdentityFixture();
        return new Harness(
            fix,
            fix.Services.GetRequiredService<TestApplicationDbContext>(),
            fix.Services.GetRequiredService<UserManager<User>>(),
            new FixedDateTime(new DateTime(2026, 7, 27, 3, 0, 0, DateTimeKind.Utc)));
    }

    private static UpdateAwDefinitionCommandHandler NewHandler(Harness h, User actor)
        => new(h.Db, new TestCurrentUser(actor.Id, actor.FullName, actor.Email), h.Clock, h.Um);

    // stepSpecs[s] = các dòng Cấp của Bước (Order s+1). Mỗi phần tử = (LevelOrder, ApproverUserId).
    // N dòng cùng LevelOrder = 1 Cấp có N người (OR-of-N) — đúng hình dạng prod.
    private static async Task<ApprovalWorkflow> SeedWorkflowAsync(
        TestApplicationDbContext db,
        ApprovalWorkflowApplicableType type,
        (int LevelOrder, Guid User)[][] stepSpecs,
        decimal? ceoThreshold = null,
        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 = version,
            ApplicableType = type,
            Name = "QT test sửa tại chỗ",
            IsActive = true,
            IsUserSelectable = true,
            CeoApprovalThreshold = ceoThreshold,
            ActivatedAt = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc),
        };
        for (int s = 0; s < stepSpecs.Length; s++)
        {
            var step = new ApprovalWorkflowStep
            {
                ApprovalWorkflowId = wf.Id,
                Order = s + 1,
                Name = $"Bước {s + 1}",
            };
            foreach (var (levelOrder, user) in stepSpecs[s])
            {
                step.Levels.Add(new ApprovalWorkflowLevel
                {
                    ApprovalWorkflowStepId = step.Id,
                    Order = levelOrder,
                    Name = $"Cấp {levelOrder}",
                    ApproverUserId = user,
                });
            }
            wf.Steps.Add(step);
        }
        db.ApprovalWorkflows.Add(wf);
        await db.SaveChangesAsync(CancellationToken.None);
        return wf;
    }

    private static async Task<PurchaseEvaluation> SeedPeAsync(
        TestApplicationDbContext db,
        Guid workflowId,
        Guid drafterId,
        string code,
        int stepIdx = 0,
        int levelOrder = 1,
        PurchaseEvaluationPhase phase = PurchaseEvaluationPhase.ChoDuyet,
        bool isDeleted = false)
    {
        var pe = new PurchaseEvaluation
        {
            Id = Guid.NewGuid(),
            Type = PurchaseEvaluationType.DuyetNcc,
            Phase = phase,
            MaPhieu = code,
            TenGoiThau = "Gói thầu test sửa quy trình tại chỗ",
            ProjectId = Guid.NewGuid(),
            DrafterUserId = drafterId,
            ApprovalWorkflowId = workflowId,
            CurrentWorkflowStepIndex = phase == PurchaseEvaluationPhase.ChoDuyet ? stepIdx : null,
            CurrentApprovalLevelOrder = phase == PurchaseEvaluationPhase.ChoDuyet ? levelOrder : null,
            IsDeleted = isDeleted,
            DeletedAt = isDeleted ? new DateTime(2026, 7, 26, 0, 0, 0, DateTimeKind.Utc) : null,
        };
        db.PurchaseEvaluations.Add(pe);
        await db.SaveChangesAsync(CancellationToken.None);
        return pe;
    }

    // 🔴 BẢN SAO Y HỆT đường resolve của phiếu đang chạy
    // (`PurchaseEvaluationWorkflowService.cs:671-675`): chỉ lọc theo Id đã pin,
    // Include Steps/Levels đã sắp xếp, AsNoTracking. Dùng nó (chứ không phải một
    // query tiện tay) để assert "phiếu THẤY thay đổi" mới là bằng chứng thật.
    private static Task<ApprovalWorkflow> ResolveLikeRunningPeAsync(TestApplicationDbContext db, Guid pinnedId)
        => db.ApprovalWorkflows.AsNoTracking()
            .Include(w => w.Steps.OrderBy(s => s.Order))
                .ThenInclude(s => s.Levels.OrderBy(l => l.Order))
            .FirstAsync(w => w.Id == pinnedId, CancellationToken.None);

    // Lệnh Update "trung thực": echo NGUYÊN TRẠNG mọi thứ đang lưu. Mọi test chỉ
    // đổi ĐÚNG MỘT biến trên nền này (kỹ thuật cặp cùng-ruột-khác-1-biến) — nếu
    // test đỏ thì biết chắc do biến đó chứ không do payload lệch chỗ khác.
    private static UpdateAwDefinitionCommand BuildEchoCommand(ApprovalWorkflow wf, string? reason = null)
        => new(
            Id: wf.Id,
            Name: wf.Name,
            Description: wf.Description,
            Steps: wf.Steps.OrderBy(s => s.Order).Select(s => new UpdateAwStepInput(
                Id: s.Id,
                Order: s.Order,
                Name: s.Name,
                DepartmentId: s.DepartmentId,
                Levels: s.Levels.OrderBy(l => l.Order).Select(EchoLevel).ToList())).ToList(),
            Reason: reason,
            Code: null,
            ApplicableType: null,
            CeoApprovalThreshold: wf.CeoApprovalThreshold);

    private static UpdateAwLevelInput EchoLevel(ApprovalWorkflowLevel l) => new(
        Id: l.Id,
        Order: l.Order,
        Name: l.Name,
        ApproverUserId: l.ApproverUserId,
        AllowReturnOneLevel: l.AllowReturnOneLevel,
        AllowReturnOneStep: l.AllowReturnOneStep,
        AllowReturnToAssignee: l.AllowReturnToAssignee,
        AllowReturnToDrafter: l.AllowReturnToDrafter,
        AllowApproverEditDetails: l.AllowApproverEditDetails,
        AllowApproverEditBudget: l.AllowApproverEditBudget,
        AllowApproverDelete: l.AllowApproverDelete,
        AllowApproverSkipToFinal: null,     // ECHO-only, null = không khai báo
        AllowApproverFinalize: null);

    private static UpdateAwLevelInput NewLevel(int order, Guid approverUserId, bool allowDelete = false)
        => new(Id: null, Order: order, Name: $"Cấp {order}", ApproverUserId: approverUserId,
               AllowApproverDelete: allowDelete);

    // Thay danh sách Cấp của Bước thứ `stepIdx` trong lệnh (giữ nguyên phần còn lại).
    private static UpdateAwDefinitionCommand WithLevels(
        UpdateAwDefinitionCommand cmd, int stepIdx, List<UpdateAwLevelInput> levels)
    {
        var steps = cmd.Steps.ToList();
        steps[stepIdx] = steps[stepIdx] with { Levels = levels };
        return cmd with { Steps = steps };
    }

    private static PurchaseEvaluationWorkflowService NewPeService(Harness h)
        => new(h.Db, h.Clock, new NoOpNotificationService(), h.Um);

    private static Task ApproveAsync(PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorId)
        => svc.TransitionAsync(
            evaluation: pe,
            targetPhase: PurchaseEvaluationPhase.ChoDuyet,     // approve-in-place
            actorUserId: actorId,
            actorRoles: Array.Empty<string>(),                 // KHÔNG Admin — phải qua guard thật
            decision: ApprovalDecision.Approve,
            comment: null,
            ct: CancellationToken.None);

    // =====================================================================
    // F-12 ⭐ BÀI KIỂM CHỨNG TRUNG TÂM (spec §F.0) — sửa hạng AN TOÀN trên
    // workflow ĐANG CÓ PHIẾU CHẠY ⇒ phiếu nhận thay đổi NGAY.
    //
    // Đo bằng HÀNH VI THẬT chứ không bằng một cột trong bảng: cùng một phiếu,
    // cùng một lời gọi duyệt của cùng một người — chỉ khác nhau ở chỗ lệnh Update
    // đã chạy hay chưa. Trước: Forbidden. Sau: duyệt được + con trỏ tiến.
    // (Nếu chỉ assert "DB có row mới" thì test vẫn xanh kể cả khi phiếu đọc
    //  snapshot cũ — tức mù đúng chỗ spec sợ nhất.)
    // Kèm luôn F-4 (Id/Version/IsActive bất biến) và F-6 (403 in TÊN, không GUID).
    // =====================================================================
    [Fact]
    public async Task F12_SafeUpdate_AddsPersonToExistingLevel_RunningPeAcceptsNewApproverImmediately()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "Trần Thị An", null, []);
            var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Lê Văn Bình", null, []);
            var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Phạm Thị Cúc", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);

            // Bước 1: Cấp 1 = alice · Cấp 2 = carol. Phiếu treo tại (Bước 1, Cấp 1).
            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id), (2, carol.Id)]]);
            var pe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F12-001");
            var svc = NewPeService(h);

            // --- TRƯỚC: bob KHÔNG duyệt được (nửa contrast — chống vacuous pass) ---
            var beforeAct = async () => await ApproveAsync(svc, pe, bob.Id);
            var forbidden = (await beforeAct.Should().ThrowAsync<ForbiddenException>(
                "bob chưa nằm trong Cấp 1 nên phải bị chặn TRƯỚC khi sửa quy trình")).Which;
            // F-6 (lead tự vá, chưa ai soi): message phải in TÊN người duyệt, không phải GUID trần.
            forbidden.Message.Should().Contain("Trần Thị An",
                "F-6: thông điệp 403 phải resolve GUID sang tên thật để người dùng biết nhờ ai duyệt");
            forbidden.Message.Should().NotContain(alice.Id.ToString(),
                "F-6: không được rơi lại về in GUID trần khi tra được tên");

            // --- ACT: thêm bob vào CẤP CÓ SẴN (row trùng Order 1) ---
            var step = wf.Steps.Single();
            var lvlAlice = step.Levels.Single(l => l.ApproverUserId == alice.Id);
            var lvlCarol = step.Levels.Single(l => l.ApproverUserId == carol.Id);
            var cmd = WithLevels(BuildEchoCommand(wf), 0,
                [EchoLevel(lvlAlice), NewLevel(1, bob.Id), EchoLevel(lvlCarol)]);

            await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            // --- F-4: giữ Id / Version / IsActive — giữ Id là TOÀN BỘ mục đích ---
            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.Id.Should().Be(wf.Id);
            live.Version.Should().Be(1, "sửa tại chỗ KHÔNG được tăng Version");
            live.IsActive.Should().BeTrue("sửa tại chỗ KHÔNG được đụng IsActive");
            live.Steps.Should().HaveCount(1);
            live.Steps[0].Levels.Where(l => l.Order == 1).Select(l => l.ApproverUserId)
                .Should().BeEquivalentTo(new[] { alice.Id, bob.Id });

            var peRow = await h.Db.PurchaseEvaluations.AsNoTracking().FirstAsync(p => p.Id == pe.Id);
            peRow.ApprovalWorkflowId.Should().Be(wf.Id, "phiếu vẫn pin ĐÚNG Id cũ — không có đường re-pin");

            // --- SAU: cùng phiếu, cùng người, cùng lời gọi ⇒ nay duyệt ĐƯỢC ---
            await ApproveAsync(svc, pe, bob.Id);

            var peAfter = await h.Db.PurchaseEvaluations.AsNoTracking().FirstAsync(p => p.Id == pe.Id);
            peAfter.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
            peAfter.CurrentApprovalLevelOrder.Should().Be(2,
                "bob duyệt xong Cấp 1 ⇒ con trỏ tiến sang Cấp 2 — phiếu chạy trên cấu hình MỚI");

            // Chữ ký gắn vào ĐÚNG dòng Cấp mới của bob (không dạt sang row alice).
            var newLevelId = live.Steps[0].Levels.Single(l => l.ApproverUserId == bob.Id).Id;
            var opinion = await h.Db.PurchaseEvaluationLevelOpinions.AsNoTracking()
                .SingleAsync(o => o.PurchaseEvaluationId == pe.Id);
            opinion.ApprovalWorkflowLevelId.Should().Be(newLevelId);
            opinion.SignedByUserId.Should().Be(bob.Id);
        }
    }

    // =====================================================================
    // F-13 — đụng tập Order ⇒ 409 VÀ workflow trong DB KHÔNG đổi.
    // Cả 2 vế: chỉ assert "có ném" là mù trước ca handler ném SAU khi đã ghi.
    // =====================================================================
    [Fact]
    public async Task F13_AddNewLevelOrder_Rejected409_AndWorkflowUnchanged()
    {
        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", "Bình", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id)]]);
            var lvlAlice = wf.Steps.Single().Levels.Single();

            // Thêm Cấp 2 (Order chưa từng có) = chèn chặng cho phiếu đang chạy.
            var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice), NewLevel(2, bob.Id)]);
            var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            await act.Should().ThrowAsync<ConflictException>().WithMessage("*không được thêm Cấp mới*");

            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.Steps.Single().Levels.Should().HaveCount(1, "409 rồi thì DB phải y nguyên");
            live.Steps.Single().Levels.Single().ApproverUserId.Should().Be(alice.Id);
        }
    }

    [Fact]
    public async Task F13_RemoveWholeLevel_Rejected409_AndWorkflowUnchanged()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
            var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Cúc", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id), (2, carol.Id)]]);
            var lvlAlice = wf.Steps.Single().Levels.Single(l => l.Order == 1);

            // Bỏ TOÀN BỘ Cấp 2 ⇒ phiếu đang chờ ở Cấp 2 sẽ KẸT.
            var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
            var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            await act.Should().ThrowAsync<ConflictException>().WithMessage("*KHÔNG CÒN người duyệt*");

            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.Steps.Single().Levels.Select(l => l.Order).Should().BeEquivalentTo(new[] { 1, 2 });
        }
    }

    [Fact]
    public async Task F13_MovePersonBetweenLevelOrders_Rejected409_AndWorkflowUnchanged()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
            var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Cúc", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id), (2, carol.Id)]]);
            var step = wf.Steps.Single();
            var lvlAlice = step.Levels.Single(l => l.Order == 1);
            var lvlCarol = step.Levels.Single(l => l.Order == 2);

            // Hoán vị Cấp giữa 2 dòng: TẬP {1,2} GIỮ NGUYÊN nên set-equality một
            // mình sẽ cho qua — chốt chặn thật là "Order của TỪNG dòng bất biến".
            var cmd = WithLevels(BuildEchoCommand(wf), 0,
                [EchoLevel(lvlAlice) with { Order = 2 }, EchoLevel(lvlCarol) with { Order = 1 }]);
            var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            await act.Should().ThrowAsync<ConflictException>().WithMessage("*không được chuyển người duyệt từ Cấp*");

            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id).Order.Should().Be(1);
            live.Steps.Single().Levels.Single(l => l.ApproverUserId == carol.Id).Order.Should().Be(2);
        }
    }

    [Fact]
    public async Task F13_SwapStepOrders_KeepsOrderSet_ButStillRejected409()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
            var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Cúc", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id)], [(1, carol.Id)]]);

            // Đảo Order 2 Bước: tập {Step.Order} = {1,2} KHÔNG đổi ⇒ phép thử
            // set-equality "khung không đổi" theo nghĩa yếu sẽ cho lọt, nhưng con
            // trỏ phiếu là INDEX vào list đã sort nên nó trỏ sang Bước KHÁC, ÂM THẦM.
            var echo = BuildEchoCommand(wf);
            var swapped = new List<UpdateAwStepInput>
            {
                echo.Steps[0] with { Order = 2 },
                echo.Steps[1] with { Order = 1 },
            };
            var act = async () => await NewHandler(h, admin).Handle(echo with { Steps = swapped }, CancellationToken.None);

            await act.Should().ThrowAsync<ConflictException>().WithMessage("*không được đổi thứ tự Bước*");

            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.Steps.Single(s => s.Levels.Any(l => l.ApproverUserId == alice.Id)).Order.Should().Be(1);
            live.Steps.Single(s => s.Levels.Any(l => l.ApproverUserId == carol.Id)).Order.Should().Be(2);
        }
    }

    // =====================================================================
    // F-13b (T2) — 2 trường owner xếp PHÁ VỠ: CeoApprovalThreshold + F5.
    // =====================================================================
    [Fact]
    public async Task F13b_ChangeCeoApprovalThreshold_Rejected409_AndValueUnchanged()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id)]], ceoThreshold: 500_000_000m);

            var cmd = BuildEchoCommand(wf) with { CeoApprovalThreshold = 900_000_000m };
            var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            await act.Should().ThrowAsync<ConflictException>().WithMessage("*Ngưỡng giá trị gói CEO*");

            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.CeoApprovalThreshold.Should().Be(500_000_000m);
        }
    }

    // Vế IM LẶNG của cùng luật: client KHÔNG khai báo ngưỡng (null) trong khi DB
    // đang có ngưỡng. Nếu handler đọc null là "bỏ qua" thì admin xoá trắng ô ngưỡng
    // sẽ nhận 204 mà số không đổi — sai im lặng, đúng lớp lỗi spec đang đi chữa.
    [Fact]
    public async Task F13b_OmitCeoApprovalThreshold_WhenStored_Rejected409_NoSilentNoop()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id)]], ceoThreshold: 500_000_000m);

            var cmd = BuildEchoCommand(wf) with { CeoApprovalThreshold = null, Name = "Tên mới" };
            var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            await act.Should().ThrowAsync<ConflictException>().WithMessage("*Ngưỡng giá trị gói CEO*");

            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.CeoApprovalThreshold.Should().Be(500_000_000m);
            live.Name.Should().Be("QT test sửa tại chỗ", "409 thì KHÔNG phần nào của lệnh được ghi");
        }
    }

    [Fact]
    public async Task F13b_ToggleAllowApproverFinalize_Rejected409_AndFlagUnchanged()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id)]]);
            var lvl = wf.Steps.Single().Levels.Single();

            // F5 = ĐỔI ĐIỂM KẾT THÚC của phiếu (Phase=DaDuyet + đóng băng số tiền
            // ngay lúc duyệt) ⇒ không phải "quyền tại chỗ".
            var cmd = WithLevels(BuildEchoCommand(wf), 0,
                [EchoLevel(lvl) with { AllowApproverFinalize = true }]);
            var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            await act.Should().ThrowAsync<ConflictException>().WithMessage("*Duyệt là kết thúc*");

            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.Steps.Single().Levels.Single().AllowApproverFinalize.Should().BeFalse();
        }
    }

    // Nửa còn lại của "ECHO-only, KHÔNG BAO GIỜ ghi": khi client bỏ trống 2 cờ
    // đổi-đường-đi thì giá trị đang lưu phải GIỮ NGUYÊN. Đây mới là chốt chặn hồi
    // quy thật — nếu ai đó sau này viết `db.X = req.X ?? false` thì F5 của người
    // đang giữ quyền kết-thúc bị TẮT âm thầm qua một lần đổi tên vô hại.
    [Fact]
    public async Task F13b_OmittedFinalizeEcho_PreservesStoredFlags_NotSilentlyCleared()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id)]]);
            var lvl = wf.Steps.Single().Levels.Single();
            lvl.AllowApproverFinalize = true;
            lvl.AllowApproverSkipToFinal = true;
            await h.Db.SaveChangesAsync(CancellationToken.None);

            // Lệnh hợp lệ, chỉ đổi tên — 2 cờ để null (không khai báo).
            var cmd = BuildEchoCommand(wf) with { Name = "Tên quy trình đã đổi" };
            await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.Name.Should().Be("Tên quy trình đã đổi");
            var liveLevel = live.Steps.Single().Levels.Single();
            liveLevel.AllowApproverFinalize.Should().BeTrue("cờ đổi-đường-đi là echo-only, không được ghi đè bằng null");
            liveLevel.AllowApproverSkipToFinal.Should().BeTrue();
        }
    }

    // =====================================================================
    // F-14 — bớt người khỏi Cấp mà dòng đó ĐÃ CÓ chữ ký ⇒ chặn LỊCH SỰ (409),
    // KHÔNG để nổ FK 547 → 500.
    // Kèm CHỨNG-NHÂN: cuối test tự tay xoá dòng đó ⇒ DB nổ thật. Không có đoạn
    // này thì assert "409" chỉ chứng minh handler biết ném, chứ không chứng minh
    // nó đang đỡ một quả nổ có thật.
    // =====================================================================
    [Fact]
    public async Task F14_RemoveApproverWithLiveOpinion_BlockedPolitely_NotFkViolation()
    {
        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ị", null, [AppRoles.Admin]);
            var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);

            // Cấp 1 có 2 người ⇒ bỏ bob KHÔNG làm Cấp rỗng (tách bạch với F-13).
            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id), (1, bob.Id)]]);
            var lvlBob = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == bob.Id);
            var lvlAlice = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id);

            var pe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F14-001");
            h.Db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
            {
                PurchaseEvaluationId = pe.Id,
                ApprovalWorkflowLevelId = lvlBob.Id,
                Comment = "Đồng ý",
                SignedAt = h.Clock.UtcNow,
                SignedByUserId = bob.Id,
                SignedByFullName = "Lê Văn Bình",
            });
            await h.Db.SaveChangesAsync(CancellationToken.None);

            var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
            var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            var ex = (await act.Should().ThrowAsync<ConflictException>(
                "chữ ký trên phiếu CÒN SỐNG phải chặn việc bỏ người, và chặn LỊCH SỰ")).Which;
            ex.Message.Should().Contain("chữ ký");

            // DB nguyên vẹn: cả dòng Cấp lẫn chữ ký đều còn.
            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.Steps.Single().Levels.Should().HaveCount(2);
            (await h.Db.PurchaseEvaluationLevelOpinions.AsNoTracking()
                .CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id)).Should().Be(1);

            // CHỨNG-NHÂN: nếu không có guard thì đây là thứ người dùng nhận (FK Restrict).
            // 🔴 Phải dọn change-tracker TRƯỚC: chữ ký ở trên do CHÍNH test `Add` vào cùng
            // context nên nó vẫn đang được theo dõi. Khi dependent còn tracked, `Remove(level)`
            // kích cascade-check CLIENT-SIDE của EF (CascadeDeleteTiming.Immediate = mặc định)
            // và ném InvalidOperationException("...has been severed...") NGAY TẠI `Remove` —
            // chưa từng chạm DB ⇒ dòng assert bên dưới KHÔNG BAO GIỜ được chấm. Dọn tracker
            // để chứng-nhân đo ĐÚNG cái tầng nó khai: ràng buộc FK ở DB.
            // (Prod không dính: context mỗi request không nạp opinion nào, guard đọc chữ ký
            //  bằng AsNoTracking ⇒ tới `Remove` là tracker vốn đã sạch.)
            h.Db.ChangeTracker.Clear();
            h.Db.ApprovalWorkflowLevels.Remove(lvlBob);
            var raw = async () => await h.Db.SaveChangesAsync(CancellationToken.None);
            await raw.Should().ThrowAsync<DbUpdateException>(
                "FK Restrict LevelOpinion→Level là thật — guard 409 đang đỡ đúng quả nổ này");
        }
    }

    // 🔴 F-2 (reviewer đợt 1) — TIÊU CHÍ NGHIỆM THU #1. Cùng RUỘT với test trên,
    // khác ĐÚNG MỘT BIT: phiếu mang chữ ký đó đã bị xoá MỀM.
    //
    // ĐẢO SO VỚI BẢN TRƯỚC (bản trước tên `..._RemovalAllowed_AndOrphanPurged`, khẳng
    // định "cho gỡ + xoá cứng chữ ký"). Vì sao đảo — không phải nới/siết cho hợp code:
    //   xoá mềm là KHÔI PHỤC ĐƯỢC và §A spec S155 có màn "Đã xoá" phải đọc được lịch
    //   sử ký. Xoá cứng chữ ký của phiếu xoá mềm = phiếu khôi phục lên mất trắng lịch
    //   sử duyệt, IM LẶNG, không dấu vết (changelog cũ chỉ ghi một con SỐ ĐẾM, không
    //   ghi phiếu nào). Đó là đúng class lỗi mà cả spec S155 đang đi chữa.
    // "Bị chặn OAN" (lý lẽ cũ) không phải là mất mát: admin còn 2 đường thoát rõ ràng
    // (khôi phục phiếu rồi xử lý, hoặc xoá HẲN phiếu) — cả hai đều hoàn tác được.
    [Fact]
    public async Task F2_OpinionBelongsToSoftDeletedPe_BlockedPolitely_AndSignatureKept()
    {
        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", "Bình", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);

            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id), (1, bob.Id)]]);
            var lvlBob = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == bob.Id);
            var lvlAlice = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id);

            var deletedPe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F14b-001", isDeleted: true);
            h.Db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
            {
                PurchaseEvaluationId = deletedPe.Id,
                ApprovalWorkflowLevelId = lvlBob.Id,
                Comment = "Đồng ý",
                SignedAt = h.Clock.UtcNow,
                SignedByUserId = bob.Id,
                SignedByFullName = "Bình",
            });
            await h.Db.SaveChangesAsync(CancellationToken.None);

            var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
            var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            var ex = (await act.Should().ThrowAsync<ConflictException>(
                "chữ ký của phiếu XOÁ MỀM vẫn là lịch sử duyệt khôi phục được")).Which;
            // Thông điệp phải NÊU TÊN phiếu đang giữ chữ ký — không thì admin không biết gỡ gì.
            ex.Message.Should().Contain("PE-F14b-001").And.Contain("đã xoá");

            // 🔴 Điều F-2 đòi: chữ ký VẪN CÒN. Đọc bằng IgnoreQueryFilters để không bị
            // chính global filter che mất row (đọc thiếu sẽ trông y hệt "đã bị xoá").
            (await h.Db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters()
                .CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id)).Should().Be(1);

            // Và quy trình cũng không bị sứt: cả 2 dòng Cấp còn nguyên.
            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.Steps.Single().Levels.Should().HaveCount(2);
        }
    }

    // 🔴 F-2 — TIÊU CHÍ NGHIỆM THU #2, vế thứ HAI của điều kiện purge cũ (`o.IsDeleted`):
    // ý kiến TỰ NÓ xoá mềm, trên phiếu CÒN SỐNG. Đó là bản ghi KIỂM TOÁN của tài liệu
    // đang hiệu lực — bản trước đem xoá CỨNG luôn. Cặp cùng-ruột-khác-1-bit với test
    // trên: ở đó cha xoá mềm / ý kiến sống, ở đây cha sống / ý kiến xoá mềm.
    [Fact]
    public async Task F2_SoftDeletedOpinionOnLivePe_NotHardPurged_BlockedAndKept()
    {
        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", "Bình", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);

            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id), (1, bob.Id)]]);
            var lvlBob = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == bob.Id);
            var lvlAlice = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id);

            // Phiếu CÒN SỐNG (không isDeleted) — chỉ ý kiến bị thu hồi (xoá mềm).
            var livePe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F2b-001");
            h.Db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
            {
                PurchaseEvaluationId = livePe.Id,
                ApprovalWorkflowLevelId = lvlBob.Id,
                Comment = "Đồng ý (sau đó thu hồi)",
                SignedAt = h.Clock.UtcNow,
                SignedByUserId = bob.Id,
                SignedByFullName = "Bình",
                IsDeleted = true,
                DeletedAt = new DateTime(2026, 7, 26, 0, 0, 0, DateTimeKind.Utc),
            });
            await h.Db.SaveChangesAsync(CancellationToken.None);

            // Tầng 1 (`CountLiveOpinionsAsync`) CỐ TÌNH bỏ qua row này (`!o.IsDeleted`)
            // ⇒ nếu tầng 2 không tồn tại thì đây rơi thẳng vào purge và mất vĩnh viễn.
            var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
            var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            var ex = (await act.Should().ThrowAsync<ConflictException>(
                "ý kiến đã thu hồi trên phiếu đang hiệu lực là bản ghi kiểm toán")).Which;
            ex.Message.Should().Contain("PE-F2b-001").And.Contain("thu hồi");

            (await h.Db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters()
                .CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id)).Should().Be(1);
        }
    }

    // 🔴 F-1 (reviewer đợt 1) — TIÊU CHÍ NGHIỆM THU: purge (`ExecuteDelete`) auto-commit
    // NGAY khi không có transaction ambient, mà `SaveChangesAsync` thì mãi cuối method.
    // Ném ở khoảng GIỮA ⇒ mọi thứ sau rollback, chữ ký đã xoá cứng thì KHÔNG quay lại.
    //
    // Dựng lại đúng hình đó, KHÔNG mock:
    //   (1) chữ ký MỒ CÔI THẬT — phiếu cha đã không còn row. Không dựng được bằng đường
    //       ứng dụng (FK cha→opinion là Cascade: xoá cứng cha thì chữ ký đi theo), nên
    //       tắt `PRAGMA foreign_keys` đúng một câu DELETE để mô phỏng dữ liệu lệch sinh
    //       ngoài luồng — chính thứ mà purge tồn tại để dọn.
    //   (2) BẪY ném SAU purge: một changelog trỏ vào phiếu không tồn tại, gài sẵn trong
    //       change-tracker ⇒ `SaveChangesAsync` cuối method nổ FK. Hiện thực đúng luồng
    //       thật: context là scoped-theo-request, handler khác trong cùng request có thể
    //       để lại thay đổi đang chờ.
    // Nếu rào giao dịch KHÔNG có: purge đã commit ⇒ assert dưới thấy 0 ⇒ đỏ.
    [Fact]
    public async Task F1_SaveChangesThrowsAfterPurge_RollsBackWholeCommand_OrphanSignaturesKept()
    {
        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", "Bình", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);

            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id), (1, bob.Id)]]);
            var lvlBob = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == bob.Id);
            var lvlAlice = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id);

            // Phiếu SỐNG pin quy trình này ⇒ nhánh ghi changelog (F-1c) có việc để làm,
            // nên `SaveChanges` cuối method thật sự được gọi.
            await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F1-LIVE");

            var ghostPe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F1-GHOST");
            h.Db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
            {
                PurchaseEvaluationId = ghostPe.Id,
                ApprovalWorkflowLevelId = lvlBob.Id,
                Comment = "Đồng ý",
                SignedAt = h.Clock.UtcNow,
                SignedByUserId = bob.Id,
                SignedByFullName = "Bình",
            });
            await h.Db.SaveChangesAsync(CancellationToken.None);

            // (1) Xoá HẲN phiếu cha nhưng chặn cascade ⇒ còn lại chữ ký MỒ CÔI THẬT.
            await h.Db.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF;");
            await h.Db.Database.ExecuteSqlRawAsync(
                "DELETE FROM PurchaseEvaluations WHERE Id = {0}", ghostPe.Id);
            await h.Db.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = ON;");
            h.Db.ChangeTracker.Clear();     // tracker vẫn giữ bản sao — dọn cho khớp DB

            (await h.Db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters()
                .CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id))
                .Should().Be(1, "tiền đề: phải có đúng 1 chữ ký mồ côi cho purge nhắm tới");

            // (2) BẪY — sẽ nổ FK tại `SaveChangesAsync`, tức SAU khi purge đã chạy.
            h.Db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
            {
                PurchaseEvaluationId = Guid.NewGuid(),      // phiếu KHÔNG tồn tại
                EntityType = PurchaseEvaluationEntityType.Workflow,
                Action = ChangelogAction.Update,
                UserId = admin.Id,
                UserName = "Quản trị",
                Summary = "Bẫy: ép SaveChanges ném SAU khi purge đã chạy",
            });

            var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
            var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
            await act.Should().ThrowAsync<DbUpdateException>(
                "bẫy phải nổ ở SaveChanges — nếu không thì test này không đo được cái nó khai");

            // 🔴 Điều F-1 đòi: số row chữ ký KHÔNG ĐỔI sau lệnh thất bại.
            h.Db.ChangeTracker.Clear();
            (await h.Db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters()
                .CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id))
                .Should().Be(1, "purge phải cùng rollback với SaveChanges, không auto-commit riêng");

            // Và phần còn lại của lệnh cũng rollback trọn: dòng Cấp chưa bị gỡ.
            (await h.Db.ApprovalWorkflowLevels.AsNoTracking()
                .CountAsync(l => l.Id == lvlBob.Id)).Should().Be(1);
            (await h.Db.PurchaseEvaluationChangelogs.AsNoTracking().CountAsync()).Should().Be(0);
        }
    }

    // =====================================================================
    // F-15 (NỬA TRƯỚC) — bật cờ F6 `AllowApproverDelete` bằng Update trên
    // workflow đang có phiếu ChoDuyet treo ⇒ cờ ĐỌC ĐƯỢC từ phía phiếu đang chạy.
    //
    // 🔴 GIỚI HẠN CỐ Ý: KHÔNG test hành vi XOÁ phiếu. Nút xoá ở màn duyệt +
    // endpoint xoá-khi-đang-duyệt thuộc ĐỢT 2 và CHƯA TỒN TẠI trên đĩa lúc viết
    // test này. Ở đây chỉ chứng minh mắt xích §F.0: cờ tick hôm nay TỚI ĐƯỢC
    // phiếu treo từ hôm qua (điều mà POST-tạo-version-mới không làm được).
    // =====================================================================
    [Fact]
    public async Task F15_EnableAllowApproverDeleteViaUpdate_FlagReachesAlreadyPendingPe_DeleteBehaviorIsWave2()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
            var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Cúc", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);

            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id), (2, carol.Id)]]);
            // Phiếu treo TỪ TRƯỚC khi admin tick cờ.
            var pe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F15-001");

            var before = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            before.Steps[0].Levels.Single(l => l.ApproverUserId == alice.Id)
                .AllowApproverDelete.Should().BeFalse("nền: cờ F6 mặc định tắt");

            var step = wf.Steps.Single();
            var lvlAlice = step.Levels.Single(l => l.ApproverUserId == alice.Id);
            var lvlCarol = step.Levels.Single(l => l.ApproverUserId == carol.Id);
            var cmd = WithLevels(BuildEchoCommand(wf, reason: "Bấm sai gói thầu — cần cho phép xoá"), 0,
                [EchoLevel(lvlAlice) with { AllowApproverDelete = true }, EchoLevel(lvlCarol)]);

            await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            // Đọc ĐÚNG như phiếu đang chạy đọc: theo Id đã pin + con trỏ (Bước, Cấp).
            var peRow = await h.Db.PurchaseEvaluations.AsNoTracking().FirstAsync(p => p.Id == pe.Id);
            var live = await ResolveLikeRunningPeAsync(h.Db, peRow.ApprovalWorkflowId!.Value);
            var currentStep = live.Steps.OrderBy(s => s.Order).ToList()[peRow.CurrentWorkflowStepIndex!.Value];
            var matchingRow = currentStep.Levels
                .Single(l => l.Order == peRow.CurrentApprovalLevelOrder && l.ApproverUserId == alice.Id);

            matchingRow.AllowApproverDelete.Should().BeTrue(
                "§F.0: chỉ sửa-tại-chỗ mới đưa được cờ tới phiếu đang treo — POST version mới sinh Id khác");
            live.Version.Should().Be(1, "phiếu vẫn đọc ĐÚNG bản đang pin, không phải bản mới");

            // Per-ROW chứ không per-Cấp: người khác trong quy trình KHÔNG tự có cờ.
            live.Steps[0].Levels.Single(l => l.ApproverUserId == carol.Id)
                .AllowApproverDelete.Should().BeFalse();
        }
    }

    // =====================================================================
    // F-16 — blast radius: `ApprovalWorkflow` V2 dùng chung ≥6 module. Nếu guard
    // chữ ký chỉ soi bảng PE thì quy trình của Văn phòng số vẫn nổ FK 547 → 500.
    // =====================================================================
    [Fact]
    public async Task F16_ProposalWorkflow_RemoveApproverWithLiveProposalOpinion_Blocked409()
    {
        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", "Bình", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);

            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.ProposalGeneral,
                [[(1, alice.Id), (1, bob.Id)]], code: "QT-DX-V2");
            var lvlBob = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == bob.Id);
            var lvlAlice = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id);

            var proposal = new Proposal
            {
                Id = Guid.NewGuid(),
                Title = "Đề xuất mua máy tính",
                Status = ProposalStatus.DaGuiDuyet,
                DrafterUserId = Guid.NewGuid(),
                ApprovalWorkflowId = wf.Id,
                CurrentApprovalLevelOrder = 1,
            };
            h.Db.Proposals.Add(proposal);
            h.Db.ProposalLevelOpinions.Add(new ProposalLevelOpinion
            {
                ProposalId = proposal.Id,
                ApprovalWorkflowLevelId = lvlBob.Id,
                Comment = "Đồng ý",
                SignedAt = h.Clock.UtcNow,
                SignedByUserId = bob.Id,
                SignedByFullName = "Bình",
            });
            await h.Db.SaveChangesAsync(CancellationToken.None);

            var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
            var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            await act.Should().ThrowAsync<ConflictException>(
                "chữ ký của module Đề xuất cũng Restrict FK y như PE — guard phải quét CẢ 7 bảng opinion");

            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.Steps.Single().Levels.Should().HaveCount(2);
        }
    }

    [Fact]
    public async Task F16_ProposalWorkflow_SafeUpdate_Succeeds_AndWritesNoPeChangelog()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
            var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Cúc", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);

            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.ProposalGeneral,
                [[(1, alice.Id)]], code: "QT-DX-V2");
            var proposal = new Proposal
            {
                Id = Guid.NewGuid(),
                Title = "Đề xuất tăng ngân sách",
                Status = ProposalStatus.DaGuiDuyet,
                DrafterUserId = Guid.NewGuid(),
                ApprovalWorkflowId = wf.Id,
                CurrentApprovalLevelOrder = 1,
            };
            h.Db.Proposals.Add(proposal);
            await h.Db.SaveChangesAsync(CancellationToken.None);

            // 🔴 CHỨNG-NHÂN cho cổng module (vá F11 — reviewer bắt ca này XANH GIẢ).
            // Nếu KHÔNG ghim phiếu PE nào vào workflow này thì `affected` rỗng ở CẢ HAI
            // thế giới — còn cổng `def.ApplicableType is DuyetNcc or DuyetNccPhuongAn`
            // hay gỡ cổng đi — nên assert `Count == 0` bên dưới luôn đúng và KHÔNG đo gì.
            // Ghim 1 phiếu PE (phase ChoDuyet = "chưa xong" ⇒ lọt truy vấn `affected`)
            // vào đúng workflow module Đề xuất này thì phép đo mới phân biệt được:
            // gỡ cổng ⇒ handler ghi changelog PE ⇒ ca này ĐỎ.
            var peGateWitness = await SeedPeAsync(h.Db, wf.Id, admin.Id, "PE-F16-GATE");

            var lvlAlice = wf.Steps.Single().Levels.Single();
            var cmd = WithLevels(BuildEchoCommand(wf, reason: "Bổ sung người duyệt"), 0,
                [EchoLevel(lvlAlice), NewLevel(1, carol.Id)]);

            await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.Steps.Single().Levels.Select(l => l.ApproverUserId)
                .Should().BeEquivalentTo(new[] { alice.Id, carol.Id });
            live.ApplicableType.Should().Be(ApprovalWorkflowApplicableType.ProposalGeneral);

            // Phép đo nay CÓ RĂNG: có đúng 1 phiếu PE ChoDuyet ghim workflow này, nên
            // nếu cổng module bị gỡ thì handler sẽ ghi vết cho nó ⇒ count = 1 ⇒ ĐỎ.
            (await h.Db.PurchaseEvaluations.AsNoTracking()
                .CountAsync(p => p.ApprovalWorkflowId == wf.Id && p.Phase == PurchaseEvaluationPhase.ChoDuyet))
                .Should().Be(1, "chứng-nhân phải tồn tại, nếu không assert dưới là rỗng nghĩa");
            (await h.Db.PurchaseEvaluationChangelogs.AsNoTracking().CountAsync())
                .Should().Be(0, "quy trình module Đề xuất KHÔNG được đẻ vết vào changelog của PE");
            peGateWitness.Should().NotBeNull();

            var proposalRow = await h.Db.Proposals.AsNoTracking().FirstAsync(p => p.Id == proposal.Id);
            proposalRow.ApprovalWorkflowId.Should().Be(wf.Id);
        }
    }

    // =====================================================================
    // F-1c — GHI VẾT. Spec đòi changelog khi xoá phiếu (§B4) nhưng ban đầu quên
    // đòi vết cho việc SỬA QUYỀN — bất đối xứng, review bắt. Vết phải rơi đúng
    // phiếu ĐANG CHỊU ẢNH HƯỞNG (chưa kết thúc), không rải cho phiếu đã đóng.
    // =====================================================================
    [Fact]
    public async Task F1c_SafeUpdate_LogsChangelogWithReason_OnlyToUnfinishedPes()
    {
        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("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);

            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id)]]);
            var pending = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F1c-PENDING");
            var approved = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F1c-DADUYET",
                phase: PurchaseEvaluationPhase.DaDuyet);
            var rejected = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F1c-TUCHOI",
                phase: PurchaseEvaluationPhase.TuChoi);

            var lvlAlice = wf.Steps.Single().Levels.Single();
            var cmd = WithLevels(BuildEchoCommand(wf, reason: "Bấm sai gói thầu"), 0,
                [EchoLevel(lvlAlice), NewLevel(1, bob.Id)]);

            await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            var logs = await h.Db.PurchaseEvaluationChangelogs.AsNoTracking().ToListAsync();
            logs.Select(l => l.PurchaseEvaluationId).Should().BeEquivalentTo(new[] { pending.Id },
                $"chỉ phiếu chưa kết thúc mới chịu ảnh hưởng (DaDuyet {approved.MaPhieu} + TuChoi {rejected.MaPhieu} phải bị loại)");

            var log = logs.Single();
            log.Action.Should().Be(ChangelogAction.Update);
            log.EntityType.Should().Be(PurchaseEvaluationEntityType.Workflow);
            log.EntityId.Should().Be(wf.Id);
            log.PhaseAtChange.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
            log.UserId.Should().Be(admin.Id);
            log.UserName.Should().Be("Quản trị hệ thống");
            log.Summary.Should().Contain("giữ nguyên phiên bản");
            log.ContextNote.Should().Contain("Lý do: Bấm sai gói thầu");
            log.ContextNote.Should().Contain("THÊM NV duyệt Lê Văn Bình",
                "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");
        }
    }

    // ###################################################################
    // [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");
        }
    }
}
