using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Common;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;
// `NoOpNotificationService` là stub `internal` sống ở
// `Services/PurchaseEvaluationWorkflowServiceGuardTests.cs:188` — dùng lại thay vì chép bản thứ hai
// (bản sao thứ hai của một no-op là thứ sẽ lệch nhau lúc `INotificationService` nở thêm hàm).
using SolutionErp.Infrastructure.Tests.Services;

namespace SolutionErp.Infrastructure.Tests.Application;

// [WQ3-TEST · YC-030 Q3 — S190 2026-08-12] KẾT THÚC TẠI CẤP (level-finalize) của Hợp đồng V2.
//
// Đối tượng đo: nhánh `ContractWorkflowService.ApproveV2Async:527-535` (cờ `EndedByLevelFinalize`
// + `FinalizeToDaPhatHanhAsync:576`). Đây là **test-bù** (mã BE đã land trước, chưa commit) —
// KHÔNG phải test-before; nếu ca nào đỏ thì đỏ đó là phát hiện về mã, không được nới assert.
//
// ── VÌ SAO QUY TRÌNH PHẢI CÓ ĐUÔI (điều kiện SỐNG CÒN của phép đo, bài S164-K3) ──────────────
// Đặt cờ finalize ở slot CUỐI thì "hợp đồng phát hành + có mã" xảy ra ở CẢ HAI thế giới (có
// nhánh finalize / không có nhánh finalize) ⇒ F-1 xanh mà chứng minh 0. Nên cả 4 ca dùng chung
// một hình: **2 Bước** — Bước 1 có **2 Cấp**, Bước 2 có 1 Cấp — và người bấm Duyệt luôn là
// **Bước 1 Cấp 1**, tức còn NGUYÊN 2 chặng phía sau. Gỡ nhánh finalize khỏi production ⇒ F-1 rơi
// về "sang Cấp 2 cùng Bước 1" ⇒ ĐỎ. Hình này được chốt bằng `AssertFinalizeSlotIsNotTheLastAsync`
// gọi ngay trước mỗi lượt bấm, để một lần "gọn hoá" seed sau này không âm thầm làm rỗng cả file.
//
// ── MA TRẬN 3 CA CHỨNG `&&` (cùng ruột, khác ĐÚNG 1 biến) ────────────────────────────────────
//   F-1: cờ Cấp = true  · ý người duyệt = MẶC ĐỊNH (không truyền tham số) ⇒ PHÁT HÀNH
//   F-2: cờ Cấp = true  · applyLevelFinalize = false                      ⇒ đi tiếp bình thường
//   F-3: cờ Cấp = false · applyLevelFinalize = true                       ⇒ đi tiếp bình thường
// F-1 giữ vế dương. F-2/F-3 mỗi ca tắt MỘT vế khác nhau — cài `||` thay `&&` (`:527`) sẽ làm ĐỎ
// cả hai; bỏ đọc tham số ⇒ chỉ F-2 đỏ; bỏ đọc cờ cấu hình ⇒ chỉ F-3 đỏ. Ba ca không thay thế nhau.
// 🔴 F-1 CỐ Ý KHÔNG truyền `applyLevelFinalize`: đó là phép đo DUY NHẤT cho ngữ nghĩa OPT-OUT
//    (`IContractWorkflowService.cs:25` mặc định `true`). Truyền tay `true` là bỏ mất vế đó.
//
// ── F-4: bất biến sau khi đã phát hành ───────────────────────────────────────────────────────
// Đọc mã trước khi chọn assert (lệnh cấm nới): sau finalize `Phase=DaPhatHanh` ⇒ lượt bấm Duyệt
// kế tiếp trượt hết mọi nhánh của `TransitionAsync` (reject `:55` / trình `:96` / duyệt `:159`
// đều đòi phase khác) và rơi xuống câu cuối `:268` `ConflictException("Transition … không hỗ
// trợ.")`. Vậy assert đúng là **409 + không đổi gì**, KHÔNG phải "im lặng bỏ qua".
//
// ── 2 quyết định hạ tầng ─────────────────────────────────────────────────────────────────────
// (1) `IdentityFixture` (KHÔNG phải fixture riêng có `AuditingInterceptor` kiểu
//     `ContractDeleteGuardTests.cs:69`). Service đòi `UserManager<User>` thật, còn interceptor chỉ
//     phục vụ ô audit/xoá-mềm — file này không đo ô nào trong số đó. Bê interceptor vào đây là
//     nghi thức thừa, lại phải dựng lại toàn bộ Identity wiring. Khuôn theo file cùng đối tượng đo:
//     `Services/ContractWorkflowServiceApproveV2Tests.cs:25`.
// (2) Mỗi ca một fixture ⇒ một DB SQLite in-memory riêng ⇒ `ContractCodeSequences` không dính
//     nhau; mã HĐ mong đợi luôn là `/01`. Vẫn đặt Code NCC/dự án khác nhau từng ca cho dễ đọc log.
public class ContractApproveFinalizeTests
{
    private static readonly DateTime Clock2026 = new(2026, 8, 12, 3, 0, 0, DateTimeKind.Utc);

    // Dựng service đúng 6 dep như prod (`ContractWorkflowService.cs:28-34`), changelog THẬT để
    // còn đọc được dòng nhật ký phân biệt finalize ⟂ advance thường.
    private static (ContractWorkflowService svc, TestApplicationDbContext db) WireService(
        IdentityFixture fix, Guid actorId, params string[] actorRoles)
    {
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var um = fix.Services.GetRequiredService<UserManager<User>>();
        var dt = new FixedDateTime(Clock2026);
        var currentUser = new TestCurrentUser { UserId = actorId, Roles = actorRoles };
        var changelog = new ChangelogService(db, currentUser, um);
        var svc = new ContractWorkflowService(
            db, new ContractCodeGenerator(db, dt), dt, new NoOpNotificationService(), changelog, um);
        return (svc, db);
    }

    // Quy trình 2 BƯỚC: Bước 1 (Cấp 1 + Cấp 2) → Bước 2 (Cấp 1). Cờ finalize chỉ gắn ở slot
    // Bước 1 Cấp 1 — đúng chỗ người duyệt sẽ bấm, và là slot còn 2 chặng phía sau.
    private static async Task<(ApprovalWorkflow wf, ApprovalWorkflowStep s1, ApprovalWorkflowLevel s1l1)>
        SeedTwoStepWorkflowAsync(
            TestApplicationDbContext db,
            Guid s1l1Approver,
            Guid s1l2Approver,
            Guid s2l1Approver,
            bool allowFinalizeAtS1L1,
            string code)
    {
        var wf = new ApprovalWorkflow
        {
            Id = Guid.NewGuid(),
            Code = code,
            Version = 1,
            Name = "Quy trình duyệt HĐ — 2 Bước",
            ApplicableType = ApprovalWorkflowApplicableType.Contract,
            IsActive = true,
            IsUserSelectable = true,
        };
        var s1 = new ApprovalWorkflowStep
        {
            Id = Guid.NewGuid(),
            ApprovalWorkflowId = wf.Id,
            Order = 1,
            DepartmentId = null,
            Name = "Bước 1 Phòng Mua hàng",
        };
        var s2 = new ApprovalWorkflowStep
        {
            Id = Guid.NewGuid(),
            ApprovalWorkflowId = wf.Id,
            Order = 2,
            DepartmentId = null,
            Name = "Bước 2 Ban Giám đốc",
        };
        var s1l1 = new ApprovalWorkflowLevel
        {
            Id = Guid.NewGuid(),
            ApprovalWorkflowStepId = s1.Id,
            Order = 1,
            ApproverUserId = s1l1Approver,
            AllowApproverFinalize = allowFinalizeAtS1L1,
        };
        var s1l2 = new ApprovalWorkflowLevel
        {
            Id = Guid.NewGuid(),
            ApprovalWorkflowStepId = s1.Id,
            Order = 2,
            ApproverUserId = s1l2Approver,
        };
        var s2l1 = new ApprovalWorkflowLevel
        {
            Id = Guid.NewGuid(),
            ApprovalWorkflowStepId = s2.Id,
            Order = 1,
            ApproverUserId = s2l1Approver,
        };
        db.ApprovalWorkflows.Add(wf);
        db.ApprovalWorkflowSteps.AddRange(s1, s2);
        db.ApprovalWorkflowLevels.AddRange(s1l1, s1l2, s2l1);
        await db.SaveChangesAsync(CancellationToken.None);
        return (wf, s1, s1l1);
    }

    // TIỀN ĐỀ chống-rỗng: slot sắp bấm Duyệt (Bước 1 Cấp 1) phải còn ĐUÔI phía sau. Xem khối
    // "VÌ SAO QUY TRÌNH PHẢI CÓ ĐUÔI" ở đầu file — thiếu vế này thì F-1 đúng ở mọi thế giới.
    private static async Task AssertFinalizeSlotIsNotTheLastAsync(
        TestApplicationDbContext db, Guid workflowId, Guid step1Id)
    {
        (await db.ApprovalWorkflowSteps.CountAsync(s => s.ApprovalWorkflowId == workflowId))
            .Should().Be(2, "phải còn Bước 2 phía sau thì 'kết thúc sớm' mới khác 'đi hết quy trình'");
        (await db.ApprovalWorkflowLevels.CountAsync(l => l.ApprovalWorkflowStepId == step1Id))
            .Should().Be(2, "ngay trong Bước 1 cũng còn Cấp 2 chưa duyệt");
    }

    private static async Task<(Supplier sup, Project proj)> SeedSupplierProjectAsync(
        TestApplicationDbContext db, string supplierCode, string projectCode)
    {
        var sup = new Supplier
        {
            Id = Guid.NewGuid(),
            Code = supplierCode,
            Name = $"NCC {supplierCode}",
            Type = SupplierType.NhaThauPhu,
        };
        var proj = new Project
        {
            Id = Guid.NewGuid(),
            Code = projectCode,
            Name = $"Dự án {projectCode}",
        };
        db.Suppliers.Add(sup);
        db.Projects.Add(proj);
        await db.SaveChangesAsync(CancellationToken.None);
        return (sup, proj);
    }

    // HĐ đang chờ duyệt, con-trỏ đứng ở Bước 1 (index 0) Cấp 1 — đúng hình sau khi người soạn trình.
    private static Contract BuildContractAtStep0Level1(Guid workflowId, Guid supplierId, Guid projectId)
        => new()
        {
            Id = Guid.NewGuid(),
            Type = ContractType.HopDongThauPhu,
            Phase = ContractPhase.ChoDuyet,
            SupplierId = supplierId,
            ProjectId = projectId,
            DrafterUserId = Guid.NewGuid(),
            TenHopDong = "HĐ thầu phụ — ca level-finalize",
            GiaTri = 800_000_000m,
            ApprovalWorkflowId = workflowId,
            CurrentWorkflowStepIndex = 0,
            CurrentApprovalLevelOrder = 1,
        };

    private static async Task<(User a1, User a2, User a3)> SeedThreeApproversAsync(
        IdentityFixture fix, string tag)
    {
        var a1 = await fix.CreateUserAsync($"b1c1-{tag}@test.local", $"NV Bước1 Cấp1 {tag}",
            departmentId: null, roles: new[] { AppRoles.CostControl });
        var a2 = await fix.CreateUserAsync($"b1c2-{tag}@test.local", $"NV Bước1 Cấp2 {tag}",
            departmentId: null, roles: new[] { AppRoles.CostControl });
        var a3 = await fix.CreateUserAsync($"b2c1-{tag}@test.local", $"NV Bước2 Cấp1 {tag}",
            departmentId: null, roles: new[] { AppRoles.CostControl });
        return (a1, a2, a3);
    }

    // =====================================================================================
    // F-1 — Cấp có cờ + người duyệt KHÔNG bỏ tick (mặc định) ⇒ HĐ PHÁT HÀNH ngay tại đây.
    // 5 vế đo, mỗi vế canh một tầng khác nhau của `FinalizeToDaPhatHanhAsync`:
    //   (a) Phase = DaPhatHanh                → có kết thúc thật
    //   (b) EndedByLevelFinalize = true       → kết thúc BẰNG ĐƯỜNG NÀO (cột runtime Mig 73)
    //   (c) MaHopDong sinh đúng RG-001        → không đẻ HĐ phát hành mà rỗng mã (luật choke-point)
    //   (d) con-trỏ Bước/Cấp + SLA = null     → không kẹt trong hộp thư cấp đã xong việc
    //   (e) đọc lại từ DB                     → cờ được LƯU, không chỉ set trong bộ nhớ
    // =====================================================================================
    [Fact]
    public async Task ApproveV2_LevelFinalizeFlag_WithDefaultApply_PublishesContractAndSetsRuntimeFlag()
    {
        var fix = new IdentityFixture();
        using (fix)
        {
            var (a1, a2, a3) = await SeedThreeApproversAsync(fix, "f1");
            var (svc, db) = WireService(fix, a1.Id, AppRoles.CostControl);
            var (wf, s1, _) = await SeedTwoStepWorkflowAsync(
                db, a1.Id, a2.Id, a3.Id, allowFinalizeAtS1L1: true, code: "QT-CT-FIN1");
            var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "FIN1", projectCode: "PFIN1");
            var contract = BuildContractAtStep0Level1(wf.Id, sup.Id, proj.Id);
            db.Contracts.Add(contract);
            await db.SaveChangesAsync(CancellationToken.None);

            await AssertFinalizeSlotIsNotTheLastAsync(db, wf.Id, s1.Id);

            // 🔴 KHÔNG truyền `applyLevelFinalize` — đo luôn mặc định OPT-OUT của hợp đồng dịch vụ.
            await svc.TransitionAsync(
                contract: contract,
                targetPhase: ContractPhase.ChoDuyet,
                actorUserId: a1.Id,
                actorRoles: new[] { AppRoles.CostControl },
                decision: ApprovalDecision.Approve,
                comment: "đủ hồ sơ, phát hành luôn",
                ct: CancellationToken.None);

            contract.Phase.Should().Be(ContractPhase.DaPhatHanh,
                "Cấp bật 'kết thúc tại cấp' bấm Duyệt ⇒ HĐ phát hành ngay, không trình tiếp Cấp 2 / Bước 2");
            contract.EndedByLevelFinalize.Should().BeTrue(
                "cột runtime phải nói HĐ kết thúc BẰNG ĐƯỜNG level-finalize, không suy từ cấu hình (bài #78)");
            contract.MaHopDong.Should().Be("PFIN1/HĐTP/SOL&FIN1/01",
                "kết thúc sớm vẫn phải đi qua đường terminal duy nhất ⇒ có mã RG-001 như HĐ đi hết quy trình");
            contract.CurrentWorkflowStepIndex.Should().BeNull("terminal xoá con-trỏ Bước");
            contract.CurrentApprovalLevelOrder.Should().BeNull("terminal xoá con-trỏ Cấp");
            contract.SlaDeadline.Should().BeNull("hết hạn xử lý vì không còn ai phải xử lý");

            // (e) cờ phải nằm trong DB — `TransitionAsync:168` SaveChanges sau nhánh V2.
            var row = await db.Contracts.AsNoTracking().SingleAsync(c => c.Id == contract.Id);
            row.EndedByLevelFinalize.Should().BeTrue("cờ được LƯU, không chỉ sống trong entity đang track");
            row.Phase.Should().Be(ContractPhase.DaPhatHanh);
            row.MaHopDong.Should().Be("PFIN1/HĐTP/SOL&FIN1/01");

            // Vết nhật ký phân biệt hẳn với terminal thường (dòng terminal thường không mang chữ này).
            var changelogs = await db.ContractChangelogs
                .Where(c => c.ContractId == contract.Id && c.EntityType == ChangelogEntityType.Workflow)
                .ToListAsync();
            changelogs.Should().Contain(c => c.ContextNote != null && c.ContextNote.Contains("KẾT THÚC TẠI CẤP"),
                "người đọc lịch sử phải phân biệt được 'phát hành vì hết quy trình' ⟂ 'phát hành vì cấp này chốt'");

            // Ý kiến cấp duyệt vẫn được UPSERT trước khi finalize (`:442-469`) — kết thúc sớm
            // KHÔNG được nuốt mất chữ ký của chính người bấm.
            var opinions = await db.ContractLevelOpinions.Where(o => o.ContractId == contract.Id).ToListAsync();
            opinions.Should().HaveCount(1);
            opinions[0].SignedByUserId.Should().Be(a1.Id);
            opinions[0].Comment.Should().Be("đủ hồ sơ, phát hành luôn");
        }
    }

    // =====================================================================================
    // F-2 — CÙNG seed F-1, đổi ĐÚNG một biến: `applyLevelFinalize=false` (người duyệt bỏ tick).
    // Vế OPT-OUT: cờ cấu hình bật KHÔNG đủ để kết thúc. Kết quả phải là advance thường.
    // =====================================================================================
    [Fact]
    public async Task ApproveV2_LevelFinalizeFlag_ButApproverOptsOut_AdvancesNormally()
    {
        var fix = new IdentityFixture();
        using (fix)
        {
            var (a1, a2, a3) = await SeedThreeApproversAsync(fix, "f2");
            var (svc, db) = WireService(fix, a1.Id, AppRoles.CostControl);
            var (wf, s1, _) = await SeedTwoStepWorkflowAsync(
                db, a1.Id, a2.Id, a3.Id, allowFinalizeAtS1L1: true, code: "QT-CT-FIN2");
            var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "FIN2", projectCode: "PFIN2");
            var contract = BuildContractAtStep0Level1(wf.Id, sup.Id, proj.Id);
            db.Contracts.Add(contract);
            await db.SaveChangesAsync(CancellationToken.None);

            await AssertFinalizeSlotIsNotTheLastAsync(db, wf.Id, s1.Id);

            await svc.TransitionAsync(
                contract: contract,
                targetPhase: ContractPhase.ChoDuyet,
                actorUserId: a1.Id,
                actorRoles: new[] { AppRoles.CostControl },
                decision: ApprovalDecision.Approve,
                comment: "duyệt nhưng để Cấp 2 xem tiếp",
                applyLevelFinalize: false,
                ct: CancellationToken.None);

            contract.Phase.Should().Be(ContractPhase.ChoDuyet,
                "bỏ tick 'kết thúc tại cấp' ⇒ HĐ đi tiếp như một lượt duyệt bình thường");
            contract.CurrentApprovalLevelOrder.Should().Be(2, "sang Cấp 2 cùng Bước 1");
            contract.CurrentWorkflowStepIndex.Should().Be(0, "vẫn trong Bước 1 vì còn Cấp 2");
            contract.EndedByLevelFinalize.Should().BeFalse(
                "không kết thúc sớm thì tuyệt đối không được gắn nhãn kết thúc sớm");
            contract.MaHopDong.Should().BeNull("chưa phát hành thì chưa cấp mã HĐ");
            contract.SlaDeadline.Should().NotBeNull("SLA đặt lại cho Cấp 2 nhận việc");

            var row = await db.Contracts.AsNoTracking().SingleAsync(c => c.Id == contract.Id);
            row.EndedByLevelFinalize.Should().BeFalse();
            row.Phase.Should().Be(ContractPhase.ChoDuyet);

            var changelogs = await db.ContractChangelogs
                .Where(c => c.ContractId == contract.Id && c.EntityType == ChangelogEntityType.Workflow)
                .ToListAsync();
            changelogs.Should().Contain(c => c.ContextNote != null
                && c.ContextNote.Contains("Hoàn tất Cấp 1, sang Cấp 2 cùng Bước 1"));
            changelogs.Should().NotContain(c => c.ContextNote != null && c.ContextNote.Contains("KẾT THÚC TẠI CẤP"),
                "không được để lại vết 'kết thúc tại cấp' cho một lượt duyệt bình thường");
        }
    }

    // =====================================================================================
    // F-3 — Vế thứ hai của phép `&&`: Cấp KHÔNG có cờ, người duyệt gửi `applyLevelFinalize=true`.
    // Ý người dùng KHÔNG tự cấp được quyền kết thúc — quyền đó do Admin gắn ở Designer.
    // (Ca này là cửa chặn thật: `ApplyLevelFinalize` đến từ body request, ai cũng gửi được true.)
    // =====================================================================================
    [Fact]
    public async Task ApproveV2_NoLevelFinalizeFlag_EvenWhenApplyRequested_AdvancesNormally()
    {
        var fix = new IdentityFixture();
        using (fix)
        {
            var (a1, a2, a3) = await SeedThreeApproversAsync(fix, "f3");
            var (svc, db) = WireService(fix, a1.Id, AppRoles.CostControl);
            var (wf, s1, s1l1) = await SeedTwoStepWorkflowAsync(
                db, a1.Id, a2.Id, a3.Id, allowFinalizeAtS1L1: false, code: "QT-CT-FIN3");
            var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "FIN3", projectCode: "PFIN3");
            var contract = BuildContractAtStep0Level1(wf.Id, sup.Id, proj.Id);
            db.Contracts.Add(contract);
            await db.SaveChangesAsync(CancellationToken.None);

            await AssertFinalizeSlotIsNotTheLastAsync(db, wf.Id, s1.Id);
            // Chốt biến đang bị tắt là ĐÚNG biến định tắt (seed sai cờ ⇒ ca này trùng F-2).
            (await db.ApprovalWorkflowLevels.AsNoTracking().SingleAsync(l => l.Id == s1l1.Id))
                .AllowApproverFinalize.Should().BeFalse("ca này tắt vế CẤU HÌNH, giữ nguyên vế ý-người-duyệt");

            await svc.TransitionAsync(
                contract: contract,
                targetPhase: ContractPhase.ChoDuyet,
                actorUserId: a1.Id,
                actorRoles: new[] { AppRoles.CostControl },
                decision: ApprovalDecision.Approve,
                comment: "tự xin kết thúc dù Cấp không được phép",
                applyLevelFinalize: true,
                ct: CancellationToken.None);

            contract.Phase.Should().Be(ContractPhase.ChoDuyet,
                "Cấp không được Admin cho phép kết thúc ⇒ xin cũng không được, HĐ đi tiếp");
            contract.CurrentApprovalLevelOrder.Should().Be(2);
            contract.CurrentWorkflowStepIndex.Should().Be(0);
            contract.EndedByLevelFinalize.Should().BeFalse();
            contract.MaHopDong.Should().BeNull("không có đường nào phát hành ⇒ không có mã");

            var row = await db.Contracts.AsNoTracking().SingleAsync(c => c.Id == contract.Id);
            row.EndedByLevelFinalize.Should().BeFalse();
            row.Phase.Should().Be(ContractPhase.ChoDuyet);

            var changelogs = await db.ContractChangelogs
                .Where(c => c.ContractId == contract.Id && c.EntityType == ChangelogEntityType.Workflow)
                .ToListAsync();
            changelogs.Should().NotContain(c => c.ContextNote != null && c.ContextNote.Contains("KẾT THÚC TẠI CẤP"));
        }
    }

    // =====================================================================================
    // F-4 — Sau khi đã kết thúc sớm, HĐ là BẤT BIẾN với mọi lượt bấm Duyệt tiếp theo.
    // Hai người bấm, hai lý do đời thực khác nhau, cùng phải 409:
    //   (a) chính người vừa chốt bấm lại (double-click / gửi lại request)
    //   (b) NV Cấp 2 — người vẫn đang thấy HĐ trong hộp thư của mình lúc quy trình bị cắt ngang
    // Vế "không đổi gì" quan trọng ngang vế ném lỗi: một cài đặt chặn-bằng-cách-ném-sau-khi-ghi
    // vẫn xanh nếu chỉ assert exception. Nên đo cả mã HĐ, cả số vết duyệt.
    // =====================================================================================
    [Fact]
    public async Task ApproveV2_AfterLevelFinalize_FurtherApproveThrowsConflict_AndChangesNothing()
    {
        var fix = new IdentityFixture();
        using (fix)
        {
            var (a1, a2, a3) = await SeedThreeApproversAsync(fix, "f4");
            var (svc, db) = WireService(fix, a1.Id, AppRoles.CostControl);
            var (wf, s1, _) = await SeedTwoStepWorkflowAsync(
                db, a1.Id, a2.Id, a3.Id, allowFinalizeAtS1L1: true, code: "QT-CT-FIN4");
            var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "FIN4", projectCode: "PFIN4");
            var contract = BuildContractAtStep0Level1(wf.Id, sup.Id, proj.Id);
            db.Contracts.Add(contract);
            await db.SaveChangesAsync(CancellationToken.None);

            await AssertFinalizeSlotIsNotTheLastAsync(db, wf.Id, s1.Id);

            // Lượt 1 — kết thúc tại cấp (tiền đề của cả ca; xanh ở đây trùng với F-1).
            await svc.TransitionAsync(
                contract: contract,
                targetPhase: ContractPhase.ChoDuyet,
                actorUserId: a1.Id,
                actorRoles: new[] { AppRoles.CostControl },
                decision: ApprovalDecision.Approve,
                comment: "chốt tại Cấp 1",
                ct: CancellationToken.None);

            contract.Phase.Should().Be(ContractPhase.DaPhatHanh, "tiền đề của F-4");
            var maSauFinalize = contract.MaHopDong;
            maSauFinalize.Should().Be("PFIN4/HĐTP/SOL&FIN4/01");

            // (a) chính người vừa chốt bấm lại.
            var actAgainSamePerson = async () => await svc.TransitionAsync(
                contract: contract,
                targetPhase: ContractPhase.ChoDuyet,
                actorUserId: a1.Id,
                actorRoles: new[] { AppRoles.CostControl },
                decision: ApprovalDecision.Approve,
                comment: "bấm lại lần nữa",
                ct: CancellationToken.None);

            await actAgainSamePerson.Should().ThrowAsync<ConflictException>()
                .WithMessage("*DaPhatHanh*không hỗ trợ*",
                    "HĐ đã phát hành không còn nhánh duyệt nào nhận nó (`ContractWorkflowService.cs:268`)");

            // (b) NV Cấp 2 — người bị cắt lượt vẫn còn thấy nút Duyệt trên màn hình cũ.
            var actNextLevelApprover = async () => await svc.TransitionAsync(
                contract: contract,
                targetPhase: ContractPhase.ChoDuyet,
                actorUserId: a2.Id,
                actorRoles: new[] { AppRoles.CostControl },
                decision: ApprovalDecision.Approve,
                comment: "Cấp 2 duyệt trên màn hình cũ",
                ct: CancellationToken.None);

            await actNextLevelApprover.Should().ThrowAsync<ConflictException>()
                .WithMessage("*DaPhatHanh*không hỗ trợ*");

            // Bất biến: không lượt bấm nào để lại dấu vết hay xê dịch trạng thái.
            contract.Phase.Should().Be(ContractPhase.DaPhatHanh);
            contract.MaHopDong.Should().Be(maSauFinalize, "không được cấp mã HĐ lần thứ hai");
            contract.EndedByLevelFinalize.Should().BeTrue("nhãn kết thúc sớm giữ nguyên");
            contract.CurrentWorkflowStepIndex.Should().BeNull("không được hồi sinh con-trỏ Bước");
            contract.CurrentApprovalLevelOrder.Should().BeNull("không được hồi sinh con-trỏ Cấp");

            var approvals = await db.ContractApprovals.Where(a => a.ContractId == contract.Id).ToListAsync();
            approvals.Should().HaveCount(1, "đúng 1 vết duyệt — 2 lượt bấm sau bị chặn TRƯỚC khi ghi");

            var opinions = await db.ContractLevelOpinions.Where(o => o.ContractId == contract.Id).ToListAsync();
            opinions.Should().HaveCount(1, "không đẻ thêm ý kiến cấp duyệt cho lượt bấm bị chặn");

            var row = await db.Contracts.AsNoTracking().SingleAsync(c => c.Id == contract.Id);
            row.Phase.Should().Be(ContractPhase.DaPhatHanh);
            row.MaHopDong.Should().Be(maSauFinalize);
            row.EndedByLevelFinalize.Should().BeTrue();
        }
    }
}
