using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.ApprovalWorkflowsV2;    // [K3 S164] CreateAwDefinitionCommand — test giữ-cờ
using SolutionErp.Application.Common.Interfaces;      // ICurrentUser — bơm cho AuditingInterceptor
using SolutionErp.Application.ContractSigningPlans;   // GetMyContractSigningPlanInboxQuery (W2, đã sống)
using SolutionErp.Application.ContractSigningPlans.Services;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.ContractSigningPlans;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master.Catalogs;             // [K2 S164] fixture gán hạng mục cho line trước trình
using SolutionErp.Infrastructure.Persistence;
using SolutionErp.Infrastructure.Persistence.Interceptors;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;

namespace SolutionErp.Infrastructure.Tests.Application;

// [W3 KHKK — S161 2026-07-29] Duyệt 3 trạm phiếu "Kế hoạch ký kết HĐ" (GĐ2) — **TEST-BEFORE**.
//
// 🔴 ĐỢT NÀY 5/6 TEST PHẢI ĐỎ. Service mới chỉ là skeleton: mọi nhánh ném
// `NotImplementedException("W3 đợt-3: <action>")` (`ContractSigningPlanWorkflowService.cs:108/113/117/121`).
// Đỏ-vì-CHƯA-LÀM (không phải đỏ-vì-không-biên-dịch-được) chính là bằng chứng duy nhất
// rằng 6 lưới này có răng — đợt-3 điền logic xong chúng phải TỰ xanh, không được sửa test
// cho vừa. Test số 4 (Inbox) đi qua query handler W2 đã sống ⇒ **xanh ngay hôm nay**, cố ý
// không ép đỏ; nó đồng thời là phép thử "seed có hợp lệ không" cho 5 test còn lại (cùng
// bộ helper: nếu FK/So-sánh seed sai thì test 4 đỏ trước tiên).
//
// Tên 6 test PIN theo spec W3 §③-B (`spec-wave-w3-duyet-3-tram-29-07-2026.md:35`) — thiếu 1
// tên là TRƯỢT acceptance, KHÔNG đổi chữ.
//
// ── Đo cái gì (map 1:1 spec §③-B) ──────────────────────────────────────────────
//   T1 OR-of-N trọn 4 vế (a)(b)(c)(d)   T2 con-trỏ ĐÔI + reset Cấp
//   T3 finalize `AND PlanId=@id`, 2 write-path   T4 Inbox đúng người đúng Cấp
//   T5 UPSERT ý kiến 1 row   T6 Trả lại → gửi lại chạy LẠI từ Bước 1 Cấp 1
//
// ── 3 quyết định hạ tầng (đều là ĐIỀU KIỆN để phép đo có nghĩa) ────────────────
//
// (1) Fixture RIÊNG có `AuditingInterceptor` (mirror `ContractSigningPlanCrudTests.cs:49-82`).
//     Guard trình đợt-3 = `CreatedBy == actor ∨ DeptManager ∨ Admin`
//     (`ContractSigningPlanWorkflowService.cs:58-59`). `TestApplicationDbContext` trần KHÔNG
//     wire interceptor ⇒ `CreatedBy` của MỌI phiếu seed sẽ là null ⇒ vế đầu của guard không
//     bao giờ đúng và `submit` đỏ vì lý do SAI. Seed phiếu bằng context của chính người soạn
//     ⇒ `CreatedBy` == `DrafterUserId` (đợt-3 đọc cột nào cũng ra cùng một người).
//
// (2) MỖI transition chạy trên MỘT DbContext MỚI (mô phỏng scoped-per-request của prod).
//     Dùng lại 1 context cho cả seed lẫn lệnh sẽ để EF track sẵn Lines/LevelOpinions —
//     một đường đi KHÔNG có thật ở prod (ở đó context mới chỉ nạp thứ service tự Include).
//
// (3) User seed THẲNG `db.Users.Add(...)`, KHÔNG kéo `IdentityFixture`/`UserManager`:
//     service nhận role qua `ICurrentUser.Roles` (lane BE QUYẾT-2 cố ý không inject
//     UserManager), còn `ApprovalWorkflowLevel.ApproverUserId` chỉ cần **row tồn tại**
//     (FK Restrict — `ApprovalWorkflowConfiguration.cs:72-75`), không cần đăng nhập được.
public class ContractSigningPlanApprovalTests
{
    private static readonly DateTime Clock2026 = new(2026, 7, 29, 3, 0, 0, DateTimeKind.Utc);

    // ===================================================================
    // Fixture: SQLite in-memory + AuditingInterceptor, 1 connection dùng chung.
    // ===================================================================
    private sealed class KhkkWorkflowFixture : IDisposable
    {
        private readonly SqliteConnection _conn;
        public FixedDateTime Clock { get; } = new(Clock2026);

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

        /// Context MỚI cho mỗi "request". `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 service,
        /// tách hai ra thì `CreatedBy`/`UpdatedBy` ghi ra người sai *theo kiểu trông như đúng*.
        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);

    /// Row `AspNetUsers` tối thiểu nhưng ĐỦ THẬT: FK `Level.ApproverUserId` Restrict cần
    /// row tồn tại, còn `SignedByFullName` (snapshot người ký) cần `FullName` khác rỗng.
    private static async Task<User> AddUserAsync(KhkkWorkflowFixture 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;
    }

    // `steps[i][j]` = danh sách NV của **Cấp j+1** trong **Bước i+1**. Nhiều NV trong cùng một
    // Cấp = OR-of-N (`ApprovalWorkflow.cs:85-88`).
    //
    // `stepOrderStart` / `stepOrderStride`: CỐ Ý cho phép `Step.Order` KHÔNG bắt đầu từ 1 và
    // không liền nhau. `CurrentWorkflowStepIndex` là **INDEX vào list đã sort theo Order**, KHÔNG
    // phải giá trị `Order` — mã W2 đã chốt semantic đó (`ContractSigningPlanFeatures.cs:256-260`
    // `steps[idx]`). T2 dùng Order 5/15/25 để hai đại lượng này không thể trùng nhau do may mắn.
    private static async Task<Guid> SeedWorkflowAsync(
        KhkkWorkflowFixture f,
        string code,
        Guid[][][] steps,
        int stepOrderStart = 1,
        int stepOrderStride = 1,
        // [S182 W1 T7] 3 tham số đuôi cho rào D6 (pin-check IsActive ∧ IsUserSelectable) và
        // cho việc dựng 2 version CÙNG Code (UNIQUE là (Code, Version)). Optional trailing ⇒
        // mọi call-site cũ giữ nguyên chữ.
        int version = 1,
        bool isActive = true,
        bool isUserSelectable = true)
    {
        await using var db = f.NewDb(TestCurrentUser.System());

        var wf = new ApprovalWorkflow
        {
            Id = Guid.NewGuid(),
            Code = code,
            Version = version,
            Name = "Quy trình duyệt Kế hoạch ký kết",
            // Loại phải là ContractSigningPlan(10) — quy trình module khác bị rào Create
            // của W2 chặn (`ContractSigningPlanFeatures.cs:218-219`).
            ApplicableType = ApprovalWorkflowApplicableType.ContractSigningPlan,
            IsActive = 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 = stepOrderStart + (s * stepOrderStride),
                Name = $"Bước {s + 1}",
                DepartmentId = null,     // null = bỏ qua FK phòng ban (khuôn 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,                    // Cấp 1-based
                        Name = $"Cấp {l + 1}",
                        ApproverUserId = approverId,
                        // 5 cờ Allow* GIỮ MẶC ĐỊNH: W3 default dry-run = đường THƯỜNG duy nhất,
                        // KHÔNG bật kết-thúc-sớm (spec §②-3b — chủ dự án phán "PHÁ VỠ" @S155).
                    });
                }
            }
        }

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

    private sealed record LineSeed(decimal PeReference, decimal Proposed, decimal? Approved = null);

    private static async Task<Guid> SeedPlanAsync(
        KhkkWorkflowFixture f,
        TestCurrentUser drafter,
        Guid workflowId,
        string maKeHoach,
        ContractSigningPlanPhase phase,
        int? stepIndex,
        int? levelOrder,
        LineSeed[] lines,
        // [S182 W1 T4-T7] Nhóm duyệt của phiếu. Hạng mục fixture ĐI THEO nhóm này (cùng giá trị)
        // ⇒ rào hạng mục `…WorkflowService.cs:182-187` luôn cho qua, và mọi 409 quan sát được
        // trong T5 chỉ có thể đến từ rào NHÓM-QUY-TRÌNH. Trailing optional ⇒ 12 call-site cũ giữ nguyên.
        int approvalGroup = 1)
    {
        // Seed dưới danh nghĩa NGƯỜI SOẠN ⇒ interceptor ghi `CreatedBy = drafter` (xem ghi chú (1)).
        await using var db = f.NewDb(drafter);

        // [K2 S164 — vá-5] SubmitAsync nay CHẶN dòng chưa gán hạng mục + hạng mục khác nhóm.
        // Fixture gán MỌI line vào 1 hạng mục CÙNG NHÓM với phiếu — mirror phiếu "đã gán đủ"
        // trước trình; Code random né unique-filtered giữa các plan.
        // 🔴 [S182 W1] Hạng mục ĐI THEO `approvalGroup` của phiếu (không còn hằng 1): T5 seed
        // phiếu nhóm 3, nếu hạng mục vẫn nhóm 1 thì `SubmitAsync` ném 409 của rào HẠNG MỤC
        // (`ContractSigningPlanWorkflowService.cs:182-187`) và test sẽ "xanh vì nhầm cửa" —
        // đo đúng chữ 409 nhưng sai hoàn toàn cơ chế.
        var catalog = new ContractCatalogEntry
        {
            Id = Guid.NewGuid(),
            Code = "T-" + Guid.NewGuid().ToString("N")[..8],
            TenVi = "Hạng mục test W3",
            GroupCode = "B1",
            ApprovalGroup = approvalGroup,
            SignerRole = "TP.PRO",
            IsActive = true,
            SortOrder = 1,
        };
        db.ContractCatalogEntries.Add(catalog);

        var plan = new ContractSigningPlan
        {
            Id = Guid.NewGuid(),
            MaKeHoach = maKeHoach,
            // 4 cột dưới là loose-Guid KHÔNG FK vật lý (`ContractSigningPlanConfiguration.cs:10-12`)
            // ⇒ W3 không phải seed PE/Project/Department như W2.
            PurchaseEvaluationId = Guid.NewGuid(),
            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(),      // loose-Guid (`ContractSigningPlanLine.cs:15`)
                CatalogEntryId = catalog.Id,      // [K2] đã gán hạng mục ⇒ qua submit-guard
                TenHangMuc = catalog.TenVi,
                PeReferenceAmount = l.PeReference,
                ProposedAmount = l.Proposed,
                ApprovedAmount = l.Approved,
            });
        }

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

    // ===================================================================
    // [gate-K2 F-1] Submit-guard phải hỏi "MỌI hạng mục resolve CÒN SỐNG + đúng nhóm".
    // Hạng mục xoá mềm biến khỏi query (filter !IsDeleted) — guard kiểu Any(sai-nhóm)
    // đọc vắng-mặt thành sạch và cho TRÌNH QUA. Test khoá: đếm-so-khớp phải CHẶN.
    // ===================================================================
    [Fact]
    public async Task Submit_HangMucDaXoaMem_ThiBiChan()
    {
        using var f = new KhkkWorkflowFixture();
        var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        var a = await AddUserAsync(f, "PMH cấp 1");
        var wf = await SeedWorkflowAsync(f, "QT-KHKK-F1", steps: [[[a.Id]]]);
        var planId = await SeedPlanAsync(f, drafter, wf, "KH-F1", ContractSigningPlanPhase.DangSoanThao,
            null, null, [new LineSeed(100m, 90m)]);

        // Xoá mềm hạng mục line đang trỏ (app-layer set cờ — mirror khuôn SchemaTests:
        // TestApplicationDbContext không wire AuditingInterceptor cho Remove()).
        await using (var db = f.NewDb(TestCurrentUser.System()))
        {
            var catIds = await db.ContractSigningPlanLines
                .Where(l => l.ContractSigningPlanId == planId)
                .Select(l => l.CatalogEntryId!.Value).Distinct().ToListAsync(CancellationToken.None);
            var cats = await db.ContractCatalogEntries.Where(c => catIds.Contains(c.Id))
                .ToListAsync(CancellationToken.None);
            foreach (var c in cats) { c.IsDeleted = true; c.DeletedAt = DateTime.UtcNow; }
            await db.SaveChangesAsync(CancellationToken.None);
        }

        var act = async () => await ActAsync(f, drafter, planId, "submit");
        await act.Should().ThrowAsync<ConflictException>(
            "hạng mục đã xoá mềm không resolve được — guard đếm-so-khớp phải chặn trình");
    }

    // ===================================================================
    // [gate-K2 F-6] Khoá TÊN THAM SỐ HTTP của bộ lọc nhóm: controller bind `?group=`
    // (một chỗ nền viết `?approvalGroup=` — gõ theo đó là lọc câm trả tất, HTTP 200).
    // Reflection giữ tên ở tầng API để drift kêu trong CI thay vì sai im lặng trên prod.
    // ===================================================================
    [Fact]
    public void List_ThamSoLocNhom_BindDungTenGroup()
    {
        var p = typeof(SolutionErp.Api.Controllers.ContractSigningPlansController)
            .GetMethod("List")!.GetParameters().Single(x => x.Name == "group");
        var attr = (Microsoft.AspNetCore.Mvc.FromQueryAttribute)p
            .GetCustomAttributes(typeof(Microsoft.AspNetCore.Mvc.FromQueryAttribute), false).Single();
        attr.Name.Should().Be("group",
            "controller expose ?group= (spec-tổng K2) — đổi tên là vỡ FE K4b lẫn acceptance K8");
    }

    // ===================================================================
    // Act / Read helpers — gọi service THẬT, KHÔNG mock.
    // ===================================================================

    private static async Task<ContractSigningPlanTransitionResult> ActAsync(
        KhkkWorkflowFixture 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(KhkkWorkflowFixture 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<List<ContractSigningPlanLevelOpinion>> ReadOpinionsAsync(
        KhkkWorkflowFixture f, Guid planId)
    {
        await using var db = f.NewDb(TestCurrentUser.System());
        // Lọc `WHERE PlanId = @id` ĐÚNG như acceptance §③-B yêu cầu cho Lines: nếu đợt-3
        // quên vế này ở bất kỳ đâu thì phiếu hàng xóm sẽ dính, và các assert "phiếu chứng
        // nhân bất động" bên dưới bắt được.
        return await db.ContractSigningPlanLevelOpinions.AsNoTracking()
            .Where(o => o.ContractSigningPlanId == planId)
            .ToListAsync(CancellationToken.None);
    }

    private static async Task<Guid> LevelIdOfAsync(KhkkWorkflowFixture f, Guid approverUserId)
    {
        await using var db = f.NewDb(TestCurrentUser.System());
        return await db.ApprovalWorkflowLevels.AsNoTracking()
            .Where(l => l.ApproverUserId == approverUserId)
            .Select(l => l.Id)
            .SingleAsync(CancellationToken.None);
    }

    private static async Task<List<ContractSigningPlanListItemDto>> InboxAsync(
        KhkkWorkflowFixture f, TestCurrentUser actor)
    {
        await using var db = f.NewDb(actor);
        var handler = new GetMyContractSigningPlanInboxQueryHandler(db, actor);
        return await handler.Handle(new GetMyContractSigningPlanInboxQuery(), CancellationToken.None);
    }

    /// Con-trỏ ĐÔI + phase là MỘT bộ ba — kiểm cả ba cùng lúc để không có chuyện
    /// "phase đúng mà con-trỏ trôi" lọt lưới.
    private static void ShouldBeAt(
        ContractSigningPlan plan, ContractSigningPlanPhase phase, int? stepIndex, int? levelOrder, string because)
    {
        plan.Phase.Should().Be(phase, because);
        plan.CurrentWorkflowStepIndex.Should().Be(stepIndex, because);
        plan.CurrentApprovalLevelOrder.Should().Be(levelOrder, because);
    }

    /// 3 field response (FE đọc để vẽ lại panel ngay, không refetch) phải KHỚP DB.
    /// Lệch = FE hiển thị một trạng thái không tồn tại cho tới lần F5 kế.
    private static void ResultShouldMirror(ContractSigningPlanTransitionResult r, ContractSigningPlan plan)
    {
        r.Phase.Should().Be((int)plan.Phase, "response `phase` là int (hợp-đồng literal) và phải bằng phase đã ghi DB");
        r.CurrentWorkflowStepIndex.Should().Be(plan.CurrentWorkflowStepIndex, "response phải soi đúng con-trỏ Bước trong DB");
        r.CurrentApprovalLevelOrder.Should().Be(plan.CurrentApprovalLevelOrder, "response phải soi đúng con-trỏ Cấp trong DB");
    }

    // ===================================================================
    // (T1) OR-of-N — acceptance #1 TRỌN 4 VẾ (a)(b)(c)(d), spec §③-B `:28`.
    //
    // Đây là lưới DUY NHẤT bắt được lỗi chết người của wave này: copy nhầm khuôn
    // `ProposalFeatures` (flatten `SelectMany` các Level) làm OR-of-N biến thành AND **ÂM
    // THẦM** — build sạch, 574 test cũ vẫn xanh, chỉ vỡ khi phiếu thật treo giữa chừng vì
    // chờ đủ chữ ký. Vì thế:
    //   (a) 2 phiếu SONG SINH cùng quy trình: phiếu-1 do A duyệt, phiếu-2 do B duyệt;
    //   (d) mỗi phiếu tiến với ĐÚNG MỘT chữ ký — nếu đòi cả hai thì con-trỏ đứng im ở Cấp 1;
    //   (c) mỗi phiếu ghi ĐÚNG 1 dòng ý kiến, và dòng đó trỏ Level của CHÍNH người ký;
    //   (b) ca ÂM 2 lớp: người sai Cấp (C, Cấp 2 chưa tới lượt) và người NGOÀI quy trình (D).
    // ===================================================================
    [Fact]
    public async Task ApproveV2_OneApproverInLevel_Advances_WithoutRequiringBothSignatures()
    {
        using var f = new KhkkWorkflowFixture();
        var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        var a = await AddUserAsync(f, "NV Cung ứng A");
        var b = await AddUserAsync(f, "NV Cung ứng B");
        var c = await AddUserAsync(f, "Trưởng phòng C");
        var outsider = await AddUserAsync(f, "Kế toán ngoài quy trình D");

        // 1 Bước — Cấp 1 = {A, B} (OR-of-N), Cấp 2 = {C}.
        var wf = await SeedWorkflowAsync(f, "QT-KHKK-ORN", steps:
        [
            [[a.Id, b.Id], [c.Id]],
        ]);

        LineSeed[] oneLine = [new(PeReference: 999m, Proposed: 111m)];
        var p1 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/001", ContractSigningPlanPhase.ChoDuyet, 0, 1, oneLine);
        var p2 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/002", ContractSigningPlanPhase.ChoDuyet, 0, 1, oneLine);
        var p3 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/003", ContractSigningPlanPhase.ChoDuyet, 0, 1, oneLine);

        // Sanity TRƯỚC khi gọi service: seed đặt đúng lượt Cấp 1 Bước 1. Thiếu chốt này thì
        // một seed hỏng (con-trỏ null) vẫn cho ra assert xanh ở thế giới sai.
        ShouldBeAt(await ReadPlanAsync(f, p1), ContractSigningPlanPhase.ChoDuyet, 0, 1,
            "seed phải đặt phiếu ĐÚNG lượt Cấp 1 Bước 1 — sai seed thì test đo chính nó");

        // ---- (a) + (d): A duyệt phiếu-1, B duyệt phiếu-2. Cả hai đều TIẾN.
        await ActAsync(f, As(a), p1, "approve", "A đồng ý đơn giá");
        var after1 = await ReadPlanAsync(f, p1);
        ShouldBeAt(after1, ContractSigningPlanPhase.ChoDuyet, 0, 2,
            "MỘT người trong Cấp 1 duyệt là Cấp 1 xong ⇒ con-trỏ sang Cấp 2 CÙNG Bước; "
            + "nếu quy trình đòi đủ cả A lẫn B (AND) thì con-trỏ còn đứng ở Cấp 1");

        await ActAsync(f, As(b), p2, "approve", "B đồng ý đơn giá");
        ShouldBeAt(await ReadPlanAsync(f, p2), ContractSigningPlanPhase.ChoDuyet, 0, 2,
            "phiếu song sinh: người CÒN LẠI của cùng Cấp duyệt cũng phải tiến y hệt — "
            + "chỉ 1 trong 2 người tiến được là dấu hiệu match cứng theo NV đầu danh sách");

        // Chưa phải cấp cuối ⇒ TUYỆT ĐỐI chưa được chốt giá.
        after1.Lines.Should().OnlyContain(l => l.ApprovedAmount == null,
            "giá chốt CHỈ sinh tại cấp cuối cùng — chốt sớm là ký thay người chưa duyệt");

        // ---- (c) ĐÚNG 1 dòng ý kiến / phiếu, trỏ Level của CHÍNH người ký.
        var op1 = await ReadOpinionsAsync(f, p1);
        op1.Should().ContainSingle("mỗi lượt duyệt ghi đúng 1 dòng ý kiến cho Cấp đó (UNIQUE PlanId × LevelId)");
        op1[0].ApprovalWorkflowLevelId.Should().Be(await LevelIdOfAsync(f, a.Id),
            "ý kiến phải gắn vào row Level CỦA NGƯỜI KÝ; gắn nhầm row của B thì màn Ý kiến cấp duyệt "
            + "hiện chữ ký treo dưới tên người chưa hề bấm nút");
        op1[0].SignedByUserId.Should().Be(a.Id);

        var op2 = await ReadOpinionsAsync(f, p2);
        op2.Should().ContainSingle();
        op2[0].ApprovalWorkflowLevelId.Should().Be(await LevelIdOfAsync(f, b.Id),
            "hai phiếu song sinh ký bởi hai người khác nhau ⇒ hai LevelId khác nhau");
        op2[0].SignedByUserId.Should().Be(b.Id);

        // ---- (b) ca ÂM lớp 1: C có chân trong quy trình nhưng Ở CẤP KHÁC, chưa tới lượt.
        Func<Task> actWrongLevel = async () => await ActAsync(f, As(c), p3, "approve", "duyệt vượt lượt");
        await actWrongLevel.Should().ThrowAsync<ForbiddenException>(
            "NV Cấp 2 KHÔNG được ký thay Cấp 1 — đây là chỗ mất trắng vòng kiểm soát nếu lọt");

        // ---- (b) ca ÂM lớp 2: người ngoài quy trình.
        Func<Task> actOutsider = async () => await ActAsync(f, As(outsider), p3, "approve", "tôi ký hộ");
        await actOutsider.Should().ThrowAsync<ForbiddenException>(
            "người không có trong Cấp nào của quy trình thì không phải approver");

        // Ném rồi mà vẫn ghi còn tệ hơn không rào: phiếu p3 phải NGUYÊN VẸN.
        ShouldBeAt(await ReadPlanAsync(f, p3), ContractSigningPlanPhase.ChoDuyet, 0, 1,
            "hai lần bị chặn ⇒ con-trỏ p3 bất động");
        (await ReadOpinionsAsync(f, p3)).Should().BeEmpty("bị chặn thì KHÔNG được để lại chữ ký");
    }

    // ===================================================================
    // (T2) Con-trỏ ĐÔI — acceptance #2 (spec §③-B `:29`).
    //
    // 3 Bước × 2 Cấp, chạy trọn 6 lượt duyệt. Hai điểm dễ hỏng nhất:
    //   • hết Cấp trong Bước ⇒ sang Bước kế **và Cấp phải RESET về 1** (quên reset thì Bước 2
    //     bắt đầu từ Cấp 2, Cấp 1 của Bước 2 bị bỏ qua vĩnh viễn — mất một chữ ký thật);
    //   • `CurrentWorkflowStepIndex` là **INDEX** vào list đã sort, KHÔNG phải `Step.Order`.
    //     Seed cố ý đặt Order = 5/15/25 để hai đại lượng này không thể trùng nhau do may mắn;
    //     mã W2 đã đọc theo INDEX (`ContractSigningPlanFeatures.cs:256-260`) nên lệch semantic
    //     ở đây = inbox mù ngay lập tức.
    // ===================================================================
    [Fact]
    public async Task ApproveV2_NewStep_ResetsLevelOrderToOne()
    {
        using var f = new KhkkWorkflowFixture();
        var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        var s1c1 = await AddUserAsync(f, "PMH cấp 1");
        var s1c2 = await AddUserAsync(f, "PMH cấp 2");
        var s2c1 = await AddUserAsync(f, "CCM cấp 1");
        var s2c2 = await AddUserAsync(f, "CCM cấp 2");
        var s3c1 = await AddUserAsync(f, "Thư ký CEO");
        var s3c2 = await AddUserAsync(f, "CEO");

        var wf = await SeedWorkflowAsync(f, "QT-KHKK-3TRAM", steps:
            [
                [[s1c1.Id], [s1c2.Id]],   // Bước 1 (PMH)
                [[s2c1.Id], [s2c2.Id]],   // Bước 2 (CCM)
                [[s3c1.Id], [s3c2.Id]],   // Bước 3 (CEO)
            ],
            stepOrderStart: 5, stepOrderStride: 10);   // Order 5/15/25 ≠ index 0/1/2

        var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/010",
            ContractSigningPlanPhase.ChoDuyet, 0, 1, [new(PeReference: 900m, Proposed: 800m)]);

        ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 1, "sanity seed");

        // 1) Trong cùng Bước 1: Cấp 1 → Cấp 2.
        var r1 = await ActAsync(f, As(s1c1), planId, "approve", "Bước 1 Cấp 1");
        var p1 = await ReadPlanAsync(f, planId);
        ShouldBeAt(p1, ContractSigningPlanPhase.ChoDuyet, 0, 2, "còn Cấp trong Bước ⇒ chỉ tăng Cấp, Bước giữ nguyên");
        ResultShouldMirror(r1, p1);

        // 2) Hết Cấp Bước 1 ⇒ Bước 2 (index 1) + Cấp RESET về 1.
        var r2 = await ActAsync(f, As(s1c2), planId, "approve", "Bước 1 Cấp 2");
        var p2 = await ReadPlanAsync(f, planId);
        ShouldBeAt(p2, ContractSigningPlanPhase.ChoDuyet, 1, 1,
            "sang Bước MỚI thì Cấp phải reset về 1; giữ nguyên 2 là Cấp 1 của Bước 2 không bao giờ được ký");
        ResultShouldMirror(r2, p2);

        // 3) Bước 2: Cấp 1 → Cấp 2.
        await ActAsync(f, As(s2c1), planId, "approve", "Bước 2 Cấp 1");
        ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 1, 2, "trong Bước 2");

        // 4) Reset LẦN HAI — một lần đúng có thể do hardcode, hai lần thì không.
        await ActAsync(f, As(s2c2), planId, "approve", "Bước 2 Cấp 2");
        ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 2, 1,
            "reset Cấp lần 2 (Bước 3) — chứng đây là quy tắc, không phải trùng hợp ở Bước 2");

        // 5) Bước 3 Cấp 1 → Cấp 2: chưa xong, TUYỆT ĐỐI chưa DaDuyet.
        await ActAsync(f, As(s3c1), planId, "approve", "Bước 3 Cấp 1");
        var p5 = await ReadPlanAsync(f, planId);
        ShouldBeAt(p5, ContractSigningPlanPhase.ChoDuyet, 2, 2,
            "còn Cấp cuối chưa ký ⇒ chưa được kết thúc (W3 đường THƯỜNG: CEO ký thật, không kết-thúc-sớm)");

        // 6) Cấp cuối Bước cuối ⇒ terminal, HAI con-trỏ về null.
        var r6 = await ActAsync(f, As(s3c2), planId, "approve", "CEO duyệt");
        var p6 = await ReadPlanAsync(f, planId);
        ShouldBeAt(p6, ContractSigningPlanPhase.DaDuyet, null, null,
            "hết Bước cuối ⇒ DaDuyet và cả 2 con-trỏ = null (`ContractSigningPlan.cs:34` 'Null khi terminal'); "
            + "để sót con-trỏ thì phiếu đã duyệt vẫn nằm trong hộp thư chờ của ai đó");
        ResultShouldMirror(r6, p6);
    }

    // ===================================================================
    // (T3) Finalize chốt giá — acceptance #3 (spec §③-B `:30`).
    //
    // Hai thứ được đo cùng lúc:
    //   • `AND PlanId = @id`: phiếu CHỨNG NHÂN (planB, cùng quy trình, cùng đang chờ) phải
    //     BẤT ĐỘNG. Bug thật của lớp này là `Where(l => l.ApprovedAmount == null)` quên vế
    //     PlanId — nó chốt giá cho mọi phiếu đang mở trong hệ thống, im lặng và không hoàn lại được.
    //   • **2 WRITE-PATH** (spec F-B1): duyệt-cấp-cuối THƯỜNG *và* Admin duyệt-thay (bypass
    //     danh sách approver — khuôn nguồn copy `ContractWorkflowService.cs:255` `if (!isAdmin…)`).
    //     Helper `ApplyApprovedValuesOnFinalize` phải được gọi ở CẢ HAI; gọi một chỗ thì
    //     phiếu do Admin chốt sẽ ở DaDuyet mà không có giá — GĐ3 sinh HĐ 0đ.
    //
    // Bẫy nguồn số: `ProposedAmount` ≠ `PeReferenceAmount` ở mọi dòng ⇒ lấy nhầm cột là lộ
    // ngay (`ApprovedAmount ??= ProposedAmount` — spec §②-3a). Dòng thứ 3 đã có giá sẵn ⇒
    // đo đúng ngữ nghĩa `??=` (giữ, KHÔNG đè).
    // ===================================================================
    [Fact]
    public async Task Finalize_SetsApprovedAmount_OnlyForLinesOfThatPlan()
    {
        using var f = new KhkkWorkflowFixture();
        var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        var approver = await AddUserAsync(f, "CEO ký duyệt");
        var adminUser = await AddUserAsync(f, "Quản trị hệ thống");
        var admin = As(adminUser, AppRoles.Admin);   // KHÔNG có chân trong quy trình

        // 1 Bước × 1 Cấp ⇒ một lượt duyệt là terminal.
        var wf = await SeedWorkflowAsync(f, "QT-KHKK-1CAP", steps: [[[approver.Id]]]);

        var planA = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/020",
            ContractSigningPlanPhase.ChoDuyet, 0, 1,
            [
                new(PeReference: 999m, Proposed: 111m),
                new(PeReference: 888m, Proposed: 222m),
                new(PeReference: 777m, Proposed: 333m, Approved: 300m),   // đã có giá chốt sẵn
            ]);
        var planB = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/021",
            ContractSigningPlanPhase.ChoDuyet, 0, 1,
            [new(PeReference: 555m, Proposed: 55m), new(PeReference: 444m, Proposed: 44m)]);
        var planC = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/022",
            ContractSigningPlanPhase.ChoDuyet, 0, 1,
            [new(PeReference: 222m, Proposed: 22m), new(PeReference: 111m, Proposed: 11m)]);

        // ---- WRITE-PATH 1: approver Cấp cuối duyệt.
        await ActAsync(f, As(approver), planA, "approve", "Chốt giá theo đề xuất BCH");

        var a = await ReadPlanAsync(f, planA);
        a.Phase.Should().Be(ContractSigningPlanPhase.DaDuyet, "Cấp cuối Bước cuối ⇒ terminal");
        a.Lines.Should().NotContain(l => l.ApprovedAmount == null,
            "acceptance: `Lines WHERE PlanId=@id AND ApprovedAmount IS NULL` = 0");

        var byProposed = a.Lines.ToDictionary(l => l.ProposedAmount);
        byProposed[111m].ApprovedAmount.Should().Be(111m,
            "giá chốt lấy từ `ProposedAmount` (BCH đề xuất), KHÔNG phải `PeReferenceAmount` 999 — "
            + "lấy nhầm cột thì phiếu chốt bằng số tham chiếu cũ của phiếu Duyệt NCC");
        byProposed[222m].ApprovedAmount.Should().Be(222m);
        byProposed[333m].ApprovedAmount.Should().Be(300m,
            "`??=` GIỮ giá đã chốt sẵn (300), không đè bằng 333 — đè là xoá số người có thẩm quyền đã sửa");

        // ---- Chứng nhân: phiếu hàng xóm BẤT ĐỘNG (vế `AND PlanId=@id`).
        var bAfter1 = await ReadPlanAsync(f, planB);
        bAfter1.Lines.Should().OnlyContain(l => l.ApprovedAmount == null,
            "phiếu KHÁC tuyệt đối không được dính giá chốt — thiếu vế PlanId là chốt giá cả hệ thống");
        bAfter1.Phase.Should().Be(ContractSigningPlanPhase.ChoDuyet, "và cũng không được nhảy phase");

        // ---- WRITE-PATH 2: Admin duyệt thay (không nằm trong Cấp nào) — spec F-B1.
        admin.Roles.Should().Contain(AppRoles.Admin, "actor của write-path 2 phải THỰC SỰ mang role Admin");
        await ActAsync(f, admin, planC, "approve", "Admin duyệt thay CEO");

        var c = await ReadPlanAsync(f, planC);
        c.Phase.Should().Be(ContractSigningPlanPhase.DaDuyet, "Admin bypass danh sách approver (khuôn `ContractWorkflowService.cs:255`)");
        c.Lines.Should().NotContain(l => l.ApprovedAmount == null,
            "đường Admin cũng PHẢI đi qua choke-point chốt giá — đây là write-path thứ 2 mà "
            + "grep literal không nhìn thấy (spec F-B1)");
        c.Lines.Single(l => l.ProposedAmount == 22m).ApprovedAmount.Should().Be(22m);

        // Chứng nhân lần 2 — sau CẢ HAI write-path vẫn phải sạch.
        (await ReadPlanAsync(f, planB)).Lines.Should().OnlyContain(l => l.ApprovedAmount == null,
            "qua 2 đường finalize, phiếu chứng nhân vẫn không được đụng tới");
    }

    // ===================================================================
    // (T4) Inbox — acceptance #4 (spec §③-B `:31`).
    //
    // 🔴 Test DUY NHẤT trong 6 cái KHÔNG chạm service ⇒ **XANH ngay đợt này** (query handler
    // W2 đã wire: `ContractSigningPlanFeatures.cs:725-752`). Cố ý không ép đỏ. Nó gánh thêm
    // vai trò lưới an toàn cho 5 test kia: dùng CHUNG bộ seed helper, nên nếu seed sai FK
    // hay sai con-trỏ thì test này đỏ TRƯỚC và ta biết ngay là lỗi test chứ không phải lỗi sản phẩm.
    //
    // Ba lớp phủ: đúng-Cấp (A, B thấy — OR-of-N) · khác-Cấp (C KHÔNG thấy phiếu Cấp 1) ·
    // ngoài-quy-trình + người-soạn (rỗng). Cộng phiếu nháp: KHÔNG vào hộp thư của ai.
    // ===================================================================
    [Fact]
    public async Task Inbox_OnlyPendingLevelApprover_SeesPlan()
    {
        using var f = new KhkkWorkflowFixture();
        var drafterUser = await AddUserAsync(f, "BCH lập kế hoạch");
        var drafter = As(drafterUser);
        var a = await AddUserAsync(f, "NV Cung ứng A");
        var b = await AddUserAsync(f, "NV Cung ứng B");
        var c = await AddUserAsync(f, "Trưởng phòng C");
        var outsider = await AddUserAsync(f, "Kế toán ngoài quy trình D");

        var wf = await SeedWorkflowAsync(f, "QT-KHKK-INBOX", steps:
        [
            [[a.Id, b.Id], [c.Id]],
        ]);

        LineSeed[] oneLine = [new(PeReference: 999m, Proposed: 111m)];
        // Tên biến = Cấp mà phiếu ĐANG CHỜ: `pendingLevel1` đứng ở Cấp 1 (việc của A/B),
        // `pendingLevel2` ở Cấp 2 (việc của C) — có ĐỦ CẢ HAI thì ca ÂM "cấp khác không thấy"
        // mới đo được cả hai chiều.
        // 🔧 [W3 đợt-3 sửa SEED] Dòng đầu trước đó là `0, 2` kèm chú "FAULT-INJECT tạm" — vết
        // của lượt tự-thử-lưới còn sót, làm CẢ HAI phiếu cùng đứng Cấp 2 ⇒ hộp thư A/B rỗng và
        // C thấy 2 phiếu. Đỏ vì SEED, KHÔNG phải vì sản phẩm (query handler W2 không đổi một
        // dòng nào ở đợt này). Mọi assert giữ NGUYÊN CHỮ.
        var pendingLevel1 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/030",
            ContractSigningPlanPhase.ChoDuyet, 0, 1, oneLine);
        var pendingLevel2 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/031",
            ContractSigningPlanPhase.ChoDuyet, 0, 2, oneLine);
        var draft = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/032",
            ContractSigningPlanPhase.DangSoanThao, null, null, oneLine);

        // A và B cùng Cấp 1 (OR-of-N) ⇒ CẢ HAI cùng thấy đúng một phiếu.
        (await InboxAsync(f, As(a))).Select(i => i.Id).Should().BeEquivalentTo(new[] { pendingLevel1 },
            "approver của Cấp ĐANG CHỜ thấy phiếu; phiếu đang ở Cấp 2 thì không phải việc của A");
        (await InboxAsync(f, As(b))).Select(i => i.Id).Should().BeEquivalentTo(new[] { pendingLevel1 },
            "OR-of-N: người thứ hai cùng Cấp cũng phải thấy, nếu không thì cả Cấp phụ thuộc 1 người");

        // C ở Cấp 2 — thấy ĐÚNG phiếu đang chờ Cấp 2, KHÔNG thấy phiếu đang ở Cấp 1 (ca ÂM).
        (await InboxAsync(f, As(c))).Select(i => i.Id).Should().BeEquivalentTo(new[] { pendingLevel2 },
            "cấp khác KHÔNG được thấy phiếu chưa tới lượt mình — thấy = duyệt vượt lượt ngay từ hộp thư");

        (await InboxAsync(f, As(outsider))).Should().BeEmpty("người ngoài quy trình không có việc gì ở đây");
        (await InboxAsync(f, drafter)).Should().BeEmpty(
            "người SOẠN không phải người DUYỆT — hộp thư là 'chờ tôi duyệt', không phải 'phiếu của tôi'");

        // Phiếu nháp không xuất hiện ở bất kỳ hộp thư nào (con-trỏ null).
        var allInboxIds = (await InboxAsync(f, As(a))).Concat(await InboxAsync(f, As(b)))
            .Concat(await InboxAsync(f, As(c))).Select(i => i.Id).ToList();
        allInboxIds.Should().NotContain(draft, "phiếu Nháp chưa trình thì chưa chờ ai duyệt");
    }

    // ===================================================================
    // (T5) UPSERT ý kiến — acceptance #5 (spec §③-B `:32`).
    //
    // "Duyệt 2 lần CÙNG MỘT CẤP" chỉ xảy ra theo đúng một đường ở W3 đường-THƯỜNG: ký →
    // bị trả lại → gửi lại → ký lại. Dùng CHÍNH một người cho cả hai lượt để `LevelId` không
    // đổi, nhờ vậy phép đo "1 row" là rõ nghĩa dưới mọi cách đọc.
    //
    // Nếu đợt-3 viết `Add(...)` thẳng thay vì UPSERT: hoặc nổ UNIQUE (PlanId, LevelId), hoặc
    // đẻ dòng thứ hai — cả hai đều bị bắt ở đây. Nếu ghi đúng 1 row nhưng quên cập nhật
    // `SignedByUserId`/`SignedByFullName`/`Comment` thì đó là chữ ký cũ đứng tên cho lần duyệt mới.
    // ===================================================================
    [Fact]
    public async Task ApproveV2_TwiceSameLevel_UpsertsSingleOpinionRow()
    {
        using var f = new KhkkWorkflowFixture();
        var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        var a = await AddUserAsync(f, "NV Cung ứng A");
        var b = await AddUserAsync(f, "NV Cung ứng B");
        var c = await AddUserAsync(f, "Trưởng phòng C");

        var wf = await SeedWorkflowAsync(f, "QT-KHKK-UPSERT", steps:
        [
            [[a.Id, b.Id], [c.Id]],
        ]);
        var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/040",
            ContractSigningPlanPhase.ChoDuyet, 0, 1, [new(PeReference: 500m, Proposed: 480m)]);
        var levelOfA = await LevelIdOfAsync(f, a.Id);

        // ---- Lượt 1: A ký, KHÔNG nhập ý kiến.
        await ActAsync(f, As(a), planId, "approve", comment: null);

        var first = await ReadOpinionsAsync(f, planId);
        first.Should().ContainSingle("lượt ký đầu tiên tạo 1 dòng");
        first[0].ApprovalWorkflowLevelId.Should().Be(levelOfA);
        first[0].SignedAt.Should().Be(Clock2026,
            "thời điểm ký lấy từ `IDateTime` được bơm vào service — dùng `DateTime.UtcNow` trực tiếp "
            + "là không test được và lệch giờ máy chủ");
        first[0].Comment.Should().NotBeNullOrWhiteSpace(
            "ý kiến rỗng phải thành chữ thay thế (`ContractSigningPlanLevelOpinion.cs:26` 'hoặc placeholder', "
            + "khuôn nguồn copy `ContractWorkflowService.cs:295-297`) — để trống thì màn Ý kiến hiện ô câm, "
            + "không phân biệt được 'đã ký không ý kiến' với 'chưa ký'");

        // ---- Trả lại rồi gửi lại ⇒ con-trỏ quay về Cấp 1, tới lượt A LẦN NỮA.
        await ActAsync(f, As(c), planId, "return", "Thiếu bảng chiết tính — trả lại BCH");
        (await ReadPlanAsync(f, planId)).Phase.Should().Be(ContractSigningPlanPhase.TraLai);

        await ActAsync(f, drafter, planId, "submit", "Đã bổ sung chiết tính");
        ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 1,
            "gửi lại thì chạy LẠI từ Bước 1 Cấp 1 ⇒ đúng lượt A");

        // ---- Lượt 2: CHÍNH A ký lại CÙNG Cấp đó, lần này có ý kiến.
        await ActAsync(f, As(a), planId, "approve", "Đã rà lại đơn giá — thống nhất ký");

        var second = await ReadOpinionsAsync(f, planId);
        second.Should().ContainSingle(
            "duyệt lần 2 cùng Cấp là CẬP NHẬT dòng cũ (UNIQUE PlanId × LevelId), KHÔNG đẻ dòng thứ 2");
        second[0].ApprovalWorkflowLevelId.Should().Be(levelOfA, "vẫn là ý kiến của đúng Cấp đó");
        second[0].Comment.Should().Be("Đã rà lại đơn giá — thống nhất ký",
            "nội dung phải là ý kiến MỚI NHẤT (latest-write-wins) — giữ chữ cũ là hồ sơ nói sai sự thật");
        second[0].SignedByUserId.Should().Be(a.Id, "người ký THẬT của lượt sau");
        second[0].SignedByFullName.Should().Be(a.FullName,
            "tên là snapshot chụp lúc ký (`ContractSigningPlanLevelOpinion.cs:29`) — bỏ trống thì bản in "
            + "tờ FO-002.01 ra ô tên rỗng");
    }

    // ===================================================================
    // (T6) Trả lại → gửi lại — acceptance #6 (spec §③-B `:33`).
    //
    // Mấu chốt: phiếu bị trả lại khi ĐÃ ĐI QUA HẾT Bước 1 và đang đứng ở Bước 2. Nếu đợt-3
    // hiểu "gửi lại" là *nối tiếp chỗ đang dở* thì con-trỏ sẽ về (Bước 2, Cấp 1) — trông rất
    // hợp lý, và hậu quả là bản sửa của BCH KHÔNG được Bước 1 xem lại lần nào. Vì thế phải
    // trả lại đầu quy trình: (Bước 1, Cấp 1).
    //
    // Lượt duyệt số 6 (A ký lại được) là vế chứng minh vòng chạy lại là THẬT, không phải chỉ
    // đặt lại hai con số rồi khoá.
    // ===================================================================
    [Fact]
    public async Task SendBack_Resubmit_RestartsFromStepOneLevelOne()
    {
        using var f = new KhkkWorkflowFixture();
        var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        var a = await AddUserAsync(f, "PMH cấp 1");
        var b = await AddUserAsync(f, "PMH cấp 2");
        var c = await AddUserAsync(f, "CCM Bước 2");

        var wf = await SeedWorkflowAsync(f, "QT-KHKK-TRALAI", steps:
        [
            [[a.Id], [b.Id]],   // Bước 1: 2 Cấp
            [[c.Id]],           // Bước 2: 1 Cấp
        ]);

        // Phiếu bắt đầu ở NHÁP, con-trỏ trống — đúng trạng thái sau khi W2 lập phiếu.
        var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/050",
            ContractSigningPlanPhase.DangSoanThao, null, null, [new(PeReference: 700m, Proposed: 650m)]);

        // 1) Trình lần đầu: đặt con-trỏ về (Bước 1, Cấp 1).
        var rSubmit = await ActAsync(f, drafter, planId, "submit", "Trình duyệt kế hoạch ký kết");
        var afterSubmit = await ReadPlanAsync(f, planId);
        ShouldBeAt(afterSubmit, ContractSigningPlanPhase.ChoDuyet, 0, 1,
            "trình = mở quy trình từ Bước 1 Cấp 1");
        ResultShouldMirror(rSubmit, afterSubmit);

        // 2-3) Đi hết Bước 1 để phiếu ĐANG ĐỨNG Ở BƯỚC 2 lúc bị trả lại.
        await ActAsync(f, As(a), planId, "approve", "Bước 1 Cấp 1 duyệt");
        ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 2, "sang Cấp 2");
        await ActAsync(f, As(b), planId, "approve", "Bước 1 Cấp 2 duyệt");
        ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 1, 1,
            "hết Bước 1 ⇒ đang chờ Bước 2 Cấp 1 (đây là mốc để câu hỏi 'gửi lại về đâu' có nghĩa)");

        // 4) Bước 2 trả lại.
        var rReturn = await ActAsync(f, As(c), planId, "return", "Sai khối lượng hạng mục 3 — trả lại");
        var afterReturn = await ReadPlanAsync(f, planId);
        ShouldBeAt(afterReturn, ContractSigningPlanPhase.TraLai, null, null,
            "Trả lại toàn phần: phase TraLai + XOÁ con-trỏ (`ContractSigningPlanWorkflowService.cs:116`) — "
            + "để sót con-trỏ thì phiếu vừa nằm ở tay người soạn vừa nằm trong hộp thư người duyệt");
        ResultShouldMirror(rReturn, afterReturn);
        afterReturn.Lines.Should().OnlyContain(l => l.ApprovedAmount == null,
            "bị trả lại thì không có giá nào được chốt");

        // 5) 🔴 Gửi lại: PHẢI về đầu quy trình, KHÔNG nối tiếp Bước 2.
        await ActAsync(f, drafter, planId, "submit", "Đã sửa khối lượng, trình lại");
        ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 1,
            "gửi lại chạy LẠI TỪ ĐẦU (Bước 1 Cấp 1); về (1,1) tức Bước 1 không hề xem bản sửa");

        // 6) Vòng mới là thật: A có lượt lại và duyệt được.
        await ActAsync(f, As(a), planId, "approve", "Xem lại bản sửa — đồng ý");
        ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 2,
            "A ký được lần nữa ⇒ vòng duyệt chạy lại thật sự, không phải con-trỏ bị đặt lại rồi khoá");
    }

    // ###################################################################
    // [K3 S164 — 2026-07-31] KẾT-THÚC-TẠI-CẤP (level-finalize).
    //
    // Owner @S164 ĐẢO phán quyết "PHÁ VỠ" của S155 ⇒ service port nhánh mới
    // (`ContractSigningPlanWorkflowService.cs:276-289`), đứng NGAY SAU `UpsertLevelOpinionAsync`
    // và TRƯỚC mọi nhánh advance. Đây là đường thứ HAI đưa phiếu sang `DaDuyet` — tức đường
    // thứ hai phải đi qua choke-point chốt giá; bỏ sót nó thì phiếu "Đã duyệt" mà
    // `ApprovedAmount` rỗng và GĐ3 sinh hợp đồng 0đ (đúng lớp hỏng mà T3 đã rào cho đường 1).
    //
    // Ba lưới dưới đây là MỘT MA TRẬN 2 chiều trên CÙNG MỘT bộ seed (kỹ thuật "cùng-ruột,
    // khác đúng 1 biến" — S153): chỉ khi cả ba cùng xanh thì mới kết luận được rằng nhánh
    // finalize phụ thuộc CẢ HAI vế, chứ không phải một vế nào đó tình cờ đúng.
    //
    //                       │ apply=true (mặc định) │ apply=false (bỏ tick)
    //   ─────────────────────┼───────────────────────┼──────────────────────
    //   Cấp CÓ cờ            │ (T-a) KẾT THÚC        │ (T-b) advance thường
    //   Cấp KHÔNG cờ         │ (T-c) advance thường  │ — (không có thông tin thêm)
    //
    // 🔴 Điều kiện để phép đo có nghĩa: Cấp mang cờ là **Bước 2 Cấp 2 của quy trình 3 Bước**,
    // tức KHÔNG phải cấp cuối. Nếu đặt cờ ở cấp cuối thì "kết thúc" xảy ra ở cả hai thế giới
    // (có cờ / không cờ) và T-a xanh mà chẳng chứng minh gì. Mỗi test vì thế assert lại số
    // Bước = 3 trước khi kết luận.
    // ###################################################################

    /// Bộ seed dùng chung cho T-a/T-b/T-c. Ba phiếu SONG SINH trên CÙNG một quy trình:
    ///   • `PlanAtFlagLevel`    — đứng ở Bước 2 Cấp 2 (Cấp MANG cờ) → đối tượng đo của T-a/T-b
    ///   • `WitnessAtFlagLevel` — y hệt, KHÔNG ai đụng → bắt lỗi finalize quên vế "chỉ phiếu này"
    ///   • `PlanAtPlainLevel`   — đứng ở Bước 1 Cấp 1 (Cấp KHÔNG cờ) → đối tượng đo của T-c
    private sealed record FinalizeScenario(
        Guid WorkflowId,
        Guid PlanAtFlagLevel,
        Guid WitnessAtFlagLevel,
        Guid PlanAtPlainLevel,
        User FlagApprover,
        User PlainApprover,
        TestCurrentUser Drafter);

    /// Bẫy nguồn số cố ý: `ProposedAmount` ≠ `PeReferenceAmount` ở MỌI dòng (lấy nhầm cột là lộ
    /// ngay), và dòng thứ 3 đã có `ApprovedAmount` sẵn để đo đúng ngữ nghĩa `??=` (GIỮ, không đè).
    private static readonly LineSeed[] FinalizeLines =
    [
        new(PeReference: 999m, Proposed: 111m),
        new(PeReference: 888m, Proposed: 222m),
        new(PeReference: 777m, Proposed: 333m, Approved: 300m),
    ];

    private static async Task<FinalizeScenario> SeedFinalizeScenarioAsync(KhkkWorkflowFixture f, string tag)
    {
        var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        var s1c1 = await AddUserAsync(f, "PMH cấp 1");
        var s1c2 = await AddUserAsync(f, "PMH cấp 2");
        var s2c1 = await AddUserAsync(f, "CCM cấp 1");
        var s2c2 = await AddUserAsync(f, "TP.CCM — Cấp được KẾT THÚC");
        var s3c1 = await AddUserAsync(f, "Thư ký CEO");
        var s3c2 = await AddUserAsync(f, "CEO");

        // Order 5/15/25 (KHÔNG 1/2/3) giữ nguyên chủ ý của T2: `CurrentWorkflowStepIndex` là
        // INDEX vào list đã sort, đọc nhầm thành `Step.Order` là lộ ngay chứ không trùng do may.
        var wf = await SeedWorkflowAsync(f, $"QT-KHKK-FIN-{tag}", steps:
            [
                [[s1c1.Id], [s1c2.Id]],   // Bước 1
                [[s2c1.Id], [s2c2.Id]],   // Bước 2  ← Cấp 2 mang cờ
                [[s3c1.Id], [s3c2.Id]],   // Bước 3  ← còn NGUYÊN phía sau ⇒ kết thúc ở Bước 2 là SỚM
            ],
            stepOrderStart: 5, stepOrderStride: 10);

        await SetLevelFinalizeAsync(f, wf, stepIndex: 1, levelOrder: 2, expectedApprover: s2c2.Id);

        var flagPlan = await SeedPlanAsync(f, drafter, wf, $"KHKK/2026/{tag}01",
            ContractSigningPlanPhase.ChoDuyet, 1, 2, FinalizeLines);
        var witness = await SeedPlanAsync(f, drafter, wf, $"KHKK/2026/{tag}02",
            ContractSigningPlanPhase.ChoDuyet, 1, 2, FinalizeLines);
        var plainPlan = await SeedPlanAsync(f, drafter, wf, $"KHKK/2026/{tag}03",
            ContractSigningPlanPhase.ChoDuyet, 0, 1, FinalizeLines);

        return new FinalizeScenario(wf, flagPlan, witness, plainPlan, s2c2, s1c1, drafter);
    }

    /// Bật cờ `AllowApproverFinalize` cho ĐÚNG MỘT Cấp, rồi tự kiểm 2 CHIỀU từ ĐĨA:
    /// (i) Cấp được bật đúng là Cấp của người ta nghĩ; (ii) TOÀN quy trình chỉ có đúng nó
    /// mang cờ. Thiếu vế (ii) thì một seed lỡ bật thêm Cấp khác vẫn cho T-c xanh ở thế giới sai.
    private static async Task SetLevelFinalizeAsync(
        KhkkWorkflowFixture f, Guid workflowId, int stepIndex, int levelOrder, Guid expectedApprover)
    {
        await using (var db = f.NewDb(TestCurrentUser.System()))
        {
            var stepIds = await db.ApprovalWorkflowSteps
                .Where(s => s.ApprovalWorkflowId == workflowId)
                .OrderBy(s => s.Order)
                .Select(s => s.Id)
                .ToListAsync(CancellationToken.None);

            var levels = await db.ApprovalWorkflowLevels
                .Where(l => l.ApprovalWorkflowStepId == stepIds[stepIndex] && l.Order == levelOrder)
                .ToListAsync(CancellationToken.None);

            levels.Should().ContainSingle("seed đặt đúng 1 NV ở Cấp mang cờ — nhiều hơn là OR-of-N, phép đo đổi nghĩa");
            levels[0].ApproverUserId.Should().Be(expectedApprover,
                "bật cờ nhầm Cấp thì mọi assert phía sau nói về một người khác");
            levels[0].AllowApproverFinalize = true;
            await db.SaveChangesAsync(CancellationToken.None);
        }

        await using var verify = f.NewDb(TestCurrentUser.System());
        var flagged = await verify.ApprovalWorkflowLevels.AsNoTracking()
            .Where(l => l.Step!.ApprovalWorkflowId == workflowId && l.AllowApproverFinalize)
            .Select(l => l.ApproverUserId)
            .ToListAsync(CancellationToken.None);
        flagged.Should().BeEquivalentTo(new[] { expectedApprover },
            "ĐÚNG MỘT Cấp trong cả quy trình mang cờ — cờ rò sang Cấp khác thì T-c mất răng");
    }

    /// Số Bước của quy trình — dùng để chốt "còn Bước phía sau" (điều kiện KẾT THÚC SỚM có nghĩa).
    private static async Task<int> StepCountAsync(KhkkWorkflowFixture f, Guid workflowId)
    {
        await using var db = f.NewDb(TestCurrentUser.System());
        return await db.ApprovalWorkflowSteps.AsNoTracking()
            .CountAsync(s => s.ApprovalWorkflowId == workflowId, CancellationToken.None);
    }

    /// `ActAsync` luôn dùng mặc định (`applyLevelFinalize = true`) — helper NÀY là đường DUY NHẤT
    /// gửi `false`, tách riêng để không đụng vào `ActAsync` mà 6 test cũ đang dựa vào.
    private static async Task<ContractSigningPlanTransitionResult> ActWithFinalizeFlagAsync(
        KhkkWorkflowFixture f, TestCurrentUser actor, Guid planId, string action,
        string? comment, bool applyLevelFinalize)
    {
        await using var db = f.NewDb(actor);
        var svc = new ContractSigningPlanWorkflowService(db, actor, f.Clock, new NoOpNotificationServiceApp());
        return await svc.TransitionAsync(planId, action, comment, applyLevelFinalize, CancellationToken.None);
    }

    /// Hai dòng khởi điểm `ApprovedAmount = null` phải CÒN null; dòng đã có 300 phải GIỮ 300.
    /// Viết thành hàm để ba test nói cùng một câu, không mỗi chỗ một kiểu.
    private static void LinesShouldBeUnpriced(ContractSigningPlan plan, string because)
    {
        plan.Lines.Where(l => l.ProposedAmount != 333m).Should()
            .OnlyContain(l => l.ApprovedAmount == null, because);
        plan.Lines.Single(l => l.ProposedAmount == 333m).ApprovedAmount.Should().Be(300m,
            "dòng vốn đã có giá thì giữ nguyên số cũ — " + because);
    }

    // ===================================================================
    // (T-a) Cấp MANG cờ + không bỏ tick ⇒ KẾT THÚC NGAY tại Cấp đó, kèm chốt giá.
    // ===================================================================
    [Fact]
    public async Task ApproveV2_CapMangCoKetThuc_ChotPhieuNgay_KhongTrinhTiepBuocSau()
    {
        using var f = new KhkkWorkflowFixture();
        var sc = await SeedFinalizeScenarioAsync(f, "A");

        // Sanity: đo đúng thế giới mình nghĩ — phiếu đứng ở Cấp mang cờ, chưa từng finalize,
        // và quy trình CÒN Bước 3 phía sau (nếu không thì "kết thúc sớm" là vô nghĩa).
        var before = await ReadPlanAsync(f, sc.PlanAtFlagLevel);
        ShouldBeAt(before, ContractSigningPlanPhase.ChoDuyet, 1, 2, "seed đặt phiếu đúng Bước 2 Cấp 2");
        before.EndedByLevelFinalize.Should().BeFalse("cờ runtime phải sạch trước khi đo");
        (await StepCountAsync(f, sc.WorkflowId)).Should().Be(3,
            "Cấp mang cờ KHÔNG được là cấp cuối — nếu là cấp cuối thì phiếu kết thúc ở CẢ HAI thế giới "
            + "(có cờ / không cờ) và test này xanh mà không chứng minh gì");

        var r = await ActAsync(f, As(sc.FlagApprover), sc.PlanAtFlagLevel,
            "approve", "TP.CCM chốt — không cần trình CEO");

        var after = await ReadPlanAsync(f, sc.PlanAtFlagLevel);
        ShouldBeAt(after, ContractSigningPlanPhase.DaDuyet, null, null,
            "Cấp mang cờ duyệt = kết thúc quy trình: phase DaDuyet và XOÁ cả 2 con-trỏ; "
            + "để sót con-trỏ thì phiếu đã duyệt vẫn nằm trong hộp thư chờ của Bước 3");
        ResultShouldMirror(r, after);

        after.EndedByLevelFinalize.Should().BeTrue(
            "cờ RUNTIME phân biệt 'kết thúc sớm THẬT' với 'chạy hết quy trình' — FE/GĐ3 đọc cột này; "
            + "suy ngược từ cấu hình quy trình là bài học gotcha #78 (config-lie) đã trả giá ở PE S97");

        // Chốt giá: đường finalize là WRITE-PATH THỨ HAI tới DaDuyet ⇒ cũng phải qua choke-point.
        after.Lines.Should().NotContain(l => l.ApprovedAmount == null,
            "phiếu 'Đã duyệt' mà ApprovedAmount rỗng thì GĐ3 sinh hợp đồng 0đ — nhánh finalize "
            + "PHẢI gọi `ApplyApprovedValuesOnFinalize`, đúng như nhánh duyệt-cấp-cuối");
        var byProposed = after.Lines.ToDictionary(l => l.ProposedAmount);
        byProposed[111m].ApprovedAmount.Should().Be(111m,
            "giá chốt lấy `ProposedAmount` (BCH đề xuất), KHÔNG phải `PeReferenceAmount` 999");
        byProposed[222m].ApprovedAmount.Should().Be(222m);
        byProposed[333m].ApprovedAmount.Should().Be(300m,
            "`??=` GIỮ giá đã chốt sẵn (300), không đè bằng 333");

        // Nhánh finalize nằm SAU `UpsertLevelOpinionAsync` ⇒ chữ ký của người kết thúc vẫn phải có.
        var op = await ReadOpinionsAsync(f, sc.PlanAtFlagLevel);
        op.Should().ContainSingle("kết thúc tại Cấp vẫn là một lượt DUYỆT ⇒ vẫn ghi ý kiến của Cấp đó");
        op[0].SignedByUserId.Should().Be(sc.FlagApprover.Id,
            "người kết thúc phiếu phải để lại tên — bản in FO-002.01 lấy chữ ký từ đây");

        // Chứng nhân: phiếu song sinh (cùng quy trình, cùng con-trỏ) tuyệt đối bất động.
        var w = await ReadPlanAsync(f, sc.WitnessAtFlagLevel);
        ShouldBeAt(w, ContractSigningPlanPhase.ChoDuyet, 1, 2,
            "finalize chỉ được chạm phiếu ĐANG duyệt — lây sang phiếu khác là duyệt hộ cả hệ thống");
        w.EndedByLevelFinalize.Should().BeFalse("cờ runtime cũng không được lây");
        LinesShouldBeUnpriced(w, "phiếu hàng xóm không được dính giá chốt");
    }

    // ===================================================================
    // (T-b) OPT-OUT — CÙNG bộ seed, đổi ĐÚNG MỘT biến `applyLevelFinalize: false`.
    // Người duyệt bỏ tick "Cấp này kết thúc" ⇒ phiếu đi tiếp Bước 3 như thường.
    // ===================================================================
    [Fact]
    public async Task ApproveV2_CapMangCoKetThuc_NhungBoTick_ThiVanTrinhTiepBuocSau()
    {
        using var f = new KhkkWorkflowFixture();
        var sc = await SeedFinalizeScenarioAsync(f, "B");

        (await StepCountAsync(f, sc.WorkflowId)).Should().Be(3, "cùng bộ seed với T-a");

        var r = await ActWithFinalizeFlagAsync(f, As(sc.FlagApprover), sc.PlanAtFlagLevel,
            "approve", "Gói lớn — tôi vẫn trình CEO", applyLevelFinalize: false);

        var after = await ReadPlanAsync(f, sc.PlanAtFlagLevel);
        ShouldBeAt(after, ContractSigningPlanPhase.ChoDuyet, 2, 1,
            "bỏ tick ⇒ rơi xuống advance THƯỜNG: hết Cấp của Bước 2 ⇒ sang Bước 3 (index 2) và "
            + "Cấp RESET về 1. Ra DaDuyet ở đây tức là cờ quy trình một mình quyết định, "
            + "quyền chọn của người duyệt bị bỏ qua");
        ResultShouldMirror(r, after);

        after.EndedByLevelFinalize.Should().BeFalse(
            "phiếu KHÔNG kết thúc sớm ⇒ cờ runtime phải sạch; bật ở đây là badge 'kết thúc sớm' "
            + "hiện sai trên phiếu vẫn còn đang chờ CEO (đúng bệnh #78 mà PE S97 đã sửa)");

        LinesShouldBeUnpriced(after,
            "chưa tới DaDuyet thì TUYỆT ĐỐI chưa được chốt giá — chốt sớm là ký thay CEO");

        // Vòng chạy tiếp là THẬT: đúng người của Bước 3 Cấp 1 duyệt được (không phải con-trỏ
        // bị đặt tới một chỗ chết).
        var op = await ReadOpinionsAsync(f, sc.PlanAtFlagLevel);
        op.Should().ContainSingle("lượt duyệt opt-out vẫn ghi ý kiến của Cấp vừa ký");
    }

    // ===================================================================
    // (T-c) Cờ KHÔNG RÒ — Cấp không mang cờ, duyệt với mặc định (`applyLevelFinalize = true`)
    // vẫn phải advance thường. Đây là vế chứng minh `applyLevelFinalize` MỘT MÌNH không đủ
    // để kết thúc phiếu — nếu điều kiện viết nhầm thành `applyLevelFinalize` đơn lẻ (hoặc `||`
    // thay vì `&&`) thì MỌI lượt duyệt đầu tiên sẽ chốt luôn cả phiếu.
    // ===================================================================
    [Fact]
    public async Task ApproveV2_CapKhongMangCo_ThiKhongBaoGioKetThucSom()
    {
        using var f = new KhkkWorkflowFixture();
        var sc = await SeedFinalizeScenarioAsync(f, "C");

        var r = await ActAsync(f, As(sc.PlainApprover), sc.PlanAtPlainLevel,
            "approve", "Bước 1 Cấp 1 duyệt");

        var after = await ReadPlanAsync(f, sc.PlanAtPlainLevel);
        ShouldBeAt(after, ContractSigningPlanPhase.ChoDuyet, 0, 2,
            "Cấp KHÔNG có cờ ⇒ chỉ tăng Cấp trong cùng Bước, dù client gửi mặc định "
            + "`applyLevelFinalize = true`; ra DaDuyet ở đây là quy trình 3 Bước bị nuốt còn 1 lượt ký");
        ResultShouldMirror(r, after);

        after.EndedByLevelFinalize.Should().BeFalse("không có cờ thì không có kết-thúc-sớm để ghi");
        LinesShouldBeUnpriced(after, "mới Bước 1 Cấp 1 — chưa có gì để chốt");

        // Và phiếu ĐANG đứng ở Cấp mang cờ vẫn nguyên (cờ là thuộc tính của CẤP, không phải
        // của quy trình/phiếu — một lượt duyệt ở Cấp khác không được kích hoạt nó).
        var atFlag = await ReadPlanAsync(f, sc.PlanAtFlagLevel);
        ShouldBeAt(atFlag, ContractSigningPlanPhase.ChoDuyet, 1, 2, "phiếu kia bất động");
        atFlag.EndedByLevelFinalize.Should().BeFalse("cờ chỉ bật khi CHÍNH Cấp mang cờ bấm duyệt");
    }

    // ===================================================================
    // (T-e) GIỮ CỜ QUA LỆNH TẠO QUY TRÌNH — `ApprovalWorkflowV2AdminFeatures.cs:376-385`.
    //
    // Trước @K3, handler tạo version ÉP `AllowApproverFinalize = false` riêng cho type-10
    // (containment thời "PHÁ VỠ" S155). Owner đảo phán quyết ⇒ ép-false đã gỡ. Nếu ai đó
    // khôi phục nó thì admin vẫn tick được cờ trên Designer, DB vẫn im lặng lưu `false`, và
    // T-a phía trên KHÔNG bắt được (T-a set cờ thẳng vào DB, không đi qua lệnh tạo) —
    // đúng hình "config-lie" gotcha #78: giao diện nói một đằng, máy chạy một nẻo.
    //
    // Ba vế để phép đo không rỗng:
    //   (i)  Cấp tick cờ ⇒ DB `true`;
    //   (ii) Cấp KHÔNG tick ⇒ DB `false` (chứng handler không bật bừa mọi dòng);
    //   (iii) ĐỐI CHỨNG type khác (DuyetNcc = 1) cũng `true` ⇒ cơ chế là ECHO tham số, không
    //         phải một nhánh đặc-cách theo loại — vế này là thứ phân biệt "đã gỡ ép-false"
    //         với "đổi ép-false thành ép-true".
    // ===================================================================
    [Fact]
    public async Task CreateAwDefinition_LoaiKhkk_GiuNguyenCoKetThucTaiCap_KhongConEpFalse()
    {
        using var f = new KhkkWorkflowFixture();
        var plain = await AddUserAsync(f, "NV duyệt Cấp 1");
        var finalizer = await AddUserAsync(f, "TP.CCM — Cấp kết thúc");

        var khkkId = await CreateAwDefinitionAsync(
            f, ApprovalWorkflowApplicableType.ContractSigningPlan, "KHKK-N1", plain.Id, finalizer.Id);
        var peId = await CreateAwDefinitionAsync(
            f, ApprovalWorkflowApplicableType.DuyetNcc, "QT-PE-DOICHUNG", plain.Id, finalizer.Id);

        var khkk = await ReadLevelFlagsAsync(f, khkkId);
        khkk.Should().HaveCount(2, "lệnh tạo dựng đúng 2 Cấp");

        khkk.Single(l => l.Approver == finalizer.Id).Finalize.Should().BeTrue(
            "admin tick 'Cấp này KẾT THÚC' thì DB phải lưu true — ép false ở đây là cờ chết: "
            + "Designer hiện đã tick, service `…WorkflowService.cs:276` đọc ra false, "
            + "và không ai biết cho tới khi phiếu thật treo chờ CEO");
        khkk.Single(l => l.Approver == plain.Id).Finalize.Should().BeFalse(
            "Cấp KHÔNG tick phải là false — nếu true thì handler đang bật bừa, và vế trên xanh vô nghĩa");

        var pe = await ReadLevelFlagsAsync(f, peId);
        pe.Single(l => l.Approver == finalizer.Id).Finalize.Should().BeTrue(
            "ĐỐI CHỨNG: loại quy trình khác cũng giữ cờ ⇒ cơ chế là echo tham số của lệnh, "
            + "không phải một nhánh đặc-cách theo `ApplicableType`");
    }

    private sealed record LevelFlag(int Order, Guid Approver, bool Finalize);

    /// Tạo 1 quy trình 1 Bước × 2 Cấp qua lệnh THẬT: Cấp 1 không tick, Cấp 2 tick kết-thúc.
    private static async Task<Guid> CreateAwDefinitionAsync(
        KhkkWorkflowFixture f, ApprovalWorkflowApplicableType type, string code,
        Guid plainApprover, Guid finalizeApprover)
    {
        await using var db = f.NewDb(TestCurrentUser.System());
        var handler = new CreateAwDefinitionCommandHandler(db);
        return await handler.Handle(
            new CreateAwDefinitionCommand(
                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", plainApprover),
                        new CreateAwLevelInput(2, "Cấp 2", finalizeApprover, AllowApproverFinalize: true),
                    ]),
                ]),
            CancellationToken.None);
    }

    private static async Task<List<LevelFlag>> ReadLevelFlagsAsync(KhkkWorkflowFixture f, Guid workflowId)
    {
        await using var db = f.NewDb(TestCurrentUser.System());
        return await db.ApprovalWorkflowLevels.AsNoTracking()
            .Where(l => l.Step!.ApprovalWorkflowId == workflowId)
            .OrderBy(l => l.Order)
            .Select(l => new LevelFlag(l.Order, l.ApproverUserId, l.AllowApproverFinalize))
            .ToListAsync(CancellationToken.None);
    }

    // ###################################################################
    // [S182 W1 — YC-013, 2026-08-08] T4-T7: rào NHÓM-QUY-TRÌNH 3-site + pin-check D6.
    // SPEC: `.claude/workflows/runs/2026-08-08-S182-khkk-awf-mirror-ncc/
    //        spec-khkk-awf-mirror-ncc-08-08-2026.md` MỤC 2 W1 fix (b) + (b2).
    //
    // 🔴 VẤN ĐỀ: rào "phiếu nhóm N{g} chỉ pin được quy trình `KHKK-N{g}`" hôm nay chỉ đứng ở
    //    ĐÚNG MỘT CỬA — `CreateContractSigningPlanCommandHandler` (`Features.cs:479-486`).
    //    Hai cửa còn lại đi thẳng: UpdateDraft `:603-607` chỉ gọi `EnsureWorkflowTypeAsync`,
    //    và `SubmitAsync` (`ContractSigningPlanWorkflowService.cs:176-189`) không hỏi câu này
    //    bao giờ. Hệ quả không phải "409 muộn" mà là KÝ SAI DÂY CHUYỀN: phiếu nhóm 3 chạy trạm
    //    của nhóm 5 ⇒ hồ sơ tới tay người không có thẩm quyền ký, im lặng, đúng luồng UI.
    //    W1b gom logic vào `ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync`
    //    và cắm đủ 3 cửa.
    //
    // ⛔ KHÔNG nhân bản cửa Create ở đây. Cửa đó ĐÃ có lưới đủ răng từ K3/S164 —
    //    `ContractSigningPlanGroupAndCatalogTests.Create_PinQuyTrinhKhacNhomDuyet_ThiBiChan_VaKhongDotMa`
    //    (chặn sai-nhóm + cho-qua-ngoài-họ + không-đốt-mã), đang XANH ở baseline. Nó chính là
    //    chứng-nhân "phép dời logic sang class dùng chung KHÔNG đổi ngữ nghĩa cửa cũ"; chép lại
    //    60 dòng seed PE vào file này chỉ đẻ bản sao đo cùng một thứ. T4-T7 chỉ viết phần nó
    //    KHÔNG đo: 2 cửa mới + rào D6.
    //
    // 🔎 Chữ trong thông điệp 409: spec đưa RA HAI bản (giữ nguyên câu Create `:483-486`, hoặc
    //    câu mới W1b "Phiếu nhóm N{g} đang gắn quy trình N{k}…"). Cả hai đều hợp lệ ⇒ test pin
    //    cái BẤT BIẾN giữa hai bản: thông điệp phải nêu CẢ nhóm-của-phiếu LẪN nhóm-của-quy-trình,
    //    vì thiếu một trong hai thì người dùng đọc xong vẫn không biết phải đổi cái gì.
    //    (Nếu BE chọn câu mới nguyên văn ⇒ nó BỎ mã quy trình và làm ĐỎ assert `Contain("KHKK-N5")`
    //     của test K3 nói trên — đã báo lead ở §8 artifact, KHÔNG tự nới test cũ.)
    // ###################################################################

    private static async Task UpdateDraftAsync(
        KhkkWorkflowFixture f, TestCurrentUser actor, Guid planId,
        Guid? awId = null, string? ghiChu = null)
    {
        await using var db = f.NewDb(actor);
        var handler = new UpdateContractSigningPlanDraftCommandHandler(db, actor);
        await handler.Handle(
            new UpdateContractSigningPlanDraftCommand(planId, GhiChu: ghiChu, HoSoLink: null,
                ApprovalWorkflowId: awId),
            CancellationToken.None);
    }

    /// Đọc pin TỪ ĐĨA (context mới). Mọi vế "409 rồi thì không đổi gì" phải đo ở đây chứ không
    /// đo trên entity trong bộ nhớ — handler gán `plan.ApprovalWorkflowId` TRƯỚC khi
    /// `SaveChangesAsync` chạy, nên bản in-memory nói dối được.
    private static async Task<Guid?> ReadPinAsync(KhkkWorkflowFixture f, Guid planId)
    {
        await using var db = f.NewDb(TestCurrentUser.System());
        return await db.ContractSigningPlans.AsNoTracking()
            .Where(p => p.Id == planId)
            .Select(p => p.ApprovalWorkflowId)
            .SingleAsync(CancellationToken.None);
    }

    private static async Task<int> ChangelogCountAsync(KhkkWorkflowFixture f, Guid planId)
    {
        await using var db = f.NewDb(TestCurrentUser.System());
        return await db.ContractSigningPlanChangelogs.AsNoTracking()
            .CountAsync(c => c.ContractSigningPlanId == planId, CancellationToken.None);
    }

    private static async Task ArchiveWorkflowAsync(KhkkWorkflowFixture f, Guid workflowId)
    {
        await using var db = f.NewDb(TestCurrentUser.System());
        var wf = await db.ApprovalWorkflows.SingleAsync(w => w.Id == workflowId, CancellationToken.None);
        wf.IsActive = false;
        await db.SaveChangesAsync(CancellationToken.None);
    }

    // ===================================================================
    // (T4) Cửa UpdateDraft — re-pin sang quy trình LỆCH NHÓM ⇒ 409.
    //
    // Đây là cửa NGUY HIỂM NHẤT trong ba: Create bị chặn thì người dùng thấy ngay và chọn lại;
    // còn ở đây phiếu ĐÃ hợp lệ rồi mới bị đổi ruột — lần sau nhìn vào chỉ thấy một phiếu Nháp
    // bình thường, không có dấu vết nào nói nó vừa bị chuyển dây chuyền ký.
    //
    // Ba vế, mỗi vế bịt một kiểu xanh-giả:
    //   (1) chặn + thông điệp nêu ĐỦ hai nhóm;
    //   (2) ĐĨA bất động: pin cũ còn nguyên VÀ 0 dòng changelog — handler gán pin rồi mới
    //       `SaveChanges` ở cuối, nên chỉ assert "có ném" là không phân biệt được "chặn kịp"
    //       với "chặn sau khi đã ghi";
    //   (3) CONTROL DƯƠNG cùng lượt: re-pin sang bản v02 CÙNG NHÓM ⇒ qua, và changelog nhảy
    //       0 → 1. Thiếu vế này thì một rào chặn-tuốt (hoặc lỗi seed làm mọi UpdateDraft ném)
    //       vẫn cho (1)+(2) xanh.
    // ===================================================================
    [Fact]
    public async Task T4_UpdateDraft_RePinQuyTrinhLechNhom_ThiBiChan_VaGiuNguyenPinCu()
    {
        using var f = new KhkkWorkflowFixture();
        var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        var approver = await AddUserAsync(f, "NV duyệt");

        var n3v1 = await SeedWorkflowAsync(f, "KHKK-N3", steps: [[[approver.Id]]]);
        var n3v2 = await SeedWorkflowAsync(f, "KHKK-N3", steps: [[[approver.Id]]], version: 2);
        var n5 = await SeedWorkflowAsync(f, "KHKK-N5", steps: [[[approver.Id]]]);

        var planId = await SeedPlanAsync(f, drafter, n3v1, "KHKK/2026/301",
            ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 90m)],
            approvalGroup: 3);

        // ---- (1) Re-pin sang quy trình của nhóm KHÁC.
        var act = async () => await UpdateDraftAsync(f, drafter, planId, awId: n5);
        var ex = await act.Should().ThrowAsync<ConflictException>(
            "sửa nháp là cửa thứ hai vào cùng một chỗ — rào chỉ đứng ở Create thì nó vô hiệu "
            + "bằng đúng một request PUT hợp lệ về mọi mặt khác");
        ex.Which.Message.Should().Contain("N5").And.Contain("N3",
            "thông điệp phải nêu CẢ nhóm của quy trình lẫn nhóm của phiếu — thiếu một vế thì "
            + "người dùng biết mình sai nhưng không biết sai ở đâu");

        // ---- (2) Đĩa bất động.
        (await ReadPinAsync(f, planId)).Should().Be(n3v1,
            "409 mà pin đã đổi = rào ném SAU khi ghi; phiếu vẫn chạy sai dây chuyền, chỉ thêm một lỗi đỏ");
        (await ChangelogCountAsync(f, planId)).Should().Be(0,
            "lệnh bị chặn không được để lại dòng 'Cập nhật thông tin kế hoạch ký kết' — vết ma "
            + "làm người đọc lịch sử tin rằng đã có thay đổi");

        // ---- (3) CONTROL DƯƠNG: đổi đúng MỘT biến (nhóm của quy trình đích) ⇒ kết quả trái ngược.
        await UpdateDraftAsync(f, drafter, planId, awId: n3v2, ghiChu: "Đổi sang bản v02 cùng nhóm");
        (await ReadPinAsync(f, planId)).Should().Be(n3v2,
            "cùng cửa, cùng người, cùng phiếu — chỉ khác nhóm của quy trình đích ⇒ rào SO KHỚP "
            + "chứ không phải chặn mọi lệnh re-pin");
        (await ChangelogCountAsync(f, planId)).Should().Be(1,
            "và phép đếm changelog ở (2) là phép đo SỐNG: nó biết tăng khi lệnh thật sự chạy");
    }

    // ===================================================================
    // (T5) Cửa Submit — phiếu pin lệch nhóm TỪ TRƯỚC (data cũ / lách API) ⇒ 409 lúc trình.
    //
    // Vì sao cửa này bắt buộc phải có dù đã chặn ở Create + UpdateDraft: prod HÔM NAY có thể
    // đang mang phiếu pin lệch (spec W0 §0.4 chạy hẳn một query audit để đếm trước khi vá).
    // Rào ở hai cửa GHI không dọn được thứ đã nằm sẵn trong bảng — chỉ cửa TRÌNH mới chặn được
    // phiếu cũ trước khi nó bước vào dây chuyền ký sai.
    // Seed thẳng DbContext là CỐ Ý: đó là cách duy nhất dựng lại trạng thái "đã lệch từ trước"
    // sau khi rào Create đóng.
    //
    // 🔴 Điều kiện để phép đo có nghĩa: `SubmitAsync` có SẴN 3 cửa 409 khác (phase, hạng mục
    //    chưa gán, hạng mục sai nhóm) ⇒ bắt được `ConflictException` là chưa nói lên gì.
    //    Fixture đã kéo hạng mục theo `approvalGroup` của phiếu, nên hai rào hạng mục im;
    //    và phiếu CHỨNG NHÂN pin đúng nhóm trình LỌT trong cùng một lượt chứng minh phần còn
    //    lại của đường trình vẫn thông.
    // ===================================================================
    [Fact]
    public async Task T5_Submit_PhieuPinLechNhomTuTruoc_ThiBiChan_ChuKhongTrinhQua()
    {
        using var f = new KhkkWorkflowFixture();
        var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        var approver = await AddUserAsync(f, "NV duyệt");

        var n3 = await SeedWorkflowAsync(f, "KHKK-N3", steps: [[[approver.Id]]]);
        var n5 = await SeedWorkflowAsync(f, "KHKK-N5", steps: [[[approver.Id]]]);

        // Phiếu nhóm 3 nhưng pin quy trình nhóm 5 — dựng thẳng, mô phỏng phiếu có từ trước rào.
        var lech = await SeedPlanAsync(f, drafter, n5, "KHKK/2026/310",
            ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 90m)],
            approvalGroup: 3);
        // Chứng nhân: y hệt, khác đúng một biến — pin ĐÚNG nhóm.
        var dung = await SeedPlanAsync(f, drafter, n3, "KHKK/2026/311",
            ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 90m)],
            approvalGroup: 3);

        var act = async () => await ActAsync(f, drafter, lech, "submit");
        var ex = await act.Should().ThrowAsync<ConflictException>(
            "phiếu lệch nhóm phải bị chặn Ở CỬA TRÌNH — hai rào kia không với tới dữ liệu đã nằm sẵn trong bảng");
        ex.Which.Message.Should().Contain("N5").And.Contain("N3",
            "thông điệp phải phân biệt được với 3 câu 409 khác của SubmitAsync (phase / hạng mục "
            + "chưa gán / hạng mục sai nhóm), nếu không người dùng đi sửa nhầm chỗ");

        var blocked = await ReadPlanAsync(f, lech);
        ShouldBeAt(blocked, ContractSigningPlanPhase.DangSoanThao, null, null,
            "bị chặn thì phiếu phải Ở NGUYÊN nháp — sang ChoDuyet nửa vời là phiếu treo trong "
            + "hộp thư của người không có thẩm quyền ký");

        // CHỨNG NHÂN cùng lượt: đường trình vẫn thông cho phiếu pin đúng nhóm ⇒ 409 ở trên là
        // của rào NHÓM, không phải một rào khác của SubmitAsync vô tình nổ.
        await ActAsync(f, drafter, dung, "submit");
        var ok = await ReadPlanAsync(f, dung);
        ShouldBeAt(ok, ContractSigningPlanPhase.ChoDuyet, 0, 1,
            "cùng fixture, cùng hạng mục, cùng quy trình 1-Bước-1-Cấp — chỉ khác nhóm của quy "
            + "trình được pin ⇒ hai kết quả trái ngược");
    }

    // ===================================================================
    // (T6) Vế ÂM — mã NGOÀI họ `KHKK-N[1-8]` (quy trình chung/legacy type-10) vẫn đi lọt ở
    // CẢ HAI cửa mới. Ngữ nghĩa này là CHỦ ĐÍCH của rào cũ (`Features.cs:477-478`) và spec W1b
    // ra lệnh giữ nguyên khi dời sang class dùng chung — siết thêm ở đây là khoá cứng mọi phiếu
    // đang pin quy trình chung, mà đường sửa duy nhất lại chính là cửa vừa bị khoá.
    //
    // 🔴 Vế âm một mình là XANH-MÙ: hôm nay rào chưa tồn tại nên "đi lọt" đúng ở cả thế giới
    //    chưa-vá. Vì vậy chân (2) — CÙNG cửa, CÙNG phiếu, chỉ đổi mã đích sang trong-họ ⇒ 409 —
    //    là thứ chứng minh rào ĐANG CHẠY trong chính lượt này; thiếu nó thì T6 không đo gì.
    // ===================================================================
    [Fact]
    public async Task T6_MaNgoaiHoNhom_UpdateDraftVaSubmit_VanDiLot()
    {
        using var f = new KhkkWorkflowFixture();
        var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        var approver = await AddUserAsync(f, "NV duyệt");

        var chung01 = await SeedWorkflowAsync(f, "QT-KHKK-V2-001", steps: [[[approver.Id]]]);
        var chung02 = await SeedWorkflowAsync(f, "QT-KHKK-V2-002", steps: [[[approver.Id]]]);
        var n7 = await SeedWorkflowAsync(f, "KHKK-N7", steps: [[[approver.Id]]]);

        var planId = await SeedPlanAsync(f, drafter, chung01, "KHKK/2026/320",
            ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 90m)],
            approvalGroup: 4);

        // ---- (1) Cửa UpdateDraft: mã ngoài họ ⇒ KHÔNG bị hỏi nhóm.
        await UpdateDraftAsync(f, drafter, planId, awId: chung02);
        (await ReadPinAsync(f, planId)).Should().Be(chung02,
            "quy trình chung không mang số nhóm trong mã ⇒ không có gì để so khớp; chặn ở đây là "
            + "siết oan và phiếu mất luôn đường tự sửa");

        // ---- (2) CHỨNG NHÂN rào đang sống: cùng cửa, cùng phiếu, chỉ đổi mã đích sang TRONG họ.
        var act = async () => await UpdateDraftAsync(f, drafter, planId, awId: n7);
        await act.Should().ThrowAsync<ConflictException>(
            "phiếu nhóm 4 pin `KHKK-N7` phải bị chặn — nếu vế này cũng đi lọt thì (1) và (3) chỉ "
            + "đang mô tả một thế giới CHƯA CÓ rào, không chứng minh ngữ nghĩa cho-qua nào cả");
        (await ReadPinAsync(f, planId)).Should().Be(chung02, "409 thì pin giữ nguyên");

        // ---- (3) Cửa Submit: vẫn mã ngoài họ ⇒ trình lọt.
        await ActAsync(f, drafter, planId, "submit");
        var after = await ReadPlanAsync(f, planId);
        ShouldBeAt(after, ContractSigningPlanPhase.ChoDuyet, 0, 1,
            "rào mới cắm vào SubmitAsync không được nuốt phiếu đang chạy quy trình chung");
    }

    // ===================================================================
    // (T7) D6 — re-pin sang bản ĐÃ NGƯNG HOẠT ĐỘNG / CHƯA GHIM ⇒ 409 (mirror vá-3
    // `CreateContractFromSigningPlanFeatures.cs:141-147`, CÙNG một lỗ ở bridge type-3).
    //
    // Vì sao đáng chặn: sau W1a, "tạo phiên bản mới" hạ `IsActive` bản cũ. Phiếu pin bản đã
    // nghỉ vẫn chạy được (service resolve theo Id, không đọc cờ) nên sai KHÔNG lộ ra — nó lộ ở
    // chỗ khác: người duyệt của bản cũ đã bị thay trong bản mới.
    //
    // Bốn chân trên CÙNG một bộ seed (cả 4 bản đều mã `KHKK-N2` ⇒ rào nhóm im, mọi 409 quan
    // sát được chỉ có thể đến từ D6):
    //   (1) IsActive=false ⇒ 409;
    //   (2) IsUserSelectable=false ⇒ 409, và THÔNG ĐIỆP PHẢI KHÁC (1) — spec đòi 2 câu tách,
    //       vì hai nguyên nhân này sửa ở hai nút khác nhau trong Designer; gộp thành một câu
    //       `!IsActive || !IsUserSelectable` là admin đi bấm nhầm nút;
    //   (3) CONTROL DƯƠNG: bản active + đã ghim ⇒ re-pin qua;
    //   (4) 🔴 GIỮ-NGUYÊN-PIN KHÔNG BỊ HỎI: phiếu cũ đang pin bản đã nghỉ vẫn phải sửa được
    //       GhiChu. Đây là vế phân biệt "check trong nhánh đổi-pin" với "check mọi lần PUT" —
    //       bản vá đặt guard ngoài nhánh sẽ khoá cứng đúng những phiếu cần cứu nhất.
    // ===================================================================
    [Fact]
    public async Task T7_RePinBanDaNgungHoacChuaGhim_ThiBiChan_ConGiuNguyenPinThiVanSuaDuoc()
    {
        using var f = new KhkkWorkflowFixture();
        var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
        var approver = await AddUserAsync(f, "NV duyệt");

        var dangDung = await SeedWorkflowAsync(f, "KHKK-N2", steps: [[[approver.Id]]]);
        var daNghi = await SeedWorkflowAsync(f, "KHKK-N2", steps: [[[approver.Id]]],
            version: 2, isActive: false);
        var chuaGhim = await SeedWorkflowAsync(f, "KHKK-N2", steps: [[[approver.Id]]],
            version: 3, isUserSelectable: false);
        var banMoi = await SeedWorkflowAsync(f, "KHKK-N2", steps: [[[approver.Id]]], version: 4);

        var planId = await SeedPlanAsync(f, drafter, dangDung, "KHKK/2026/330",
            ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 90m)],
            approvalGroup: 2);

        // ---- (1) Bản đã ngưng hoạt động.
        var actNghi = async () => await UpdateDraftAsync(f, drafter, planId, awId: daNghi);
        var exNghi = await actNghi.Should().ThrowAsync<ConflictException>(
            "pin bản đã nghỉ = phiếu chạy dàn người duyệt của một phiên bản không còn hiệu lực");
        (await ReadPinAsync(f, planId)).Should().Be(dangDung);

        // ---- (2) Bản chưa ghim cho người dùng chọn.
        var actGhim = async () => await UpdateDraftAsync(f, drafter, planId, awId: chuaGhim);
        var exGhim = await actGhim.Should().ThrowAsync<ConflictException>(
            "cờ ghim là cách Admin quyết quy trình nào người dùng được chọn — bỏ qua nó ở đường "
            + "PUT thì nút Ghim/Bỏ ghim chỉ còn là trang trí");
        (await ReadPinAsync(f, planId)).Should().Be(dangDung);

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

        // ---- (3) CONTROL DƯƠNG: bản active + đã ghim.
        await UpdateDraftAsync(f, drafter, planId, awId: banMoi, ghiChu: "Chuyển sang bản v04");
        (await ReadPinAsync(f, planId)).Should().Be(banMoi,
            "khác đúng một biến (2 cờ trạng thái của quy trình đích) ⇒ hai kết quả trái ngược");

        // ---- (4) Bản đang pin bị cho nghỉ SAU đó ⇒ phiếu vẫn phải sửa được nội dung.
        await ArchiveWorkflowAsync(f, banMoi);
        await UpdateDraftAsync(f, drafter, planId, awId: banMoi, ghiChu: "Ghi chú sau khi quy trình nghỉ");

        var plan = await ReadPlanAsync(f, planId);
        plan.GhiChu.Should().Be("Ghi chú sau khi quy trình nghỉ",
            "GIỮ NGUYÊN pin thì không có gì để kiểm — đặt guard ngoài nhánh `awId != pin hiện tại` "
            + "là khoá cứng mọi phiếu đang pin bản đã nghỉ, đúng nhóm phiếu cần sửa nhất");
        plan.ApprovalWorkflowId.Should().Be(banMoi, "và pin không được tự đổi trong lượt sửa GhiChu");
    }

    // ===================================================================
    // [YC-016 @S183 — anh chốt "(c) THEO TAY CẦM PHIẾU"] File-khi-duyệt (Purpose=ApprovalAttachment)
    // đi theo TAY cầm phiếu — 8 ca, mỗi ca 1 DbContext MỚI (khuôn (2) đầu file).
    // Guard: `ContractSigningPlanWorkflowGuards.EnsureApprovalAttachmentUploaderAsync`.
    // Control (ca 8): đường căn-cứ (DossierScan) KHÔNG bị rào mới đụng — hành vi cũ giữ nguyên.
    // ===================================================================

    private sealed class FakeStorage : SolutionErp.Application.Common.Interfaces.IFileStorage
    {
        public Task<string> SaveAsync(string relativePath, Stream content, CancellationToken ct = default)
            => Task.FromResult(relativePath);
        public Task<Stream> OpenReadAsync(string relativePath, CancellationToken ct = default)
            => Task.FromResult<Stream>(new MemoryStream());
        public Task DeleteAsync(string relativePath, CancellationToken ct = default) => Task.CompletedTask;
        public bool Exists(string relativePath) => true;
    }

    private static async Task<ContractSigningPlanAttachmentDto> UploadAsync(
        KhkkWorkflowFixture f, TestCurrentUser actor, Guid planId,
        ContractSigningPlanAttachmentPurpose purpose = ContractSigningPlanAttachmentPurpose.ApprovalAttachment)
    {
        await using var db = f.NewDb(actor);
        var handler = new UploadContractSigningPlanAttachmentCommandHandler(db, new FakeStorage(), actor);
        await using var content = new MemoryStream(new byte[] { 1, 2, 3, 4 });
        return await handler.Handle(new UploadContractSigningPlanAttachmentCommand(
            planId, DossierItemId: null, "bien-ban.pdf", "application/pdf", 4, content,
            purpose, Note: "test"), CancellationToken.None);
    }

    private static async Task<int> CountAttachmentsAsync(KhkkWorkflowFixture f, Guid planId)
    {
        await using var db = f.NewDb(TestCurrentUser.System());
        return await db.ContractSigningPlanAttachments
            .CountAsync(a => a.ContractSigningPlanId == planId, CancellationToken.None);
    }

    [Fact]
    public async Task UploadDuyet_Nhap_NguoiSoan_DuocUp()
    {
        using var f = new KhkkWorkflowFixture();
        var drafter = As(await AddUserAsync(f, "Người soạn"), "Drafter");
        var approver = await AddUserAsync(f, "NV duyệt");
        var wf = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]);
        var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/701",
            ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 100m)]);

        var dto = await UploadAsync(f, drafter, planId);

        dto.Purpose.Should().Be(ContractSigningPlanAttachmentPurpose.ApprovalAttachment);
        (await CountAttachmentsAsync(f, planId)).Should().Be(1,
            "Nháp = tay người soạn ⇒ người soạn up file duyệt được (YC-016 nhánh 1)");
    }

    [Fact]
    public async Task UploadDuyet_TraLai_NguoiSoan_DuocUp()
    {
        using var f = new KhkkWorkflowFixture();
        var drafter = As(await AddUserAsync(f, "Người soạn"), "Drafter");
        var approver = await AddUserAsync(f, "NV duyệt");
        var wf = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]);
        var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/702",
            ContractSigningPlanPhase.TraLai, null, null, [new LineSeed(100m, 100m)]);

        await UploadAsync(f, drafter, planId);

        (await CountAttachmentsAsync(f, planId)).Should().Be(1,
            "Trả lại = phiếu về tay người soạn ⇒ vẫn là tay người soạn");
    }

    [Fact]
    public async Task UploadDuyet_ChoDuyet_NguoiSoan_409_DiaBatDong()
    {
        using var f = new KhkkWorkflowFixture();
        var drafter = As(await AddUserAsync(f, "Người soạn"), "Drafter");
        var approver = await AddUserAsync(f, "NV duyệt");
        var wf = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]);
        var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/703",
            ContractSigningPlanPhase.ChoDuyet, 0, 1, [new LineSeed(100m, 100m)]);

        var act = async () => await UploadAsync(f, drafter, planId);

        (await act.Should().ThrowAsync<ConflictException>(
            "phiếu đã rời tay người soạn — up tiếp là nhét tài liệu vào hồ sơ đang duyệt"))
            .Which.Message.Should().Contain("chờ duyệt").And.Contain("người duyệt");
        (await CountAttachmentsAsync(f, planId)).Should().Be(0, "409 thì đĩa phải bất động");
    }

    [Fact]
    public async Task UploadDuyet_ChoDuyet_ThanhVienQuyTrinh_DuocUp()
    {
        using var f = new KhkkWorkflowFixture();
        var drafter = As(await AddUserAsync(f, "Người soạn"), "Drafter");
        var approver1 = await AddUserAsync(f, "NV duyệt Bước 1");
        var approver2 = await AddUserAsync(f, "NV duyệt Bước 2");
        var wf = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver1.Id]], [[approver2.Id]]]);
        var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/704",
            ContractSigningPlanPhase.ChoDuyet, 0, 1, [new LineSeed(100m, 100m)]);

        // approver2 thuộc BƯỚC SAU (chưa tới lượt) — membership là đủ, KHÔNG đòi đúng-lượt
        // (chữ anh @S171 không thu hẹp về acting-level; guard doc-comment khai rõ).
        await UploadAsync(f, As(approver2, "DeptManager"), planId);

        (await CountAttachmentsAsync(f, planId)).Should().Be(1,
            "thành viên quy trình (kể cả bước sau) up được khi phiếu đang chờ duyệt");
    }

    [Fact]
    public async Task UploadDuyet_Nhap_NguoiDuyet_409()
    {
        using var f = new KhkkWorkflowFixture();
        var drafter = As(await AddUserAsync(f, "Người soạn"), "Drafter");
        var approver = await AddUserAsync(f, "NV duyệt");
        var wf = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]);
        var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/705",
            ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 100m)]);

        var act = async () => await UploadAsync(f, As(approver, "DeptManager"), planId);

        (await act.Should().ThrowAsync<ConflictException>(
            "Nháp = phiếu chưa trình — người duyệt chưa có việc gì với nó"))
            .Which.Message.Should().Contain("tay người soạn");
    }

    [Fact]
    public async Task UploadDuyet_ChoDuyet_NgoaiCuoc_409()
    {
        using var f = new KhkkWorkflowFixture();
        var drafter = As(await AddUserAsync(f, "Người soạn"), "Drafter");
        var approver = await AddUserAsync(f, "NV duyệt");
        var outsider = await AddUserAsync(f, "Người ngoài quy trình");
        var wf = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]);
        var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/706",
            ContractSigningPlanPhase.ChoDuyet, 0, 1, [new LineSeed(100m, 100m)]);

        // outsider đã QUA policy module từ controller (ca đo prod @S172) — guard là rào CUỐI.
        var act = async () => await UploadAsync(f, As(outsider, "DeptManager"), planId);

        (await act.Should().ThrowAsync<ConflictException>(
            "đây chính là ca đo prod @S172: qua policy nhưng KHÔNG thuộc tay nào"))
            .Which.Message.Should().Contain("người duyệt trong quy trình");
    }

    [Fact]
    public async Task UploadDuyet_Terminal_ChanCaHai()
    {
        using var f = new KhkkWorkflowFixture();
        var drafter = As(await AddUserAsync(f, "Người soạn"), "Drafter");
        var approver = await AddUserAsync(f, "NV duyệt");
        var wf = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]);
        var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/707",
            ContractSigningPlanPhase.DaDuyet, null, null, [new LineSeed(100m, 100m)]);

        var actDrafter = async () => await UploadAsync(f, drafter, planId);
        var actApprover = async () => await UploadAsync(f, As(approver, "DeptManager"), planId);

        (await actDrafter.Should().ThrowAsync<ConflictException>())
            .Which.Message.Should().Contain("đã kết thúc");
        (await actApprover.Should().ThrowAsync<ConflictException>(
            "terminal = không ai cầm phiếu — hồ sơ đã chốt, cả hai đều bị chặn"))
            .Which.Message.Should().Contain("đã kết thúc");
        (await CountAttachmentsAsync(f, planId)).Should().Be(0);
    }

    [Fact]
    public async Task UploadCanCu_DuongSoan_KhongBiRaoMoiDung()
    {
        using var f = new KhkkWorkflowFixture();
        var drafter = As(await AddUserAsync(f, "Người soạn"), "Drafter");
        var approver = await AddUserAsync(f, "NV duyệt");
        var wf = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]);
        var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/708",
            ContractSigningPlanPhase.ChoDuyet, 0, 1, [new LineSeed(100m, 100m)]);

        // CONTROL: purpose = DossierScan (đường căn-cứ, KHÔNG phải file-khi-duyệt) — guard mới
        // CHỈ rẽ nhánh ApprovalAttachment ⇒ hành vi cũ (cho qua) GIỮ NGUYÊN. Ca này chết nếu
        // ai đó nới guard sang mọi purpose "cho chắc" — đó là đổi hành vi NGOÀI phán quyết YC-016.
        await UploadAsync(f, drafter, planId, ContractSigningPlanAttachmentPurpose.DossierScan);

        (await CountAttachmentsAsync(f, planId)).Should().Be(1,
            "đường căn-cứ giữ nguyên hành vi cũ — YC-016 chỉ phán về file-khi-duyệt");
    }
}
