using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;     // ICurrentUser — bơm cho AuditingInterceptor
using SolutionErp.Application.Common.Models;         // PagedResult / PagedRequest
using SolutionErp.Application.ContractSigningPlans;
using SolutionErp.Application.ContractSigningPlans.Services;   // ContractSigningPlanTransitionResult
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.ContractSigningPlans;
using SolutionErp.Domain.Identity;                   // AppRoles + User
using SolutionErp.Domain.Master;
using SolutionErp.Domain.Master.Catalogs;            // ContractCatalogEntry (K1 — Mig 70)
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Persistence;
using SolutionErp.Infrastructure.Persistence.Interceptors;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;

namespace SolutionErp.Infrastructure.Tests.Application;

// [K2 KHKK — S164 2026-07-31] Phiếu Kế hoạch ký kết mang HẠNG MỤC (danh mục SP-002) + NHÓM
// DUYỆT N1..N8 (Mig 71) — test-AFTER (`docs/rules.md §7`: BE đã land, spec ĐÓNG BĂNG).
//
// Code under test (đo lại trên đĩa, KHÔNG chép số từ sổ):
//   submit-guard hạng mục   `ContractSigningPlanWorkflowService.cs:174-182`  (vá-5 spec-cụm1)
//   rào (PeId, ApprovalGroup) `ContractSigningPlanFeatures.cs:393-402`
//   list `?group=`            `ContractSigningPlanFeatures.cs:737-738`
//   Σ đã-chốt (vá-8)          `ContractSigningPlanFeatures.cs:803-804`
//   gate ghi nháp             `ContractSigningPlanFeatures.cs:229-237`
//   site gán-Phase-qua-BIẾN   `ContractSigningPlanWorkflowService.cs:327-359`
//
// ── 3 quyết định hạ tầng (mỗi cái là ĐIỀU KIỆN để phép đo có nghĩa) ─────────────
//
// (1) File RIÊNG + fixture RIÊNG (mirror `ContractSigningPlanCrudTests.cs:49-82`) thay vì
//     nối vào 2 file KHKK sẵn có: cả hai file đó vừa được sửa theo spec K2 ở lane khác —
//     chèn thêm vào chúng là hai tay cùng ghi một tệp. Fixture wire `AuditingInterceptor`
//     vì guard trình đọc `plan.CreatedBy` (`…Service.cs:222`); `TestApplicationDbContext`
//     trần KHÔNG wire ⇒ `CreatedBy` null ⇒ submit có thể đỏ/xanh vì LÝ DO SAI (bài S155).
//
// (2) MỖI lệnh một `DbContext` mới trên CÙNG một connection — mô phỏng scoped-per-request.
//     Dùng lại context của seed thì Lines/Changelogs đã nằm sẵn trong change-tracker, một
//     đường đi không có thật ở prod (ở đó service chỉ có thứ nó tự `Include`).
//
// (3) Seed User bằng `db.Users.Add` thẳng, KHÔNG kéo `IdentityFixture`: các handler dưới
//     test nhận vai qua `ICurrentUser.Roles`; `ApprovalWorkflowLevel.ApproverUserId` chỉ
//     cần ROW tồn tại (FK Restrict).
//
// ⚠️ QUAN SÁT (KHÔNG phải bug-claim, không pin thành test): submit-guard dùng
//    `plan.Lines.Any(l => l.CatalogEntryId == null)` ⇒ phiếu KHÔNG có dòng nào sẽ đi lọt cả
//    hai vế guard. Đường tạo phiếu của K2 luôn sinh ≥1 dòng (mỗi winner 1 dòng, và create
//    ném khi 0 winner) nên hiện KHÔNG với tới được; nếu K4b mở đường xoá dòng thì khe này
//    thành thật. Vì thế các test dưới đây đo trên phiếu CÓ dòng thật (DoD vá-5: chống
//    PASS-bằng-tập-rỗng) và assert số dòng trước khi trình.
public class ContractSigningPlanGroupAndCatalogTests
{
    private static readonly DateTime Clock2026 = new(2026, 7, 31, 3, 0, 0, DateTimeKind.Utc);

    // ===================================================================
    // Fixture: SQLite in-memory + AuditingInterceptor.
    // ===================================================================
    private sealed class KhkkGroupFixture : IDisposable
    {
        private readonly SqliteConnection _conn;
        public FixedDateTime Clock { get; } = new(Clock2026);

        public KhkkGroupFixture()
        {
            _conn = new SqliteConnection("DataSource=:memory:");
            _conn.Open();
            using var boot = NewDb(TestCurrentUser.System());
            boot.Database.EnsureCreated();
        }

        /// `actor` phải là ĐÚNG người mà lệnh chạy dưới danh nghĩa — prod resolve CÙNG một
        /// `ICurrentUser` scoped cho cả interceptor lẫn handler.
        public TestApplicationDbContext NewDb(ICurrentUser actor)
        {
            var options = new DbContextOptionsBuilder<ApplicationDbContext>()
                .UseSqlite(_conn)
                .EnableSensitiveDataLogging()
                .AddInterceptors(new AuditingInterceptor(actor, Clock))
                .Options;
            return new TestApplicationDbContext(options);
        }

        public void Dispose() => _conn.Dispose();
    }

    // ===================================================================
    // Seed helpers
    // ===================================================================

    private static TestCurrentUser As(User u, params string[] roles)
        => new(u.Id, u.FullName, u.Email, roles);

    private static async Task<User> AddUserAsync(KhkkGroupFixture f, string fullName)
    {
        await using var db = f.NewDb(TestCurrentUser.System());
        var email = $"u{Guid.NewGuid():N}@test.local";
        var user = new User
        {
            Id = Guid.NewGuid(),
            FullName = fullName,
            UserName = email,
            NormalizedUserName = email.ToUpperInvariant(),
            Email = email,
            NormalizedEmail = email.ToUpperInvariant(),
            EmailConfirmed = true,
            SecurityStamp = Guid.NewGuid().ToString("N"),
            ConcurrencyStamp = Guid.NewGuid().ToString("N"),
            IsActive = true,
            CreatedAt = Clock2026,
        };
        db.Users.Add(user);
        await db.SaveChangesAsync(CancellationToken.None);
        return user;
    }

    /// 1 dòng danh mục SP-002 thuộc `approvalGroup`. `Code` random né UNIQUE filtered
    /// (`ContractCatalogEntryConfiguration` — K1 Mig 70).
    private static async Task<Guid> AddCatalogAsync(KhkkGroupFixture f, int approvalGroup, string tenVi)
    {
        await using var db = f.NewDb(TestCurrentUser.System());
        var entry = new ContractCatalogEntry
        {
            Id = Guid.NewGuid(),
            Code = "K2-" + Guid.NewGuid().ToString("N")[..8],
            TenVi = tenVi,
            GroupCode = "B1",
            ApprovalGroup = approvalGroup,
            SignerRole = "TP.PRO",
            IsActive = true,
            SortOrder = 1,
        };
        db.ContractCatalogEntries.Add(entry);
        await db.SaveChangesAsync(CancellationToken.None);
        return entry.Id;
    }

    /// `steps[i][j]` = NV của Cấp j+1 trong Bước i+1 (OR-of-N nếu nhiều NV cùng Cấp).
    private static async Task<Guid> SeedWorkflowAsync(
        KhkkGroupFixture f,
        string code,
        Guid[][][] steps,
        ApprovalWorkflowApplicableType type = ApprovalWorkflowApplicableType.ContractSigningPlan,
        // [S182 vá MAJOR-3] 3 tham số ĐUÔI cho rào D6 (`EnsureWorkflowPinnableAsync`): cần
        // dựng nhiều version CÙNG một Code (UNIQUE là (Code, Version) —
        // `ApprovalWorkflowConfiguration.cs:23`) rồi lật TỪNG cờ một. Optional TRAILING ⇒ mọi
        // call-site cũ giữ nguyên chữ. Cùng khuôn `ContractSigningPlanApprovalTests.cs:135-140`.
        int version = 1,
        bool isActive = true,
        bool isUserSelectable = true)
    {
        await using var db = f.NewDb(TestCurrentUser.System());
        var wf = new ApprovalWorkflow
        {
            Id = Guid.NewGuid(),
            Code = code,
            Version = version,
            Name = "Quy trình duyệt Kế hoạch ký kết",
            ApplicableType = type,
            IsActive = isActive,
            IsUserSelectable = isUserSelectable,
        };
        db.ApprovalWorkflows.Add(wf);

        for (var s = 0; s < steps.Length; s++)
        {
            var step = new ApprovalWorkflowStep
            {
                Id = Guid.NewGuid(),
                ApprovalWorkflowId = wf.Id,
                Order = s + 1,
                Name = $"Bước {s + 1}",
                DepartmentId = null,       // null = né FK phòng ban (Pattern 11)
            };
            db.ApprovalWorkflowSteps.Add(step);

            for (var l = 0; l < steps[s].Length; l++)
                foreach (var approverId in steps[s][l])
                    db.ApprovalWorkflowLevels.Add(new ApprovalWorkflowLevel
                    {
                        Id = Guid.NewGuid(),
                        ApprovalWorkflowStepId = step.Id,
                        Order = l + 1,
                        Name = $"Cấp {l + 1}",
                        ApproverUserId = approverId,
                    });
        }

        await db.SaveChangesAsync(CancellationToken.None);
        return wf.Id;
    }

    /// Dòng phiếu: `CatalogId = null` = dòng CHƯA gán hạng mục (đúng trạng thái auto-gen
    /// `ContractSigningPlanFeatures.cs:454-455`).
    private sealed record LineSeed(
        decimal PeReference, decimal Proposed, Guid? CatalogId, decimal? Approved = null);

    private static async Task<Guid> SeedPlanAsync(
        KhkkGroupFixture f,
        TestCurrentUser drafter,
        Guid? workflowId,
        string maKeHoach,
        ContractSigningPlanPhase phase,
        int approvalGroup,
        LineSeed[] lines,
        int? stepIndex = null,
        int? levelOrder = null,
        Guid? peId = null)
    {
        await using var db = f.NewDb(drafter);   // interceptor ghi CreatedBy = người soạn

        var plan = new ContractSigningPlan
        {
            Id = Guid.NewGuid(),
            MaKeHoach = maKeHoach,
            PurchaseEvaluationId = peId ?? Guid.NewGuid(),   // loose-Guid, không FK vật lý
            ProjectId = Guid.NewGuid(),
            DepartmentId = Guid.NewGuid(),
            DrafterUserId = drafter.UserId!.Value,
            Phase = phase,
            ApprovalGroup = approvalGroup,
            ApprovalWorkflowId = workflowId,
            CurrentWorkflowStepIndex = stepIndex,
            CurrentApprovalLevelOrder = levelOrder,
        };

        foreach (var l in lines)
            plan.Lines.Add(new ContractSigningPlanLine
            {
                Id = Guid.NewGuid(),
                ContractSigningPlanId = plan.Id,
                SupplierId = Guid.NewGuid(),
                CatalogEntryId = l.CatalogId,
                TenHangMuc = l.CatalogId is null ? null : "Hạng mục seed",
                PeReferenceAmount = l.PeReference,
                ProposedAmount = l.Proposed,
                ApprovedAmount = l.Approved,
            });

        db.ContractSigningPlans.Add(plan);
        await db.SaveChangesAsync(CancellationToken.None);
        return plan.Id;
    }

    // ---- Act / Read helpers — service + handler THẬT, không mock ----

    private static async Task<ContractSigningPlanTransitionResult> ActAsync(
        KhkkGroupFixture f, TestCurrentUser actor, Guid planId, string action, string? comment = null)
    {
        await using var db = f.NewDb(actor);
        var svc = new ContractSigningPlanWorkflowService(db, actor, f.Clock, new NoOpNotificationServiceApp());
        return await svc.TransitionAsync(planId, action, comment, ct: CancellationToken.None);
    }

    private static async Task<ContractSigningPlan> ReadPlanAsync(KhkkGroupFixture f, Guid planId)
    {
        await using var db = f.NewDb(TestCurrentUser.System());
        return await db.ContractSigningPlans.AsNoTracking()
            .Include(p => p.Lines)
            .SingleAsync(p => p.Id == planId, CancellationToken.None);
    }

    private static async Task<PagedResult<ContractSigningPlanListItemDto>> ListAsync(
        KhkkGroupFixture f, TestCurrentUser actor, int? group)
    {
        await using var db = f.NewDb(actor);
        var handler = new ListContractSigningPlansQueryHandler(db, actor);
        return await handler.Handle(
            new ListContractSigningPlansQuery(ApprovalGroup: group) { Page = 1, PageSize = 50 },
            CancellationToken.None);
    }

    // ===================================================================
    // (T-a) SUBMIT-GUARD null — vá-5 spec-cụm1 (`…Service.cs:174-176`), THẮNG nền `:126`.
    //
    // Đo trên phiếu CÓ DÒNG THẬT (DoD chống PASS-bằng-tập-rỗng) và bằng CẶP CÙNG-RUỘT khác
    // đúng 1 biến: hai phiếu y hệt nhau (cùng workflow, cùng nhóm, cùng 2 dòng, cùng người
    // soạn), chỉ khác việc dòng thứ hai đã gán hạng mục hay chưa. Thiếu vế dương thì test âm
    // vẫn xanh trong cái thế giới mà submit hỏng vì lý do khác (workflow rỗng, guard quyền…).
    // ===================================================================
    [Fact]
    public async Task Submit_ConDongChuaGanHangMuc_ThiBiChan_GanDuThiTrinhQua()
    {
        using var f = new KhkkGroupFixture();
        var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        var approver = await AddUserAsync(f, "NV Cung ứng duyệt Cấp 1");
        var wf = await SeedWorkflowAsync(f, "QT-KHKK-N1", steps: [[[approver.Id]]]);
        var catalog = await AddCatalogAsync(f, approvalGroup: 1, tenVi: "Thi công phần thô");

        // ---- Ca ÂM: dòng 2 còn NULL.
        var pBad = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/001",
            ContractSigningPlanPhase.DangSoanThao, approvalGroup: 1,
            lines: [new(100m, 100m, catalog), new(200m, 200m, CatalogId: null)]);

        var beforeBad = await ReadPlanAsync(f, pBad);
        beforeBad.Lines.Should().HaveCount(2,
            "guard phải được đo trên phiếu CÓ dòng thật — phiếu rỗng đi lọt `Any(...)` nên "
            + "một test seed 0 dòng sẽ xanh mà chẳng chứng minh điều gì (DoD vá-5)");

        Func<Task> actBad = async () => await ActAsync(f, drafter, pBad, "submit");
        (await actBad.Should().ThrowAsync<ConflictException>(
                "còn dòng chưa gán hạng mục SP-002 thì chưa đủ căn cứ để trình duyệt"))
            .WithMessage("*chưa gán hạng mục*");

        var afterBad = await ReadPlanAsync(f, pBad);
        afterBad.Phase.Should().Be(ContractSigningPlanPhase.DangSoanThao,
            "bị chặn thì phiếu phải NGUYÊN Ở NHÁP — ném xong mà vẫn đổi phase còn tệ hơn không rào");
        afterBad.CurrentWorkflowStepIndex.Should().BeNull("chưa trình thì chưa có con-trỏ Bước");
        afterBad.CurrentApprovalLevelOrder.Should().BeNull("chưa trình thì chưa có con-trỏ Cấp");

        // ---- Ca DƯƠNG (đối chứng, đổi ĐÚNG 1 biến): dòng 2 đã gán.
        var pOk = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/002",
            ContractSigningPlanPhase.DangSoanThao, approvalGroup: 1,
            lines: [new(100m, 100m, catalog), new(200m, 200m, catalog)]);

        var res = await ActAsync(f, drafter, pOk, "submit");
        res.Phase.Should().Be((int)ContractSigningPlanPhase.ChoDuyet);

        var afterOk = await ReadPlanAsync(f, pOk);
        afterOk.Phase.Should().Be(ContractSigningPlanPhase.ChoDuyet,
            "cùng bộ seed đó, gán đủ hạng mục thì phải trình được — nếu vế này cũng đỏ thì "
            + "ca âm ở trên đang bắt một lỗi khác chứ không phải guard hạng mục");
        afterOk.CurrentWorkflowStepIndex.Should().Be(0);
        afterOk.CurrentApprovalLevelOrder.Should().Be(1);
    }

    // ===================================================================
    // (T-b) SUBMIT-GUARD khác nhóm — `…Service.cs:177-182` (code mẫu nền K2.d).
    //
    // 3 vế, mỗi vế bắt một lỗi khác nhau:
    //   • phiếu nhóm 1 + hạng mục nhóm 2                   → 409 (vế chính)
    //   • phiếu nhóm 1 + [hạng mục nhóm 1, hạng mục nhóm 2] → 409 (chứng vị ngữ là ANY,
    //     không phải "mọi dòng đều sai"; cài đặt bằng `All` sẽ cho lọt phiếu pha trộn)
    //   • phiếu nhóm 2 + hạng mục nhóm 2                    → QUA (chứng guard so theo nhóm
    //     CỦA PHIẾU, không phải hằng số 1)
    // ===================================================================
    [Fact]
    public async Task Submit_HangMucKhacNhomDuyetCuaPhieu_ThiBiChan_CungNhomThiQua()
    {
        using var f = new KhkkGroupFixture();
        var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        var approver = await AddUserAsync(f, "NV duyệt");
        var wf = await SeedWorkflowAsync(f, "QT-KHKK-MIX", steps: [[[approver.Id]]]);

        var catN1 = await AddCatalogAsync(f, approvalGroup: 1, tenVi: "Hạng mục nhóm 1");
        var catN2 = await AddCatalogAsync(f, approvalGroup: 2, tenVi: "Hạng mục nhóm 2");

        // ---- Vế 1: phiếu nhóm 1, dòng duy nhất thuộc nhóm 2.
        var pWrong = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/010",
            ContractSigningPlanPhase.DangSoanThao, approvalGroup: 1,
            lines: [new(100m, 100m, catN2)]);

        Func<Task> actWrong = async () => await ActAsync(f, drafter, pWrong, "submit");
        (await actWrong.Should().ThrowAsync<ConflictException>(
                "hạng mục quyết định workflow nào duyệt phiếu — lệch nhóm là trình sai hội đồng"))
            .WithMessage("*N1*");

        (await ReadPlanAsync(f, pWrong)).Phase.Should().Be(ContractSigningPlanPhase.DangSoanThao,
            "bị chặn ⇒ phiếu bất động");

        // ---- Vế 2: phiếu PHA TRỘN — 1 dòng đúng nhóm + 1 dòng sai nhóm.
        var pMixed = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/011",
            ContractSigningPlanPhase.DangSoanThao, approvalGroup: 1,
            lines: [new(100m, 100m, catN1), new(200m, 200m, catN2)]);

        Func<Task> actMixed = async () => await ActAsync(f, drafter, pMixed, "submit");
        await actMixed.Should().ThrowAsync<ConflictException>(
            "chỉ cần MỘT dòng lệch nhóm là phiếu đã sai hội đồng duyệt — cài bằng `All(...)` "
            + "thì phiếu pha trộn lọt qua mà vẫn trông như có rào");

        // ---- Vế 3 (đối chứng): phiếu nhóm 2 + hạng mục nhóm 2 ⇒ QUA.
        var pN2 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/012",
            ContractSigningPlanPhase.DangSoanThao, approvalGroup: 2,
            lines: [new(100m, 100m, catN2)]);

        await ActAsync(f, drafter, pN2, "submit");
        (await ReadPlanAsync(f, pN2)).Phase.Should().Be(ContractSigningPlanPhase.ChoDuyet,
            "hạng mục nhóm 2 trên phiếu nhóm 2 là hợp lệ — nếu vế này cũng chặn thì guard đang "
            + "so với hằng số 1 chứ không phải với nhóm của phiếu");
    }

    // ===================================================================
    // (T-c) Rào (PeId, ApprovalGroup) qua CreateHandler THẬT — `Features.cs:393-402`.
    //
    // Trước K2 rào là "1 PE = 1 phiếu" tuyệt đối; K2 nới thành 1 phiếu SỐNG MỖI NHÓM.
    // Đo 4 nước liên tiếp trên CÙNG một PE: nhóm 1 → nhóm 2 → nhóm 2 lần nữa (409) →
    // nhóm 1 lần nữa (409). Kèm phép đo phụ "KHÔNG ĐỐT MÃ": rào đứng TRƯỚC codegen
    // (`:395` vs `:413`) ⇒ 2 lần 409 không được làm nhảy số thứ tự.
    // ===================================================================
    [Fact]
    public async Task Create_CungPeCungNhom_ThiConflict_KhacNhom_ThiTaoDuoc_VaKhongDotMa()
    {
        using var f = new KhkkGroupFixture();
        var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        var seeded = await SeedPeForCreateAsync(f);

        var g1 = await CreateAsync(f, drafter, seeded.PeId, seeded.WorkflowId, approvalGroup: 1);
        g1.LineCount.Should().Be(1, "PE có đúng 1 NCC trúng thầu ⇒ 1 dòng");

        var g2 = await CreateAsync(f, drafter, seeded.PeId, seeded.WorkflowId, approvalGroup: 2);
        g2.Id.Should().NotBe(g1.Id, "cùng PE khác NHÓM = phiếu khác, không phải cập nhật phiếu cũ");
        g2.MaKeHoach.Should().NotBe(g1.MaKeHoach, "mỗi phiếu một mã");

        // ---- 409 nhóm 2 (đã có phiếu sống nhóm 2).
        Func<Task> dupG2 = async () =>
            await CreateAsync(f, drafter, seeded.PeId, seeded.WorkflowId, approvalGroup: 2);
        (await dupG2.Should().ThrowAsync<ConflictException>(
                "1 PE chỉ được 1 kế hoạch SỐNG mỗi nhóm duyệt"))
            .WithMessage("*N2*");

        // ---- 409 nhóm 1 (chứng rào không phải "chỉ chặn nhóm vừa tạo").
        Func<Task> dupG1 = async () =>
            await CreateAsync(f, drafter, seeded.PeId, seeded.WorkflowId, approvalGroup: 1);
        (await dupG1.Should().ThrowAsync<ConflictException>()).WithMessage("*N1*");

        await using var db = f.NewDb(drafter);
        db.ContractSigningPlans.Count(p => p.PurchaseEvaluationId == seeded.PeId)
            .Should().Be(2, "hai lần 409 KHÔNG được để lại phiếu nửa vời");
        db.ContractSigningPlans.Where(p => p.PurchaseEvaluationId == seeded.PeId)
            .Select(p => p.ApprovalGroup).OrderBy(x => x).ToList()
            .Should().Equal([1, 2], "hai phiếu sống thuộc hai nhóm khác nhau");

        // Rào đứng TRƯỚC codegen ⇒ số thứ tự chỉ nhảy đúng 2 lần (2 phiếu tạo được).
        db.WorkflowAppCodeSequences.Single(s => s.Prefix == "KHKK/2026").LastSeq
            .Should().Be(2, "409 xảy ra trước khi cấp mã ⇒ không đốt số; đổi thứ tự hai bước "
                + "này thì dải mã KHKK thủng lỗ mà chẳng phiếu nào mang");
    }

    // ===================================================================
    // (T-c bis) Phiếu bị TỪ CHỐI không chiếm chỗ — vế `Phase != TuChoi` của rào.
    //
    // Chính vế này là lý do BE CỐ Ý không dựng unique (PeId, ApprovalGroup) dưới DB
    // (`ContractSigningPlanConfiguration.cs:33-37`): unique không diễn tả được "trừ phiếu
    // đã từ chối". Test khoá cả hai chiều — TuChoi nhường chỗ, ChoDuyet thì KHÔNG.
    // ===================================================================
    [Fact]
    public async Task Create_PhieuNhomDaTuChoi_KhongChiemCho_PhieuDangChoDuyet_ThiChiemCho()
    {
        using var f = new KhkkGroupFixture();
        var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        var seeded = await SeedPeForCreateAsync(f);

        var first = await CreateAsync(f, drafter, seeded.PeId, seeded.WorkflowId, approvalGroup: 3);

        // Phiếu bị từ chối (kết quả của nhánh reject — ở đây set thẳng phase để cô lập rào create).
        await using (var db = f.NewDb(drafter))
        {
            var plan = await db.ContractSigningPlans.SingleAsync(p => p.Id == first.Id);
            plan.Phase = ContractSigningPlanPhase.TuChoi;
            await db.SaveChangesAsync(CancellationToken.None);
        }

        var again = await CreateAsync(f, drafter, seeded.PeId, seeded.WorkflowId, approvalGroup: 3);
        again.Id.Should().NotBe(first.Id,
            "phiếu bị từ chối là ngõ cụt ⇒ phải lập lại được cho CÙNG nhóm, bằng một phiếu MỚI");

        // ---- Vế đối chứng: phiếu vừa tạo đang SỐNG (Nháp) ⇒ nhóm 3 lại bị khoá.
        Func<Task> third = async () =>
            await CreateAsync(f, drafter, seeded.PeId, seeded.WorkflowId, approvalGroup: 3);
        (await third.Should().ThrowAsync<ConflictException>(
                "phiếu sống mới lập lại chiếm chỗ nhóm 3 — nếu vế này cũng qua thì rào đã chết hẳn "
                + "chứ không phải 'TuChoi được miễn'"))
            .WithMessage("*N3*");

        await using var check = f.NewDb(drafter);
        check.ContractSigningPlans.Count(p => p.PurchaseEvaluationId == seeded.PeId
            && p.Phase != ContractSigningPlanPhase.TuChoi)
            .Should().Be(1, "đúng MỘT phiếu sống cho nhóm 3");
    }

    // ===================================================================
    // (T-d) List lọc theo nhóm — `Features.cs:737-738`.
    //
    // Hai điều dễ hỏng, test khoá cả hai:
    //   • lọc ở CLIENT (lấy hết rồi `.Where` trên list): `Items` trông y hệt nhưng `Total`
    //     vẫn là cả thế giới ⇒ phân trang sai. Vì thế assert CẢ `Total`, không chỉ `Items`.
    //   • lọc đặt NHẦM vào trong khối IDOR (`:720-729`) ⇒ chỉ đúng cho một vai. Vì thế đo
    //     bằng CẢ non-admin (chủ phiếu) LẪN admin.
    // ===================================================================
    [Fact]
    public async Task List_LocTheoNhomDuyet_ChiTraDungNhom_VaTotalKhopFilter()
    {
        using var f = new KhkkGroupFixture();
        var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        var admin = As(await AddUserAsync(f, "Quản trị"), AppRoles.Admin);
        var wf = await SeedWorkflowAsync(f, "QT-KHKK-LIST", steps: [[[drafter.UserId!.Value]]]);
        var cat = await AddCatalogAsync(f, 1, "Hạng mục list");

        LineSeed[] one = [new(10m, 10m, cat)];
        await SeedPlanAsync(f, drafter, wf, "KHKK/2026/020", ContractSigningPlanPhase.DangSoanThao, 1, one);
        await SeedPlanAsync(f, drafter, wf, "KHKK/2026/021", ContractSigningPlanPhase.DangSoanThao, 1, one);
        await SeedPlanAsync(f, drafter, wf, "KHKK/2026/022", ContractSigningPlanPhase.DangSoanThao, 2, one);

        foreach (var (actor, vai) in new[] { (drafter, "người soạn"), (admin, "admin") })
        {
            var all = await ListAsync(f, actor, group: null);
            all.Total.Should().Be(3, $"{vai}: không lọc ⇒ thấy cả 3 phiếu (giữ nguyên hành vi trước K2)");
            all.Items.Should().HaveCount(3);

            var g2 = await ListAsync(f, actor, group: 2);
            g2.Items.Should().ContainSingle($"{vai}: chỉ 1 phiếu thuộc nhóm 2")
                .Which.ApprovalGroup.Should().Be(2, "DTO phải mang nhóm để FE gắn badge/cây folder");
            g2.Total.Should().Be(1,
                $"{vai}: `Total` là dấu vân tay của việc lọc CHẠY DƯỚI DB — lọc ở client thì "
                + "Items đúng mà Total vẫn là 3");

            var g1 = await ListAsync(f, actor, group: 1);
            g1.Total.Should().Be(2, $"{vai}: 2 phiếu nhóm 1");
            g1.Items.Should().OnlyContain(i => i.ApprovalGroup == 1);

            var g8 = await ListAsync(f, actor, group: 8);
            g8.Total.Should().Be(0, $"{vai}: nhóm chưa có phiếu ⇒ rỗng, KHÔNG rơi về 'trả hết'");
        }
    }

    // ===================================================================
    // (T-e) Σ ĐÃ CHỐT trên list — vá-8 (`Features.cs:803-804`).
    //
    // Thiếu Σ này thì màn danh sách khoe số ĐỀ XUẤT còn màn chi tiết khoá theo số ĐÃ CHỐT =
    // hai màn nói hai con số về cùng một phiếu. Seed cố ý cho Proposed ≠ Approved để một cài
    // đặt "Σ Approved = Σ Proposed" (copy nhầm dòng trên) lộ ra ngay.
    // ===================================================================
    [Fact]
    public async Task List_TongDaChot_CongTheoApprovedAmount_PhieuChuaDuyetThiBangKhong()
    {
        using var f = new KhkkGroupFixture();
        var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        var wf = await SeedWorkflowAsync(f, "QT-KHKK-SUM", steps: [[[drafter.UserId!.Value]]]);
        var cat = await AddCatalogAsync(f, 1, "Hạng mục Σ");

        // Phiếu ĐÃ DUYỆT: đề xuất 70+50=120 nhưng chốt 30+20=50 (hai số KHÁC nhau).
        var approved = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/030",
            ContractSigningPlanPhase.DaDuyet, 1,
            [new(80m, 70m, cat, Approved: 30m), new(60m, 50m, cat, Approved: 20m)]);

        // Phiếu NHÁP: có tiền đề xuất nhưng chưa chốt đồng nào.
        var draft = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/031",
            ContractSigningPlanPhase.DangSoanThao, 1,
            [new(111m, 111m, cat)]);

        // Phiếu CHỐT DỞ (1 dòng có, 1 dòng chưa) — khoá ngữ nghĩa "Σ bỏ qua dòng null".
        var partial = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/032",
            ContractSigningPlanPhase.ChoDuyet, 1,
            [new(40m, 40m, cat, Approved: 10m), new(40m, 40m, cat)]);

        var page = await ListAsync(f, drafter, group: null);

        var a = page.Items.Single(i => i.Id == approved);
        a.TotalApprovedAmount.Should().Be(50m, "Σ số ĐÃ CHỐT = 30 + 20");
        a.TotalProposedAmount.Should().Be(120m,
            "Σ đề xuất phải VẪN là 120 — hai Σ song song, không được cái này đè cái kia");
        a.TotalPeReferenceAmount.Should().Be(140m, "Σ tham chiếu PE bất biến (bài K2.e)");

        var d = page.Items.Single(i => i.Id == draft);
        d.TotalApprovedAmount.Should().Be(0m, "phiếu chưa duyệt ⇒ mọi dòng null ⇒ 0");
        d.TotalProposedAmount.Should().Be(111m,
            "và số 0 ở trên phải là do null-coalesce chứ không phải do phiếu rỗng tiền");

        var p = page.Items.Single(i => i.Id == partial);
        p.TotalApprovedAmount.Should().Be(10m,
            "Σ cộng đúng phần đã chốt, bỏ qua dòng chưa chốt — nếu ra 0 thì cài đặt đang null-hoá "
            + "cả tổng khi gặp một dòng null");
    }

    // ===================================================================
    // (T-f) Gate ghi nháp — `ContractSigningPlanScope.EnsureDraftEditable` (`Features.cs:229-237`).
    //
    // BE lane chốt (`sub-implbe-k2.md §e`): K2 KHÔNG dựng guard mồ-côi cho line-edit; ràng buộc
    // là "mọi đường ghi (kể cả Lines-editor K4b) PHẢI đi qua gate này". Test đo 2 tầng:
    //   (1) BẢN THÂN gate: ma trận 5 phase + vế quyền;
    //   (2) gate ĐÃ ĐƯỢC NỐI vào một đường ghi THẬT (`UpdateContractSigningPlanDraftCommandHandler`
    //       `:518`) và chặn được thật — chống ghost-wire (guard tồn tại nhưng không ai gọi).
    // ===================================================================
    [Fact]
    public async Task EnsureDraftEditable_ChanMoiPhaseNgoaiNhapVaTraLai_VaDaNoiVaoDuongGhiThat()
    {
        using var f = new KhkkGroupFixture();
        var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        var stranger = As(await AddUserAsync(f, "Người ngoài"));
        var admin = As(await AddUserAsync(f, "Quản trị"), AppRoles.Admin);

        // ---- (1) Ma trận phase, gọi thẳng gate (InternalsVisibleTo).
        ContractSigningPlan PlanAt(ContractSigningPlanPhase phase) => new()
        {
            Id = Guid.NewGuid(),
            DrafterUserId = drafter.UserId!.Value,
            Phase = phase,
        };

        foreach (var open in new[] { ContractSigningPlanPhase.DangSoanThao, ContractSigningPlanPhase.TraLai })
        {
            var act = () => ContractSigningPlanScope.EnsureDraftEditable(PlanAt(open), drafter);
            act.Should().NotThrow($"{open} là trạng thái người soạn đang cầm phiếu — phải sửa được");
        }

        foreach (var closed in new[]
        {
            ContractSigningPlanPhase.ChoDuyet,
            ContractSigningPlanPhase.DaDuyet,
            ContractSigningPlanPhase.TuChoi,
        })
        {
            var act = () => ContractSigningPlanScope.EnsureDraftEditable(PlanAt(closed), drafter);
            act.Should().Throw<ConflictException>(
                    $"{closed}: phiếu đã rời tay người soạn — sửa/xoá dòng lúc này làm Σ của phiếu "
                    + "đã duyệt trôi ÂM THẦM (khe rò gián tiếp duy nhất, spec vá-6)")
                .WithMessage("*Nháp hoặc Trả lại*");
        }

        // Admin vẫn bị chặn theo PHASE (vế quyền và vế phase là hai rào độc lập).
        var adminOnApproved = () =>
            ContractSigningPlanScope.EnsureDraftEditable(PlanAt(ContractSigningPlanPhase.DaDuyet), admin);
        adminOnApproved.Should().Throw<ConflictException>(
            "Admin bỏ qua vế QUYỀN chứ không bỏ qua vế PHASE — nếu Admin sửa được phiếu đã duyệt "
            + "thì số đã chốt không còn là số chốt");

        // Người ngoài bị chặn ngay ở vế quyền, kể cả phiếu đang mở.
        var strangerOnDraft = () =>
            ContractSigningPlanScope.EnsureDraftEditable(PlanAt(ContractSigningPlanPhase.DangSoanThao), stranger);
        strangerOnDraft.Should().Throw<ForbiddenException>("phiếu nháp là của người soạn (S89)");

        // ---- (2) Gate đã NỐI vào đường ghi thật.
        var wf = await SeedWorkflowAsync(f, "QT-KHKK-GATE", steps: [[[drafter.UserId!.Value]]]);
        var cat = await AddCatalogAsync(f, 1, "Hạng mục gate");
        var approvedPlan = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/040",
            ContractSigningPlanPhase.DaDuyet, 1, [new(10m, 10m, cat)]);

        async Task UpdateAsync()
        {
            await using var db = f.NewDb(drafter);
            var handler = new UpdateContractSigningPlanDraftCommandHandler(db, drafter);
            await handler.Handle(
                new UpdateContractSigningPlanDraftCommand(approvedPlan, GhiChu: "sửa sau khi duyệt"),
                CancellationToken.None);
        }

        Func<Task> actUpdate = UpdateAsync;
        await actUpdate.Should().ThrowAsync<ConflictException>(
            "gate phải chặn TẠI handler thật, không chỉ tồn tại như một hàm không ai gọi");

        await using var db2 = f.NewDb(drafter);
        (await db2.ContractSigningPlans.AsNoTracking().SingleAsync(p => p.Id == approvedPlan))
            .GhiChu.Should().BeNull("ném rồi thì KHÔNG được ghi gì xuống phiếu");
        db2.ContractSigningPlanChangelogs.Count(c => c.ContractSigningPlanId == approvedPlan)
            .Should().Be(0, "cũng không được để lại dòng nhật ký cho một lần sửa đã bị chặn");
    }

    // ===================================================================
    // (T-g) Mặc định nhóm 1 — `ContractSigningPlan.cs:39` + backfill Mig 71 (`:61`).
    //
    // Ngữ nghĩa backfill: MỌI phiếu sinh ra từ đường code cũ (không set nhóm) phải rơi vào
    // nhóm 1, khớp `UPDATE … SET ApprovalGroup = 1`. Kèm vế đối chứng "set 5 thì ra 5" —
    // thiếu vế đó, một cài đặt ép cứng 1 (default DB đè giá trị) vẫn xanh.
    // Đo luôn `EndedByLevelFinalize` (cột K2 ĐẺ, K3 tiêu thụ — ghost-window khai ở
    // `sub-implbe-k2.md §a vá-7`): tới khi K3 land, nó phải luôn false.
    // ===================================================================
    [Fact]
    public async Task Entity_MacDinhNhomMot_VaGiuNguyenKhiGanTay_ConCoKetThucSomMacDinhTat()
    {
        new ContractSigningPlan().ApprovalGroup.Should().Be(1,
            "phiếu không khai nhóm = nhóm 1, đúng ngữ nghĩa backfill Mig 71 cho dữ liệu cũ");
        new ContractSigningPlan().EndedByLevelFinalize.Should().BeFalse(
            "cột runtime của K3: từ nay tới khi K3 land KHÔNG site nào ghi ⇒ luôn false");

        using var f = new KhkkGroupFixture();
        var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));

        // Ghi phiếu KHÔNG set ApprovalGroup — đúng hình dạng code-path cũ.
        var legacyId = Guid.NewGuid();
        await using (var db = f.NewDb(drafter))
        {
            db.ContractSigningPlans.Add(new ContractSigningPlan
            {
                Id = legacyId,
                MaKeHoach = "KHKK/2026/050",
                PurchaseEvaluationId = Guid.NewGuid(),
                ProjectId = Guid.NewGuid(),
                DrafterUserId = drafter.UserId!.Value,
                Phase = ContractSigningPlanPhase.DangSoanThao,
            });
            await db.SaveChangesAsync(CancellationToken.None);
        }

        var legacy = await ReadPlanAsync(f, legacyId);
        legacy.ApprovalGroup.Should().Be(1, "round-trip qua DB vẫn là nhóm 1");
        legacy.EndedByLevelFinalize.Should().BeFalse();

        // Đối chứng: gán tay nhóm 5 ⇒ đọc lại phải là 5 (default không được đè).
        var explicitId = await SeedPlanAsync(f, drafter, workflowId: null, "KHKK/2026/051",
            ContractSigningPlanPhase.DangSoanThao, approvalGroup: 5, lines: []);
        (await ReadPlanAsync(f, explicitId)).ApprovalGroup.Should().Be(5,
            "giá trị người dùng chọn phải thắng giá trị mặc định — nếu cũng ra 1 thì cột đang bị "
            + "ép cứng và mọi phiếu nhóm 2..8 sẽ âm thầm nằm sai folder");
    }

    // ===================================================================
    // (T-h) MỌI đường tới `DaDuyet` đều đi qua chốt giá — vá-2 spec-cụm1 phương án (ii).
    //
    // `ReturnOrRejectAsync` (`…Service.cs:327-359`) gán `plan.Phase = targetPhase` QUA BIẾN —
    // đúng lớp bẫy #81-EXT (PE Mig 67 site-5). Spec chốt KHÔNG sửa site đó mà KHOÁ bằng test:
    // biến ấy chỉ nhận `TraLai`/`TuChoi`, nên không đường nào tới `DaDuyet` mà bỏ qua
    // `ApplyApprovedValuesOnFinalize`. Đo bằng CẢ BA hành động trên ba phiếu song sinh.
    // ===================================================================
    [Fact]
    public async Task Transition_ChiNhanhDuyetCuoiMoiToiDaDuyet_VaLuonKemChotGia()
    {
        using var f = new KhkkGroupFixture();
        var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        var approver = await AddUserAsync(f, "NV duyệt Cấp cuối");
        var wf = await SeedWorkflowAsync(f, "QT-KHKK-END", steps: [[[approver.Id]]]);
        var cat = await AddCatalogAsync(f, 1, "Hạng mục kết thúc");

        async Task<Guid> PendingPlanAsync(string ma) => await SeedPlanAsync(
            f, drafter, wf, ma, ContractSigningPlanPhase.ChoDuyet, 1,
            [new(500m, 400m, cat)], stepIndex: 0, levelOrder: 1);

        var pReturn = await PendingPlanAsync("KHKK/2026/060");
        var pReject = await PendingPlanAsync("KHKK/2026/061");
        var pApprove = await PendingPlanAsync("KHKK/2026/062");

        await ActAsync(f, As(approver), pReturn, "return", "thiếu hồ sơ");
        var afterReturn = await ReadPlanAsync(f, pReturn);
        afterReturn.Phase.Should().Be(ContractSigningPlanPhase.TraLai,
            "biến `targetPhase` ở nhánh trả lại chỉ nhận TraLai — ra DaDuyet ở đây là phiếu tự duyệt");
        afterReturn.Lines.Should().OnlyContain(l => l.ApprovedAmount == null,
            "trả lại KHÔNG chốt giá");

        await ActAsync(f, As(approver), pReject, "reject", "không ký loại HĐ này");
        var afterReject = await ReadPlanAsync(f, pReject);
        afterReject.Phase.Should().Be(ContractSigningPlanPhase.TuChoi);
        afterReject.Lines.Should().OnlyContain(l => l.ApprovedAmount == null, "từ chối KHÔNG chốt giá");

        // Đường DUY NHẤT tới DaDuyet — và nó phải kèm chốt giá.
        await ActAsync(f, As(approver), pApprove, "approve", "đồng ý");
        var afterApprove = await ReadPlanAsync(f, pApprove);
        afterApprove.Phase.Should().Be(ContractSigningPlanPhase.DaDuyet);
        afterApprove.Lines.Should().OnlyContain(l => l.ApprovedAmount == 400m,
            "tới DaDuyet là phải đi qua `ApplyApprovedValuesOnFinalize` ⇒ mọi dòng có số chốt "
            + "(mặc định = số đề xuất); phiếu 'Đã duyệt' mà ApprovedAmount rỗng thì GĐ3 sinh HĐ 0đ");

        // Hai phiếu kia PHẢI bất động ở đúng chỗ của chúng (không lây tác dụng phụ).
        (await ReadPlanAsync(f, pReturn)).Phase.Should().Be(ContractSigningPlanPhase.TraLai);
        (await ReadPlanAsync(f, pReject)).Phase.Should().Be(ContractSigningPlanPhase.TuChoi);
    }

    // ===================================================================
    // Seed PE + create helper (dùng cho T-c / T-c bis)
    // ===================================================================

    private sealed record SeededPe(Guid PeId, Guid WorkflowId);

    /// PE `DaDuyet`, 1 NCC trúng thầu, 1 báo giá được chọn — bộ tối thiểu để CreateHandler
    /// đi hết 4 rào (`Features.cs:377-405`) mà không vướng cái nào.
    private static async Task<SeededPe> SeedPeForCreateAsync(KhkkGroupFixture f)
    {
        var wf = await SeedWorkflowAsync(f, "QT-KHKK-CREATE", steps: []);

        await using var db = f.NewDb(TestCurrentUser.System());
        var peId = Guid.NewGuid();

        var master = new Supplier
        {
            Id = Guid.NewGuid(),
            Code = "NCC-K2",
            Name = "Nhà cung cấp K2",
            Type = SupplierType.NhaThauPhu,
        };
        db.Suppliers.Add(master);

        var row = new PurchaseEvaluationSupplier
        {
            Id = Guid.NewGuid(),          // ≠ master.Id — bẫy `w.Id` vs `w.SupplierId`
            PurchaseEvaluationId = peId,
            SupplierId = master.Id,
            Order = 0,
            IsWinner = true,
        };

        var pe = new PurchaseEvaluation
        {
            Id = peId,
            MaPhieu = "PE-K2-001",
            Type = PurchaseEvaluationType.DuyetNcc,
            Phase = PurchaseEvaluationPhase.DaDuyet,
            TenGoiThau = "Gói thầu K2",
            ProjectId = Guid.NewGuid(),
            DepartmentId = Guid.NewGuid(),
            DrafterUserId = Guid.NewGuid(),
            ApprovalWorkflowId = wf,
        };
        pe.Suppliers.Add(row);

        var detail = new PurchaseEvaluationDetail
        {
            Id = Guid.NewGuid(),
            PurchaseEvaluationId = peId,
            GroupCode = "A.I",
            GroupName = "Nhóm A",
            NoiDung = "Hạng mục 1",
            Order = 0,
            ThanhTienNganSach = 1_000_000_000m,
        };
        detail.Quotes.Add(new PurchaseEvaluationQuote
        {
            Id = Guid.NewGuid(),
            PurchaseEvaluationDetailId = detail.Id,
            PurchaseEvaluationSupplierId = row.Id,
            ThanhTien = 250m,
            IsSelected = true,
        });
        pe.Details.Add(detail);

        db.PurchaseEvaluations.Add(pe);
        await db.SaveChangesAsync(CancellationToken.None);

        return new SeededPe(peId, wf);
    }

    private static async Task<CreateContractSigningPlanResult> CreateAsync(
        KhkkGroupFixture f, TestCurrentUser actor, Guid peId, Guid workflowId, int? approvalGroup)
    {
        await using var db = f.NewDb(actor);
        var handler = new CreateContractSigningPlanCommandHandler(
            db, actor, new ContractSigningPlanCodeGenerator(db, f.Clock));
        return await handler.Handle(
            new CreateContractSigningPlanCommand(peId, workflowId, GhiChu: null, ApprovalGroup: approvalGroup),
            CancellationToken.None);
    }

    // ===================================================================
    // [K3 S164 — 2026-07-31] Rào (v) PIN-ĐÚNG-NHÓM — `ContractSigningPlanFeatures.cs:409-420`.
    //
    // 8 quy trình seed @K3 mang mã họ `KHKK-N{n}`; phiếu nhóm 3 mà pin quy trình nhóm 5 thì
    // toàn bộ dây chuyền ký (người ký, thứ tự trạm) là của LOẠI HỢP ĐỒNG KHÁC — sai im lặng,
    // chỉ lộ khi hồ sơ tới tay người không có thẩm quyền ký. Mã NGOÀI họ (legacy/chung) cố ý
    // không bị ràng buộc.
    //
    // Ba điều được đo, mỗi cái bịt một cách "xanh giả":
    //   (1) CÙNG MỘT quy trình `KHKK-N5` cho ra HAI kết quả trái ngược khi chỉ đổi nhóm của
    //       phiếu (3 → chặn, 5 → cho qua). Chỉ đo một chiều thì một rào chặn-tuốt hoặc một
    //       rào chưa-bao-giờ-chạy đều xanh.
    //   (2) Lần bị chặn KHÔNG ĐỐT MÃ: rào (v) đứng TRƯỚC `codeGen.GenerateAsync` (`:428`), mà
    //       codegen TỰ COMMIT trong transaction riêng ⇒ nếu ai đó đảo thứ tự, số thứ tự bốc hơi
    //       vĩnh viễn và mã phiếu nhảy cóc. Đo bằng chính mã của phiếu hợp lệ ĐẦU TIÊN (001).
    //   (3) Mã ngoài họ đi lọt — vế này giữ cho rào không nuốt các quy trình chung.
    // ===================================================================
    [Fact]
    public async Task Create_PinQuyTrinhKhacNhomDuyet_ThiBiChan_VaKhongDotMa()
    {
        using var f = new KhkkGroupFixture();
        var bch = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        var approver = await AddUserAsync(f, "NV duyệt");
        var pe = await SeedPeForCreateAsync(f);

        var wfN5 = await SeedWorkflowAsync(f, "KHKK-N5", steps: [[[approver.Id]]]);
        var wfN3 = await SeedWorkflowAsync(f, "KHKK-N3", steps: [[[approver.Id]]]);
        var wfLegacy = await SeedWorkflowAsync(f, "QT-KHKK-LEGACY", steps: [[[approver.Id]]]);

        // ---- (1a) Phiếu nhóm 3 pin quy trình nhóm 5 ⇒ chặn, và nói ĐÍCH DANH quy trình sai.
        var wrongGroup = async () => await CreateAsync(f, bch, pe.PeId, wfN5, approvalGroup: 3);
        var ex = await wrongGroup.Should().ThrowAsync<ConflictException>(
            "quy trình họ KHKK-N{n} chỉ dùng được cho phiếu đúng nhóm n — sai nhóm là ký sai dây chuyền");
        // [S182 W1b — spec change, KHÔNG phải nới bar] Thân rào dời sang
        // `ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync` dùng chung cho 3 cửa
        // (tạo / sửa nháp / trình), và spec chốt MỘT câu 409 duy nhất cho cả ba:
        //   "Phiếu nhóm N{g} đang gắn quy trình N{k} — vào phiếu (Nháp/Trả lại) đổi lại quy trình
        //    đúng nhóm trước khi trình."
        // Câu mới bỏ MÃ quy trình, giữ SỐ NHÓM. Không mất thông tin: rào chỉ nổ cho mã TRONG họ
        // `KHKK-N[1-8]` nên "N5" xác định duy nhất mã "KHKK-N5" (mã ngoài họ đi thẳng, vế (3)).
        // ⇒ pin cái BẤT BIẾN qua cả hai bản chữ: thông điệp phải nêu ĐỦ HAI nhóm.
        // [S182 MINOR-1] Pin NGUYÊN CỤM có thứ tự ở ĐÚNG 1 ca này (các ca khác giữ Contain 2 vế):
        // `Contain(N5) ∧ Contain(N3)` vẫn xanh nếu ai đó ĐẢO 2 đối số ở call-site
        // (`EnsureWorkflowGroupMatchAsync(db, awId, approvalGroup, ct)` — 2 tham số kề nhau,
        // 3 call-site). Cụm đầy đủ dưới đây chết ngay khi đảo ⇒ bất-biến thứ-tự có chứng nhân.
        ex.Which.Message.Should().Contain("Phiếu nhóm N3 đang gắn quy trình N5",
            "thông báo phải nêu ĐỦ và ĐÚNG CHIỀU: nhóm của phiếu (N3) trước, nhóm của quy trình "
            + "đang pin (N5) sau — đảo đối số ở bất kỳ call-site nào là câu này đổi chiều");

        // Bị chặn thì KHÔNG được để lại dấu vết nào.
        (await ReadPlanScopesAsync(f)).Should().BeEmpty("rào ném trước khi `Add` ⇒ không có phiếu nửa vời");

        // ---- (2) + (1b) Pin ĐÚNG nhóm ⇒ qua. Mã 001 chứng minh lần 409 không tiêu số thứ tự.
        var okSameGroup = await CreateAsync(f, bch, pe.PeId, wfN3, approvalGroup: 3);
        okSameGroup.MaKeHoach.Should().Be("KHKK/2026/001",
            "rào (v) đứng TRƯỚC codegen ⇒ lần bị chặn KHÔNG đốt mã; ra 002 tức là số thứ tự đã bị "
            + "tiêu cho một phiếu chưa từng tồn tại (codegen tự commit, không rollback theo request)");

        // ---- (1c) CHÍNH quy trình vừa bị chặn, nay hợp lệ vì phiếu đúng nhóm 5.
        var okOtherGroup = await CreateAsync(f, bch, pe.PeId, wfN5, approvalGroup: 5);
        okOtherGroup.MaKeHoach.Should().Be("KHKK/2026/002",
            "cùng một quy trình KHKK-N5: nhóm 3 bị chặn, nhóm 5 cho qua — hai kết quả trái ngược "
            + "chỉ khác đúng một biến, đó là bằng chứng rào SO KHỚP chứ không chặn-tuốt");

        // ---- (3) Mã ngoài họ `KHKK-N{n}` KHÔNG bị ràng buộc (quy trình chung/legacy).
        var okLegacy = await CreateAsync(f, bch, pe.PeId, wfLegacy, approvalGroup: 4);
        okLegacy.MaKeHoach.Should().Be("KHKK/2026/003",
            "quy trình ngoài họ đi thẳng — rào chỉ áp cho 8 mã nhóm seed @K3");

        // ---- Chốt bằng ĐĨA: 3 phiếu, mỗi phiếu pin đúng quy trình của nó.
        var scopes = await ReadPlanScopesAsync(f);
        scopes.Should().BeEquivalentTo(new[]
        {
            new PlanScope("KHKK/2026/001", 3, wfN3),
            new PlanScope("KHKK/2026/002", 5, wfN5),
            new PlanScope("KHKK/2026/003", 4, wfLegacy),
        }, "nhóm duyệt + quy trình phải được ghi đúng như lúc tạo — rào cho qua mà lưu sai cặp "
         + "thì phiếu vẫn chạy nhầm dây chuyền, chỉ là muộn hơn một bước");
    }

    private sealed record PlanScope(string MaKeHoach, int ApprovalGroup, Guid ApprovalWorkflowId);

    private static async Task<List<PlanScope>> ReadPlanScopesAsync(KhkkGroupFixture f)
    {
        await using var db = f.NewDb(TestCurrentUser.System());
        return await db.ContractSigningPlans.AsNoTracking()
            .OrderBy(p => p.MaKeHoach)
            .Select(p => new PlanScope(p.MaKeHoach!, p.ApprovalGroup, p.ApprovalWorkflowId!.Value))
            .ToListAsync(CancellationToken.None);
    }

    // ===================================================================
    // [S182 — vá MAJOR-3 reviewer pre-commit] Rào D6 Ở ĐƯỜNG TẠO —
    // `ContractSigningPlanFeatures.cs:507` gọi `ContractSigningPlanScope
    // .EnsureWorkflowPinnableAsync` (thân `:331-346`).
    //
    // Vì sao ca này phải tồn tại RIÊNG: D6 cắm ở HAI site (Create `:507` + UpdateDraft `:642`),
    // nhưng ca duy nhất phủ D6 — T7 `ContractSigningPlanApprovalTests.cs:1402` — gọi
    // `UpdateDraftAsync` ở CẢ BỐN chân. Tức là write-path mà MỌI phiếu KHKK đi qua đang có 0
    // răng đo: ai dời/đảo/gỡ guard khỏi Create thì cả suite vẫn xanh.
    //
    // Ba chân trên CÙNG một bộ seed. Ba quy trình khác nhau ĐÚNG ở Version + MỘT cờ; Code đều
    // là `KHKK-N1` và phiếu cũng nhóm 1 ⇒ rào (v) `:515` khớp nên IM LẶNG, mọi 409 quan sát
    // được chỉ có thể đến từ D6:
    //   (1) IsActive=false        ⇒ 409 "… đã ngừng hoạt động";
    //   (2) IsUserSelectable=false ⇒ 409 "… chưa được ghim", và THÔNG ĐIỆP PHẢI KHÁC (1) — đó
    //       đúng là lý do prod tách 2 câu (`:328-330`: hai nguyên nhân sửa ở hai nút khác nhau
    //       trong Designer). 🔴 Bản này BUỘC giữ IsActive=true, nếu không vế `!IsActive` nổ
    //       trước và chân (2) đang đo lại chân (1) — hai cờ cùng false = phép đo rỗng.
    //   (3) CONTROL DƯƠNG: bản active + đã ghim ⇒ tạo được, VÀ mã ra `001`. Hai lần 409 ở trên
    //       đứng TRƯỚC `codeGen.GenerateAsync` (`:524`), mà codegen TỰ COMMIT trong transaction
    //       riêng (`ContractSigningPlanCodeGenerator.cs:23-43`) ⇒ đảo thứ tự hai bước này là số
    //       thứ tự bốc hơi vĩnh viễn (mã sẽ ra `003`) và dải mã KHKK thủng lỗ.
    // ===================================================================
    [Fact]
    public async Task Create_PinQuyTrinhDaNgungHoacChuaGhim_ThiBiChan_VaKhongDotMa()
    {
        using var f = new KhkkGroupFixture();
        var bch = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        var approver = await AddUserAsync(f, "NV duyệt");
        var pe = await SeedPeForCreateAsync(f);

        var dangDung = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]);
        var daNghi = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]],
            version: 2, isActive: false);
        // IsActive VẪN true — xem ghi chú chân (2) ở khối trên.
        var chuaGhim = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]],
            version: 3, isUserSelectable: false);

        // ---- (1) Pin bản ĐÃ NGƯNG HOẠT ĐỘNG.
        var actNghi = async () => await CreateAsync(f, bch, pe.PeId, daNghi, approvalGroup: 1);
        var exNghi = await actNghi.Should().ThrowAsync<ConflictException>(
            "'tạo phiên bản mới' hạ IsActive bản cũ; pin được bản đã nghỉ thì phiếu chạy dàn "
            + "người duyệt của một phiên bản không còn hiệu lực — sai IM LẶNG, chỉ lộ khi hồ sơ "
            + "tới tay người đã bị thay trong bản mới");
        exNghi.Which.Message.Should().Contain("ngừng hoạt động");

        // ---- (2) Pin bản CHƯA GHIM cho người dùng chọn.
        var actGhim = async () => await CreateAsync(f, bch, pe.PeId, chuaGhim, approvalGroup: 1);
        var exGhim = await actGhim.Should().ThrowAsync<ConflictException>(
            "FE chỉ hiện quy trình active+ghim, nhưng POST thẳng thì bỏ qua FE ⇒ cờ ghim phải "
            + "có răng ở BE, không thì nút Ghim/Bỏ ghim của Admin chỉ là trang trí");
        exGhim.Which.Message.Should().Contain("ghim");

        exGhim.Which.Message.Should().NotBe(exNghi.Which.Message,
            "hai nguyên nhân sửa ở HAI NÚT khác nhau trong Designer ⇒ phải hai câu khác nhau; "
            + "gộp thành một câu `!IsActive || !IsUserSelectable` là đẩy admin đi mò");

        // ---- Bị chặn thì KHÔNG để lại gì: không phiếu nửa vời, và mã CHƯA TỪNG được cấp.
        (await ReadPlanScopesAsync(f)).Should().BeEmpty(
            "D6 ném trước `db.ContractSigningPlans.Add` (`:577`) ⇒ không phiếu nào ra đời");
        await using (var probe = f.NewDb(bch))
        {
            probe.WorkflowAppCodeSequences.Any(s => s.Prefix == "KHKK/2026").Should().BeFalse(
                "hai lần 409 xảy ra TRƯỚC codegen ⇒ hàng sequence còn chưa được tạo lần nào");
        }

        // ---- (3) CONTROL DƯƠNG: cùng bộ seed, chỉ đổi trạng thái 2 cờ của quy trình đích.
        var ok = await CreateAsync(f, bch, pe.PeId, dangDung, approvalGroup: 1);
        ok.MaKeHoach.Should().Be("KHKK/2026/001",
            "ra 002/003 tức là hai lần bị chặn đã TIÊU số thứ tự cho phiếu chưa từng tồn tại — "
            + "codegen tự commit nên số đó mất vĩnh viễn, không rollback theo request");
        ok.LineCount.Should().Be(1, "PE seed có đúng 1 NCC trúng thầu ⇒ 1 dòng");

        (await ReadPlanScopesAsync(f)).Should().BeEquivalentTo(new[]
        {
            new PlanScope("KHKK/2026/001", 1, dangDung),
        }, "đúng MỘT phiếu ra đời và pin đúng bản đang dùng — nếu D6 chặn-tuốt thì chân này đỏ, "
         + "còn nếu D6 bị gỡ khỏi Create thì hai chân trên xanh giả; cần cả ba mới phân biệt được");

        await using var db = f.NewDb(bch);
        db.WorkflowAppCodeSequences.Single(s => s.Prefix == "KHKK/2026").LastSeq.Should().Be(1,
            "đúng một lần cấp mã cho đúng một phiếu");
    }

    // ===================================================================
    // [S168 K8-blocker] Lines-editor `UpdateContractSigningPlanLineCommand` — mảnh comment
    // auto-gen HẸN cho K4b nhưng chưa từng land, dry-run K8 bắt: submit-guard vá-5 chặn
    // line null mà 0 đường gán ⇒ phiếu mới kẹt Nháp vĩnh viễn. 3 phép đo: DƯƠNG đi trọn
    // (gán → submit QUA — nối thẳng vào guard thật, không đo gán suông) · ÂM sai nhóm ·
    // ÂM sai phase. Handler THẬT, không mock (khuôn Act-helpers file này).
    // ===================================================================

    private static async Task AssignLineAsync(
        KhkkGroupFixture f, TestCurrentUser actor, Guid planId, Guid lineId, Guid catalogId)
    {
        await using var db = f.NewDb(actor);
        var handler = new UpdateContractSigningPlanLineCommandHandler(db, actor);
        await handler.Handle(
            new UpdateContractSigningPlanLineCommand(planId, lineId, catalogId), CancellationToken.None);
    }

    [Fact]
    public async Task GanHangMuc_DungNhom_SnapshotTen_RoiTrinhQuaGuard()
    {
        using var f = new KhkkGroupFixture();
        var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        var approver = await AddUserAsync(f, "NV duyệt Cấp 1");
        var wf = await SeedWorkflowAsync(f, "QT-KHKK-LINE-A", steps: [[[approver.Id]]]);
        var catalog = await AddCatalogAsync(f, approvalGroup: 4, tenVi: "Xi măng");
        var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/101",
            ContractSigningPlanPhase.DangSoanThao, approvalGroup: 4,
            lines: [new LineSeed(100m, 100m, CatalogId: null)]);

        var lineId = (await ReadPlanAsync(f, planId)).Lines.Single().Id;
        await AssignLineAsync(f, drafter, planId, lineId, catalog);

        var line = (await ReadPlanAsync(f, planId)).Lines.Single();
        line.CatalogEntryId.Should().Be(catalog);
        line.TenHangMuc.Should().Be("Xi măng",
            "tên hạng mục phải denorm TẠI THỜI ĐIỂM GÁN (khuôn SignedByFullName Mig 26)");

        // Nối trọn vào submit-guard THẬT: gán xong thì trình phải QUA (ca dương của vá-5,
        // chống thế giới nơi gán ăn nhưng submit vẫn đỏ vì lý do khác).
        await ActAsync(f, drafter, planId, "submit");
        (await ReadPlanAsync(f, planId)).Phase.Should().Be(ContractSigningPlanPhase.ChoDuyet);
    }

    [Fact]
    public async Task GanHangMuc_KhacNhomDuyet_BiChan409_LineGiuNguyen()
    {
        using var f = new KhkkGroupFixture();
        var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        // ApproverUserId cần ROW Users THẬT (FK Restrict — ghi chú fixture đầu file).
        var approver = await AddUserAsync(f, "NV duyệt Cấp 1");
        var wf = await SeedWorkflowAsync(f, "QT-KHKK-LINE-B", steps: [[[approver.Id]]]);
        var catalogN2 = await AddCatalogAsync(f, approvalGroup: 2, tenVi: "Nhân công cốp pha");
        var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/102",
            ContractSigningPlanPhase.DangSoanThao, approvalGroup: 4,
            lines: [new LineSeed(100m, 100m, CatalogId: null)]);
        var lineId = (await ReadPlanAsync(f, planId)).Lines.Single().Id;

        var act = () => AssignLineAsync(f, drafter, planId, lineId, catalogN2);
        await act.Should().ThrowAsync<ConflictException>(
            "hạng mục nhóm N2 không được gán vào phiếu nhóm N4 — fail-fast tầng-gán, "
            + "submit-guard chỉ là chốt cuối");

        (await ReadPlanAsync(f, planId)).Lines.Single().CatalogEntryId.Should().BeNull(
            "gán fail thì line phải GIỮ NGUYÊN — không ghi dở");
    }

    [Fact]
    public async Task GanHangMuc_EntryNgungHieuLuc_BiChan409()
    {
        using var f = new KhkkGroupFixture();
        var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        var approver = await AddUserAsync(f, "NV duyệt Cấp 1");
        var wf = await SeedWorkflowAsync(f, "QT-KHKK-LINE-D", steps: [[[approver.Id]]]);
        // Entry ĐÚNG nhóm nhưng IsActive=false — nhánh guard còn lại (gate-lineeditor F-4).
        Guid inactive;
        {
            await using var db = f.NewDb(TestCurrentUser.System());
            var entry = new ContractCatalogEntry
            {
                Id = Guid.NewGuid(), Code = "K2-" + Guid.NewGuid().ToString("N")[..8],
                TenVi = "Hạng mục đã ngừng", GroupCode = "B1", ApprovalGroup = 4,
                SignerRole = "TP.PRO", IsActive = false, SortOrder = 1,
            };
            db.ContractCatalogEntries.Add(entry);
            await db.SaveChangesAsync(CancellationToken.None);
            inactive = entry.Id;
        }
        var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/104",
            ContractSigningPlanPhase.DangSoanThao, approvalGroup: 4,
            lines: [new LineSeed(100m, 100m, CatalogId: null)]);
        var lineId = (await ReadPlanAsync(f, planId)).Lines.Single().Id;

        var act = () => AssignLineAsync(f, drafter, planId, lineId, inactive);
        await act.Should().ThrowAsync<ConflictException>(
            "entry IsActive=false phải bị chặn dù ĐÚNG nhóm — nhánh guard độc lập với nhánh khác-nhóm");
    }

    [Fact]
    public async Task GanHangMuc_GanDe_DoiCaIdLanTenSnapshot()
    {
        using var f = new KhkkGroupFixture();
        var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        var approver = await AddUserAsync(f, "NV duyệt Cấp 1");
        var wf = await SeedWorkflowAsync(f, "QT-KHKK-LINE-E", steps: [[[approver.Id]]]);
        var catA = await AddCatalogAsync(f, approvalGroup: 4, tenVi: "Xi măng");
        var catB = await AddCatalogAsync(f, approvalGroup: 4, tenVi: "Thép hình");
        var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/105",
            ContractSigningPlanPhase.DangSoanThao, approvalGroup: 4,
            lines: [new LineSeed(100m, 100m, CatalogId: null)]);
        var lineId = (await ReadPlanAsync(f, planId)).Lines.Single().Id;

        await AssignLineAsync(f, drafter, planId, lineId, catA);
        // Gán ĐÈ là đường hợp lệ (người soạn chọn nhầm rồi chọn lại) — cả Id lẫn snapshot đổi theo.
        await AssignLineAsync(f, drafter, planId, lineId, catB);

        var line = (await ReadPlanAsync(f, planId)).Lines.Single();
        line.CatalogEntryId.Should().Be(catB);
        line.TenHangMuc.Should().Be("Thép hình", "snapshot phải đổi theo lần gán CUỐI");
    }

    [Fact]
    public async Task GanHangMuc_PhieuDaTrinh_BiChanPhase()
    {
        using var f = new KhkkGroupFixture();
        var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        // ApproverUserId cần ROW Users THẬT (FK Restrict — ghi chú fixture đầu file).
        var approver = await AddUserAsync(f, "NV duyệt Cấp 1");
        var wf = await SeedWorkflowAsync(f, "QT-KHKK-LINE-C", steps: [[[approver.Id]]]);
        var catalog = await AddCatalogAsync(f, approvalGroup: 4, tenVi: "Thép hình");
        var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/103",
            ContractSigningPlanPhase.ChoDuyet, approvalGroup: 4,
            lines: [new LineSeed(100m, 100m, CatalogId: null)],
            stepIndex: 0, levelOrder: 1);
        var lineId = (await ReadPlanAsync(f, planId)).Lines.Single().Id;

        var act = () => AssignLineAsync(f, drafter, planId, lineId, catalog);
        await act.Should().ThrowAsync<ConflictException>(
            "phiếu đã trình thì dòng bất biến — chỉ Nháp/Trả lại sửa được (EnsureDraftEditable)");
    }
}
