using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Api.Controllers;                       // Pattern 10 — reflection authz (T25)
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.PurchaseEvaluations;
using SolutionErp.Application.PurchaseEvaluations.Dtos;   // ApprovalWorkflowOptionsDto (T27)
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Contracts;                      // ChangelogAction (dùng chung PE)
using SolutionErp.Domain.Identity;                       // AppRoles + MenuKeys
using SolutionErp.Domain.Master;
using SolutionErp.Domain.Master.Catalogs;
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Persistence;
using SolutionErp.Infrastructure.Persistence.Interceptors;
using SolutionErp.Infrastructure.Tests.Common;

namespace SolutionErp.Infrastructure.Tests.Application;

// =============================================================================================
// S155 ĐỢT 2 (2026-07-27) — XÓA MỀM phiếu khi ĐANG DUYỆT + màn "Đã xóa".
//
// Vật đo: `src/Backend/SolutionErp.Application/PurchaseEvaluations/PeSoftDeleteFeatures.cs`
//   A. `DeletePurchaseEvaluationByApproverCommandHandler` (:50-145)
//   B. `ListDeletedPurchaseEvaluationsQueryHandler`        (:153-248)
//   + `PurchaseEvaluationsController` :163 (`by-approver`) / :174 (`deleted`) — Pattern 10.
//
// 🔴 VÌ SAO FILE NÀY DỰNG FIXTURE RIÊNG (`SoftDeleteFixture`) THAY VÌ DÙNG `IdentityFixture`
// `TestApplicationDbContext` KHÔNG wire `AuditingInterceptor` (`Common/SqliteDbFixture.cs:12-32`;
// prod wire ở `Infrastructure/DependencyInjection.cs:61-62`). 7 file test hiện có né bằng cách
// set `IsDeleted = true` THỦ CÔNG. Đợt này KHÔNG né được: thứ cần đo là chính lời gọi
// `db.PurchaseEvaluations.Remove(pe)` — mà Ý NGHĨA của nó do interceptor quyết định:
//   · CÓ interceptor  ⇒ Deleted→Modified + IsDeleted=true ⇒ ROW Ở LẠI ⇒ màn "Đã xóa" có gì để hiện.
//   · KHÔNG interceptor ⇒ XÓA CỨNG ⇒ mọi assert của T22b/T22c chạy trên list RỖNG ⇒ đúng ở CẢ HAI
//     thế giới ⇒ đo được số không. Đó đúng là bẫy "assert rỗng nghĩa".
// ⇒ fixture này = bản sao `IdentityFixture` + `.AddInterceptors(new AuditingInterceptor(...))`.
// KHÔNG sửa `IdentityFixture` dùng chung (500+ test đang dựa vào hành vi hiện tại của nó).
//
// Test theo CODE (single source of truth). Test-after: code đợt 2 đã land. KHÔNG chạm production.
// =============================================================================================
public class PeDeleteByApproverTests
{
    // Mốc thời gian: peer seed TRƯỚC `CurCreatedAt`; accumulator lấy window `CreatedAt < CurCreatedAt`.
    private static readonly DateTime BaseT = new(2026, 7, 27, 8, 0, 0, DateTimeKind.Utc);
    private static readonly DateTime CurCreatedAt = new(2026, 7, 27, 12, 0, 0, DateTimeKind.Utc);

    // ============================ HẠ TẦNG ============================

    /// Bản sao `IdentityFixture` + AuditingInterceptor để `Remove()` = XÓA MỀM đúng như prod.
    /// `Actor` mutable — đổi người thao tác giữa 2 lời gọi trong cùng test (T24b: bob rồi alice).
    private sealed class SoftDeleteFixture : IDisposable
    {
        private readonly SqliteConnection _conn;
        private readonly ServiceProvider _root;
        public IServiceProvider Services { get; }
        public TestApplicationDbContext Db { get; }
        public TestCurrentUser Actor { get; } = new();
        public FixedDateTime Clock { get; } = new(new DateTime(2026, 7, 27, 3, 0, 0, DateTimeKind.Utc));

        public SoftDeleteFixture()
        {
            _conn = new SqliteConnection("DataSource=:memory:");
            _conn.Open();

            var services = new ServiceCollection();
            services.AddLogging();

            var conn = _conn;
            var actor = Actor;
            var clock = Clock;
            services.AddScoped<ApplicationDbContext>(_ =>
            {
                var options = new DbContextOptionsBuilder<ApplicationDbContext>()
                    .UseSqlite(conn)
                    .EnableSensitiveDataLogging()
                    .AddInterceptors(new AuditingInterceptor(actor, clock))   // 🔴 khác IdentityFixture
                    .Options;
                return new TestApplicationDbContext(options);
            });
            services.AddScoped<TestApplicationDbContext>(sp =>
                (TestApplicationDbContext)sp.GetRequiredService<ApplicationDbContext>());
            services.AddScoped<IApplicationDbContext>(sp =>
                sp.GetRequiredService<TestApplicationDbContext>());

            services.AddIdentityCore<User>(opt =>
                {
                    opt.Password.RequireDigit = false;
                    opt.Password.RequireLowercase = false;
                    opt.Password.RequireUppercase = false;
                    opt.Password.RequireNonAlphanumeric = false;
                    opt.Password.RequiredLength = 4;
                })
                .AddRoles<Role>()
                .AddEntityFrameworkStores<ApplicationDbContext>();

            _root = services.BuildServiceProvider();
            Services = _root.CreateScope().ServiceProvider;
            Db = Services.GetRequiredService<TestApplicationDbContext>();
            Db.Database.EnsureCreated();
        }

        public async Task<User> CreateUserAsync(string email, string fullName, params string[] roles)
        {
            var um = Services.GetRequiredService<UserManager<User>>();
            var rm = Services.GetRequiredService<RoleManager<Role>>();
            foreach (var role in roles)
                if (!await rm.RoleExistsAsync(role))
                    await rm.CreateAsync(new Role { Id = Guid.NewGuid(), Name = role });

            var user = new User
            {
                Id = Guid.NewGuid(),
                UserName = email,
                Email = email,
                EmailConfirmed = true,
                FullName = fullName,
                IsActive = true,
            };
            var created = await um.CreateAsync(user, "Test@123");
            if (!created.Succeeded)
                throw new InvalidOperationException("CreateUserAsync failed: "
                    + string.Join(",", created.Errors.Select(e => e.Description)));
            if (roles.Length > 0) await um.AddToRolesAsync(user, roles);
            return user;
        }

        /// Đặt người đang thao tác cho CẢ handler LẪN interceptor (DeletedBy đọc từ đây).
        public TestCurrentUser ActAs(User u, params string[] roles)
        {
            Actor.UserId = u.Id;
            Actor.FullName = u.FullName;
            Actor.Email = u.Email;
            Actor.Roles = roles;
            return Actor;
        }

        public void Dispose()
        {
            _root.Dispose();
            _conn.Dispose();
        }
    }

    private static DeletePurchaseEvaluationByApproverCommandHandler NewDeleteHandler(SoftDeleteFixture f)
        => new(f.Db, f.Actor);

    private static ListDeletedPurchaseEvaluationsQueryHandler NewDeletedListHandler(
        SoftDeleteFixture f, ICurrentUser who) => new(f.Db, who);

    // ---- seed helpers (mirror PePendingAccumulationTests :58-142) ----

    private static async Task<(Project project, WorkItem wi)> SeedProjectAndWorkItemAsync(
        TestApplicationDbContext db, string suffix)
    {
        var project = new Project { Id = Guid.NewGuid(), Code = "PRJ-DEL-" + suffix, Name = "Dự án " + suffix };
        var wi = new WorkItem { Id = Guid.NewGuid(), Code = "WI-DEL-" + suffix, Name = "Hạng mục " + suffix, IsActive = true };
        db.Projects.Add(project);
        db.WorkItems.Add(wi);
        await db.SaveChangesAsync(CancellationToken.None);
        return (project, wi);
    }

    /// 1 đơn vị IsWinner + 1 hạng mục + 1 báo giá IsSelected (nuôi PrevSelected/PendingSelected).
    private static async Task SeedWinnerQuoteAsync(
        TestApplicationDbContext db, PurchaseEvaluation pe, decimal thanhTien)
    {
        var sup = new PurchaseEvaluationSupplier
        {
            Id = Guid.NewGuid(),
            PurchaseEvaluationId = pe.Id,
            SupplierId = Guid.NewGuid(),
            Order = 0,
            IsWinner = true,
        };
        var detail = new PurchaseEvaluationDetail
        {
            Id = Guid.NewGuid(),
            PurchaseEvaluationId = pe.Id,
            GroupCode = "A.I",
            GroupName = "Bê tông",
            NoiDung = "Concrete",
            Order = 0,
        };
        db.PurchaseEvaluationSuppliers.Add(sup);
        db.PurchaseEvaluationDetails.Add(detail);
        db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
        {
            Id = Guid.NewGuid(),
            PurchaseEvaluationDetailId = detail.Id,
            PurchaseEvaluationSupplierId = sup.Id,
            ThanhTien = thanhTien,
            IsSelected = true,
        });
        await db.SaveChangesAsync(CancellationToken.None);
    }

    private static async Task<PurchaseEvaluation> SeedPeAsync(
        TestApplicationDbContext db,
        Guid projectId, Guid? workItemId, Guid drafterUserId, string code,
        PurchaseEvaluationPhase phase = PurchaseEvaluationPhase.ChoDuyet,
        Guid? approvalWorkflowId = null,
        int stepIdx = 0, int levelOrder = 1,
        decimal? budgetPeriod = null, decimal? winnerQuote = null,
        DateTime? createdAt = null)
    {
        var pe = new PurchaseEvaluation
        {
            Id = Guid.NewGuid(),
            Type = PurchaseEvaluationType.DuyetNcc,
            Phase = phase,
            MaPhieu = code,
            TenGoiThau = "Gói thầu " + code,
            ProjectId = projectId,
            WorkItemId = workItemId,
            DrafterUserId = drafterUserId,
            ApprovalWorkflowId = approvalWorkflowId,
            CurrentWorkflowStepIndex = approvalWorkflowId is null ? null : stepIdx,
            CurrentApprovalLevelOrder = approvalWorkflowId is null ? null : levelOrder,
            BudgetPeriodAmount = budgetPeriod,
        };
        db.PurchaseEvaluations.Add(pe);
        await db.SaveChangesAsync(CancellationToken.None);
        // Interceptor đóng dấu CreatedAt=clock lúc Add ⇒ ghi đè SAU (lượt 2 = Modified, chỉ đụng UpdatedAt).
        pe.CreatedAt = createdAt ?? BaseT;
        await db.SaveChangesAsync(CancellationToken.None);
        if (winnerQuote is decimal wq) await SeedWinnerQuoteAsync(db, pe, wq);
        return pe;
    }

    /// stepSpecs[s] = các dòng Cấp của Bước (Order s+1): (LevelOrder, ApproverUserId, AllowApproverDelete).
    /// N dòng cùng LevelOrder = 1 Cấp N người (OR-of-N) — đúng hình dạng prod (ảnh UAT Cấp 2 có 3 người).
    private static async Task<ApprovalWorkflow> SeedWorkflowAsync(
        TestApplicationDbContext db,
        (int LevelOrder, Guid User, bool AllowDelete)[][] stepSpecs,
        string code = "QT-DEL-V2")
    {
        var wf = new ApprovalWorkflow
        {
            Code = code,
            Version = 1,
            ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
            Name = "QT test xóa khi đang duyệt",
            IsActive = true,
            IsUserSelectable = true,
        };
        for (int s = 0; s < stepSpecs.Length; s++)
        {
            var step = new ApprovalWorkflowStep
            {
                ApprovalWorkflowId = wf.Id,
                Order = s + 1,
                Name = $"Bước {s + 1}",
            };
            foreach (var (levelOrder, user, allowDelete) in stepSpecs[s])
                step.Levels.Add(new ApprovalWorkflowLevel
                {
                    ApprovalWorkflowStepId = step.Id,
                    Order = levelOrder,
                    Name = $"Cấp {levelOrder}",
                    ApproverUserId = user,
                    AllowApproverDelete = allowDelete,
                });
            wf.Steps.Add(step);
        }
        db.ApprovalWorkflows.Add(wf);
        await db.SaveChangesAsync(CancellationToken.None);
        return wf;
    }

    private static Task<PeBudgetAccumulation> ExactAsync(TestApplicationDbContext db, Guid projectId, Guid wiId)
        => PeBudgetAccumulator.ComputeAsync(
            db, projectId, wiId, Guid.NewGuid(), CurCreatedAt, Array.Empty<Guid>(), CancellationToken.None);

    private static Task<PePendingAccumulation> PendingAsync(TestApplicationDbContext db, Guid projectId, Guid wiId)
        => PeBudgetAccumulator.ComputePendingAsync(
            db, projectId, wiId, Guid.NewGuid(), CurCreatedAt, CancellationToken.None);

    /// Nền chung T21/T22: 3 phiếu cùng cặp (Dự án × Hạng mục), tất cả TRƯỚC `CurCreatedAt`.
    /// 🔴 CHỦ Ý: peer DaDuyet + peer TraLai tồn tại để 2 đại lượng "KHÔNG đổi" giữ giá trị ≠ 0 ở
    /// CẢ HAI phía. Nếu chỉ seed mỗi phiếu bị xóa thì "không đổi" hóa ra 0 → 0 = không đo gì.
    private static async Task<(Project project, WorkItem wi, PurchaseEvaluation target, User approver)>
        SeedAccumulationSceneAsync(SoftDeleteFixture f, string suffix)
    {
        var db = f.Db;
        var (project, wi) = await SeedProjectAndWorkItemAsync(db, suffix);
        var alice = await f.CreateUserAsync($"alice.{suffix}@t.vn", "Trần Thị An");
        var drafter = await f.CreateUserAsync($"drafter.{suffix}@t.vn", "Người soạn", AppRoles.Drafter);

        // (1) DaDuyet — nuôi PrevSubmitted (100tr) + PrevSelected (90tr). KHÔNG bị đụng khi xóa target.
        await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, $"PE-{suffix}-DD",
            phase: PurchaseEvaluationPhase.DaDuyet, budgetPeriod: 100_000_000m,
            winnerQuote: 90_000_000m, createdAt: BaseT.AddDays(-3));

        // (2) TraLai — nuôi PendingSubmitted (60tr) + một phần PendingSelected (20tr).
        await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, $"PE-{suffix}-TL",
            phase: PurchaseEvaluationPhase.TraLai, budgetPeriod: 60_000_000m,
            winnerQuote: 20_000_000m, createdAt: BaseT.AddDays(-2));

        // (3) TARGET ChoDuyet — vào PrevSubmitted (30tr) + PendingSelected (50tr). Đây là phiếu bị xóa.
        var wf = await SeedWorkflowAsync(db, [[(1, alice.Id, true)]], "QT-DEL-" + suffix);
        var target = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, $"PE-{suffix}-TARGET",
            phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id,
            budgetPeriod: 30_000_000m, winnerQuote: 50_000_000m, createdAt: BaseT.AddDays(-1));

        return (project, wi, target, alice);
    }

    // =====================================================================
    // T21 ⭐ LŨY KẾ — xóa phiếu ChoDuyet qua `/by-approver` ⇒ 4 đại lượng đi 4 HƯỚNG KHÁC NHAU.
    //
    // 🔴 KHÔNG phải "cả 4 cùng giảm". Số học từ code:
    //   PrevSubmitted    ↓  (`PeBudgetAccumulator.cs:53-59` gom ChoDuyet ∥ DaDuyet)
    //   PrevSelected     =  (`:61-62` đòi DaDuyet ⇒ phiếu ChoDuyet chưa từng góp mặt)
    //   PendingSubmitted =  (`:107` loại TƯỜNG MINH ChoDuyet để tránh double-count)
    //   PendingSelected  ↓  (`:114-126` nhận ChoDuyet-có-winner — đúng gap UAT)
    //   PriorPes         mất đúng 1 phần tử (`:128-133`)
    // Cơ chế: `Remove` → IsDeleted=true → global filter `PurchaseEvaluationConfiguration.cs:84`
    // loại phiếu khỏi CẢ 4 phép cộng. Accumulator KHÔNG bị sửa dòng nào.
    // =====================================================================
    [Fact]
    public async Task T21_DeleteByApprover_ChoDuyetPe_MovesFourAccumulatorsInFourDirections()
    {
        using var f = new SoftDeleteFixture();
        var (project, wi, target, alice) = await SeedAccumulationSceneAsync(f, "T21");

        // ---------- TRƯỚC ----------
        var exactBefore = await ExactAsync(f.Db, project.Id, wi.Id);
        var pendBefore = await PendingAsync(f.Db, project.Id, wi.Id);

        exactBefore.PrevSubmittedTotal.Should().Be(130_000_000m, "ChoDuyet 30tr + DaDuyet 100tr");
        exactBefore.PrevSubmittedCount.Should().Be(2);
        exactBefore.PrevSelectedTotal.Should().Be(90_000_000m, "chỉ DaDuyet+winner");
        exactBefore.PrevSelectedCount.Should().Be(1);
        pendBefore.PendingSubmittedTotal.Should().Be(60_000_000m, "chỉ TraLai (ChoDuyet/DaDuyet bị loại)");
        pendBefore.PendingSubmittedCount.Should().Be(1);
        pendBefore.PendingSelectedTotal.Should().Be(70_000_000m, "TraLai 20tr + ChoDuyet 50tr");
        pendBefore.PendingSelectedCount.Should().Be(2);
        pendBefore.PriorPes.Select(p => p.Id).Should().Contain(target.Id,
            "tiền đề: phiếu sắp xóa PHẢI đang nằm trong danh sách phiếu trước cần lưu ý");
        var priorCountBefore = pendBefore.PriorPes.Count;
        priorCountBefore.Should().Be(2, "TraLai + ChoDuyet (DaDuyet là terminal nên không tính)");

        // ---------- ACT: đúng người, đúng lượt, đúng cờ ----------
        f.ActAs(alice);
        await NewDeleteHandler(f).Handle(
            new DeletePurchaseEvaluationByApproverCommand(target.Id, "Bấm sai gói thầu"), CancellationToken.None);

        // Neo chống-vacuous: phải là XÓA MỀM thật (row còn, cờ bật) — không phải xóa cứng.
        var row = await f.Db.PurchaseEvaluations.AsNoTracking().IgnoreQueryFilters()
            .SingleAsync(p => p.Id == target.Id);
        row.IsDeleted.Should().BeTrue("`Remove` phải đi qua AuditingInterceptor thành xóa MỀM");

        // ---------- SAU ----------
        var exactAfter = await ExactAsync(f.Db, project.Id, wi.Id);
        var pendAfter = await PendingAsync(f.Db, project.Id, wi.Id);

        // ↓ giảm ĐÚNG phần đóng góp của phiếu bị xóa, không hơn không kém.
        exactAfter.PrevSubmittedTotal.Should().Be(100_000_000m,
            "PrevSubmitted phải GIẢM đúng 30tr = BudgetPeriodAmount của phiếu vừa xóa");
        exactAfter.PrevSubmittedCount.Should().Be(1);
        pendAfter.PendingSelectedTotal.Should().Be(20_000_000m,
            "PendingSelected phải GIẢM đúng 50tr = báo giá đã chọn của phiếu vừa xóa");
        pendAfter.PendingSelectedCount.Should().Be(1);

        // = BẤT ĐỘNG, và cả hai đều ≠ 0 nên phép đo có nội dung.
        exactAfter.PrevSelectedTotal.Should().Be(90_000_000m,
            "PrevSelected đòi phase DaDuyet ⇒ phiếu ChoDuyet chưa bao giờ nằm trong đó ⇒ KHÔNG đổi");
        exactAfter.PrevSelectedCount.Should().Be(1);
        pendAfter.PendingSubmittedTotal.Should().Be(60_000_000m,
            "PendingSubmitted loại TƯỜNG MINH ChoDuyet (PeBudgetAccumulator.cs:107) ⇒ KHÔNG đổi");
        pendAfter.PendingSubmittedCount.Should().Be(1);

        // PriorPes: mất ĐÚNG một phần tử, và đúng phần tử vừa xóa.
        pendAfter.PriorPes.Should().HaveCount(priorCountBefore - 1, "mất đúng 1 phần tử");
        pendAfter.PriorPes.Should().NotContain(p => p.Id == target.Id);
        pendAfter.PriorPes.Should().ContainSingle().Which.MaPhieu.Should().Be("PE-T21-TL",
            "phần tử còn lại phải là phiếu TraLai — không phải mất nhầm phiếu khác");
    }

    // =====================================================================
    // T22 — gọi màn "Đã xóa" (`IgnoreQueryFilters`) KHÔNG làm phiếu đã xóa lọt NGƯỢC vào lũy kế.
    // Ranh giới D3: `IgnoreQueryFilters` chỉ được sống trong CHÍNH query đó. Nếu ai đó gom nó
    // vào helper trả `IQueryable` dùng chung / thêm cờ `includeDeleted` lan sang accumulator thì
    // 4 con số quay lại giá trị TRƯỚC-XÓA ⇒ ca này đỏ.
    // Chứng-nhân: list "Đã xóa" phải THẬT SỰ trả về phiếu đó — nếu list rỗng thì "không rò rỉ"
    // chỉ là hệ quả của việc không có gì để rò.
    // =====================================================================
    [Fact]
    public async Task T22_ListingDeletedScreen_DoesNotLeakDeletedPeBackIntoAccumulator()
    {
        using var f = new SoftDeleteFixture();
        var (project, wi, target, alice) = await SeedAccumulationSceneAsync(f, "T22");

        f.ActAs(alice);
        await NewDeleteHandler(f).Handle(
            new DeletePurchaseEvaluationByApproverCommand(target.Id), CancellationToken.None);

        var admin = new TestCurrentUser(Guid.NewGuid(), "Quản trị", null, AppRoles.Admin);
        var deleted = await NewDeletedListHandler(f, admin)
            .Handle(new ListDeletedPurchaseEvaluationsQuery(), CancellationToken.None);

        deleted.Items.Select(i => i.Id).Should().Contain(target.Id,
            "chứng-nhân: màn Đã xóa PHẢI thấy phiếu vừa xóa, nếu không thì assert dưới rỗng nghĩa");

        // Ngay SAU khi query bỏ-lọc chạy, lũy kế vẫn phải là số hậu-xóa.
        var exact = await ExactAsync(f.Db, project.Id, wi.Id);
        var pend = await PendingAsync(f.Db, project.Id, wi.Id);

        exact.PrevSubmittedTotal.Should().Be(100_000_000m, "phiếu đã xóa KHÔNG được quay lại PrevSubmitted");
        exact.PrevSubmittedCount.Should().Be(1);
        pend.PendingSelectedTotal.Should().Be(20_000_000m, "phiếu đã xóa KHÔNG được quay lại PendingSelected");
        pend.PendingSelectedCount.Should().Be(1);
        pend.PriorPes.Should().NotContain(p => p.Id == target.Id);
    }

    // =====================================================================
    // T22b — list "Đã xóa" KHÔNG chứa phiếu SỐNG.
    // 🔴 `IgnoreQueryFilters()` GỠ bộ lọc chứ không ĐẢO nó. Thiếu `.Where(x => x.IsDeleted)`
    // (`PeSoftDeleteFeatures.cs:167-168`) thì màn này liệt kê TOÀN BỘ phiếu sống — mà build,
    // mọi test khác và cả compiler đều xanh.
    // Chứng-nhân: bảng có 3 row (2 sống + 1 xóa) nên nếu gỡ `.Where` thì kết quả là 3 ⇒ đỏ.
    // =====================================================================
    [Fact]
    public async Task T22b_DeletedList_ExcludesLivePes_IgnoreQueryFiltersRemovesFilterNotInvertsIt()
    {
        using var f = new SoftDeleteFixture();
        var db = f.Db;
        var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T22b");
        var alice = await f.CreateUserAsync("alice.t22b@t.vn", "Trần Thị An");
        var drafter = await f.CreateUserAsync("drafter.t22b@t.vn", "Người soạn", AppRoles.Drafter);
        var wf = await SeedWorkflowAsync(db, [[(1, alice.Id, true)]], "QT-DEL-T22b");

        // 2 phiếu SỐNG — Project THẬT (handler INNER JOIN Projects, phiếu không có dự án sẽ tự rơi
        // khỏi kết quả và làm phép đo mất răng).
        var live1 = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T22b-LIVE1");
        var live2 = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T22b-LIVE2",
            phase: PurchaseEvaluationPhase.DangSoanThao);
        var target = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T22b-DEL",
            phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id);

        f.ActAs(alice);
        await NewDeleteHandler(f).Handle(
            new DeletePurchaseEvaluationByApproverCommand(target.Id), CancellationToken.None);

        // Chứng-nhân: bảng CÓ 3 row; chỉ 1 mang cờ IsDeleted.
        (await db.PurchaseEvaluations.AsNoTracking().IgnoreQueryFilters().CountAsync())
            .Should().Be(3, "tiền đề: 2 phiếu sống + 1 phiếu đã xóa cùng tồn tại trong bảng");
        (await db.PurchaseEvaluations.AsNoTracking().CountAsync())
            .Should().Be(2, "global filter cho 2 phiếu sống đi qua");

        var admin = new TestCurrentUser(Guid.NewGuid(), "Quản trị", null, AppRoles.Admin);
        var result = await NewDeletedListHandler(f, admin)
            .Handle(new ListDeletedPurchaseEvaluationsQuery(), CancellationToken.None);

        result.Total.Should().Be(1, "màn Đã xóa chỉ đếm phiếu đã xóa — bỏ `.Where(IsDeleted)` sẽ ra 3");
        result.Items.Should().ContainSingle().Which.Id.Should().Be(target.Id);
        result.Items.Select(i => i.Id).Should().NotContain(live1.Id).And.NotContain(live2.Id,
            "phiếu đang sống KHÔNG được xuất hiện ở màn Đã xóa");
    }

    // =====================================================================
    // T22c — IDOR: user ngoài phạm vi KHÔNG thấy phiếu đã xóa của người khác.
    // Handler MỚI mặc định 0 rào ⇒ khối `PeSoftDeleteFeatures.cs:185-202` là thứ duy nhất chặn.
    // 🔴 Chống assert-rỗng-nghĩa: CÙNG dữ liệu, CÙNG câu truy vấn, chỉ đổi NGƯỜI GỌI —
    // người ngoài thấy 0, còn drafter và approver đều thấy 1. Nếu gỡ khối IDOR ⇒ dave thấy 1 ⇒ đỏ.
    // =====================================================================
    [Fact]
    public async Task T22c_DeletedList_OutsiderSeesNothing_WhileDrafterAndApproverSeeIt()
    {
        using var f = new SoftDeleteFixture();
        var db = f.Db;
        var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T22c");
        var alice = await f.CreateUserAsync("alice.t22c@t.vn", "Trần Thị An");
        var drafter = await f.CreateUserAsync("drafter.t22c@t.vn", "Người soạn", AppRoles.Drafter);
        var dave = await f.CreateUserAsync("dave.t22c@t.vn", "Người ngoài");   // 0 role, 0 dính líu

        var wf = await SeedWorkflowAsync(db, [[(1, alice.Id, true)]], "QT-DEL-T22c");
        var target = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T22c-DEL",
            phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id,
            budgetPeriod: 777_000_000m);

        f.ActAs(alice);
        await NewDeleteHandler(f).Handle(
            new DeletePurchaseEvaluationByApproverCommand(target.Id), CancellationToken.None);

        var q = new ListDeletedPurchaseEvaluationsQuery();

        // (a) NGƯỜI NGOÀI — không phải drafter, không role eligible, không approver quy trình đã pin.
        var outsider = await NewDeletedListHandler(f, new TestCurrentUser(dave.Id, dave.FullName, dave.Email))
            .Handle(q, CancellationToken.None);
        outsider.Total.Should().Be(0,
            "gỡ khối IDOR ⇒ mọi user đọc được phiếu đã xóa của dự án khác kèm ngân sách + tên gói thầu");
        outsider.Items.Should().BeEmpty();

        // (b) ĐỐI CHỨNG 1 — người soạn phiếu (cond `DrafterUserId == userId`).
        var asDrafter = await NewDeletedListHandler(f,
                new TestCurrentUser(drafter.Id, drafter.FullName, drafter.Email, AppRoles.Drafter))
            .Handle(q, CancellationToken.None);
        asDrafter.Items.Should().ContainSingle().Which.Id.Should().Be(target.Id,
            "chứng-nhân: cùng dữ liệu + cùng truy vấn, người trong cuộc PHẢI thấy — nên số 0 ở trên là do rào, không do rỗng");

        // (c) ĐỐI CHỨNG 2 — người duyệt của quy trình đã pin (cond V2-approver).
        var asApprover = await NewDeletedListHandler(f,
                new TestCurrentUser(alice.Id, alice.FullName, alice.Email))
            .Handle(q, CancellationToken.None);
        asApprover.Items.Should().ContainSingle().Which.Id.Should().Be(target.Id);
    }

    // =====================================================================
    // T23 — Cấp KHÔNG bật cờ `AllowApproverDelete` ⇒ từ chối, KỂ CẢ khi đúng lượt.
    // Biến duy nhất khác T25b là cái cờ (cặp cùng-ruột-khác-1-bit).
    // =====================================================================
    [Fact]
    public async Task T23_ApproverAtCurrentTurn_ButFlagOff_IsForbidden_AndPeSurvives()
    {
        using var f = new SoftDeleteFixture();
        var db = f.Db;
        var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T23");
        var alice = await f.CreateUserAsync("alice.t23@t.vn", "Trần Thị An");
        var drafter = await f.CreateUserAsync("drafter.t23@t.vn", "Người soạn", AppRoles.Drafter);

        var wf = await SeedWorkflowAsync(db, [[(1, alice.Id, false)]], "QT-DEL-T23");   // 🔴 cờ TẮT
        var pe = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T23",
            phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id);

        f.ActAs(alice);
        var act = async () => await NewDeleteHandler(f).Handle(
            new DeletePurchaseEvaluationByApproverCommand(pe.Id), CancellationToken.None);

        var ex = (await act.Should().ThrowAsync<ForbiddenException>(
            "đúng lượt vẫn chưa đủ — cờ F6 là rào riêng")).Which;
        ex.Message.Should().Contain("Xóa phiếu khi đang duyệt",
            "thông điệp phải chỉ đúng cái cờ cần bật, không phải lỗi 'chưa tới lượt'");

        (await db.PurchaseEvaluations.AsNoTracking().IgnoreQueryFilters()
            .SingleAsync(p => p.Id == pe.Id)).IsDeleted
            .Should().BeFalse("ném rồi thì phiếu phải còn nguyên — không xóa nửa vời");
    }

    // =====================================================================
    // T24 — đúng cờ nhưng KHÔNG đúng lượt ⇒ từ chối.
    // Cờ của carol để BẬT có chủ ý: để ca này đo đúng MỘT biến "lượt", không lẫn với ca cờ.
    // =====================================================================
    [Fact]
    public async Task T24_ApproverOfLaterLevel_WithFlagOn_ButNotCurrentTurn_IsForbidden()
    {
        using var f = new SoftDeleteFixture();
        var db = f.Db;
        var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T24");
        var alice = await f.CreateUserAsync("alice.t24@t.vn", "Trần Thị An");
        var carol = await f.CreateUserAsync("carol.t24@t.vn", "Phạm Thị Cúc");
        var drafter = await f.CreateUserAsync("drafter.t24@t.vn", "Người soạn", AppRoles.Drafter);

        // Cấp 1 = alice, Cấp 2 = carol — CẢ HAI đều bật cờ. Con trỏ đang ở Cấp 1.
        var wf = await SeedWorkflowAsync(db, [[(1, alice.Id, true), (2, carol.Id, true)]], "QT-DEL-T24");
        var pe = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T24",
            phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id, levelOrder: 1);

        f.ActAs(carol);
        var act = async () => await NewDeleteHandler(f).Handle(
            new DeletePurchaseEvaluationByApproverCommand(pe.Id), CancellationToken.None);

        var ex = (await act.Should().ThrowAsync<ForbiddenException>(
            "carol có cờ nhưng phiếu chưa tới Cấp 2 — cờ không cấp quyền vượt lượt")).Which;
        ex.Message.Should().Contain("chưa tới lượt");

        (await db.PurchaseEvaluations.AsNoTracking().IgnoreQueryFilters()
            .SingleAsync(p => p.Id == pe.Id)).IsDeleted.Should().BeFalse();
    }

    // =====================================================================
    // T24b ⭐ PER-NGƯỜI, KHÔNG per-Cấp (owner chốt).
    // Cấp 1 có 2 người CÙNG đúng lượt: alice bật cờ, bob KHÔNG. Cùng một phiếu, cùng một lời gọi
    // — chỉ khác người thao tác: bob bị chặn, alice xóa được.
    // 🔴 Đây là ca phân biệt `matchingLevel.AllowApproverDelete` (đúng) với `g.Any(l => ...)` (sai):
    // với `Any`, bob sẽ xóa được ⇒ ca này đỏ ngay. Không có ca này thì hai cách đọc không phân biệt nổi.
    // Thứ tự bob-trước-alice là bắt buộc: alice xóa xong thì phiếu biến mất khỏi global filter
    // và lượt của bob sẽ nhận NotFound thay vì Forbidden — tức đo nhầm rào.
    // =====================================================================
    [Fact]
    public async Task T24b_SameLevelTwoPeople_OnlyTheTickedOneCanDelete_PerRowNotPerLevel()
    {
        using var f = new SoftDeleteFixture();
        var db = f.Db;
        var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T24b");
        var alice = await f.CreateUserAsync("alice.t24b@t.vn", "Trần Thị An");
        var bob = await f.CreateUserAsync("bob.t24b@t.vn", "Lê Văn Bình");
        var drafter = await f.CreateUserAsync("drafter.t24b@t.vn", "Người soạn", AppRoles.Drafter);

        // 1 Cấp — 2 dòng cùng Order 1 (OR-of-N). Chỉ alice được tick.
        var wf = await SeedWorkflowAsync(db,
            [[(1, alice.Id, true), (1, bob.Id, false)]], "QT-DEL-T24b");
        var pe = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T24b",
            phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id);

        // ---- bob: đúng lượt, KHÔNG được tick ⇒ chặn ----
        f.ActAs(bob);
        var bobAct = async () => await NewDeleteHandler(f).Handle(
            new DeletePurchaseEvaluationByApproverCommand(pe.Id), CancellationToken.None);
        var ex = (await bobAct.Should().ThrowAsync<ForbiddenException>(
            "quyền huỷ chứng-từ phát theo TỪNG NGƯỜI; đọc per-Cấp bằng `Any` sẽ cho bob lọt")).Which;
        ex.Message.Should().Contain("Xóa phiếu khi đang duyệt", "bob bị chặn vì CỜ, không phải vì lượt");

        (await db.PurchaseEvaluations.AsNoTracking().IgnoreQueryFilters()
            .SingleAsync(p => p.Id == pe.Id)).IsDeleted
            .Should().BeFalse("bob không được xóa gì cả");

        // ---- alice: cùng Cấp, cùng lượt, CÓ tick ⇒ xóa được ----
        f.Db.ChangeTracker.Clear();
        f.ActAs(alice);
        await NewDeleteHandler(f).Handle(
            new DeletePurchaseEvaluationByApproverCommand(pe.Id), CancellationToken.None);

        (await db.PurchaseEvaluations.AsNoTracking().IgnoreQueryFilters()
            .SingleAsync(p => p.Id == pe.Id)).IsDeleted
            .Should().BeTrue("cùng Cấp mà khác người ⇒ khác kết quả — đó chính là 'per-người'");
    }

    // =====================================================================
    // T25 — quyền ở tầng API (Pattern 10 reflection, không dựng WebApplicationFactory).
    // 3 mệnh đề, mất bất kỳ cái nào cũng là lỗ hổng thật:
    //   (i)  `by-approver` PHẢI mang `[Authorize(Policy = "PurchaseEvaluations.Delete")]` —
    //        thiếu thì mọi tài khoản đăng nhập gọi được (class-level chỉ `[Authorize]` trần).
    //   (ii) tên policy phải CÓ THẬT trong bộ sinh của `Program.cs` (`MenuKeys.All × Actions`) —
    //        gắn tên không đăng ký thì ASP.NET ném lúc chạy mà test attribute vẫn xanh.
    //   (iii) đường xóa NHÁP cũ PHẢI vẫn KHÔNG có policy — gắn vào đó là cướp mất quyền xóa nháp
    //        của chính Drafter (11/13 role có CanDelete=0).
    // =====================================================================
    [Fact]
    public void T25_DeleteByApprover_MustStayPolicyFree_AndDraftDeleteStaysUngated()
    {
        var t = typeof(PurchaseEvaluationsController);

        // 🔴 ĐỔI SPEC 2026-07-27 (owner chốt sau đo của reviewer) — ca này TRƯỚC ĐÂY đòi
        // `Policy == "PurchaseEvaluations.Delete"`. Nay đòi NGƯỢC LẠI: KHÔNG được có policy.
        //
        // Vì sao: đo trên DB thật, MỌI vai đang làm người duyệt (`Procurement`, `CostControl`, …)
        // đều `CanDelete = 0`, và không seeder nào nâng cờ đó (`DbInitializer.cs:2139-2140` chỉ
        // nâng Read/Create; `:2538` bỏ qua hàng đã tồn tại). Gắn policy ⇒ **403 với đúng người
        // cần dùng**, mà FE gate cùng quyền nên không thấy nút, không thấy lỗi (gotcha #44).
        //
        // Nguồn quyền THẬT = 3 rào nghiệp vụ trong handler: phase ChoDuyet ∧ đúng lượt ∧
        // `matchingLevel.AllowApproverDelete` (cờ per-NGƯỜI admin tick đích danh) — CHẶT HƠN
        // permission theo vai. T23 / T24 / T24b / T25b là chứng-nhân cho 3 rào đó.
        //
        // ⚠️ Ca này là CHỐT CHẶN chống hồi quy: ai đó "siết bảo mật" bằng cách thêm lại
        // `[Authorize(Policy=...)]` sẽ làm tính năng CHẾT IM LẶNG trên prod — và test này đỏ ngay.
        GetActionAuthorize(t, nameof(PurchaseEvaluationsController.DeleteByApprover))
            .Should().BeNull(
                "đường xóa-khi-đang-duyệt CỐ Ý không gate bằng quyền theo vai — approver thật đều "
                + "CanDelete=0 nên gắn policy là chặn đúng người cần dùng; quyền nằm ở 3 rào handler");

        GetActionAuthorize(t, nameof(PurchaseEvaluationsController.Delete))
            .Should().BeNull(
                "đường xóa NHÁP cũ phải giữ nguyên KHÔNG policy — gắn vào đó là chặn Drafter xóa nháp của chính mình");

        GetActionAuthorize(t, nameof(PurchaseEvaluationsController.ListDeleted))!.Policy
            .Should().Be("PurchaseEvaluations.Read", "màn Đã xóa là màn đọc, gate bằng quyền Read");
    }

    // =====================================================================
    // T27 ⭐ HỢP-ĐỒNG ENTITY ⟷ DTO — sinh ra từ một bug THẬT của đợt này.
    //
    // Chuyện đã xảy ra: cờ `AllowApproverDelete` được thêm vào entity + migration + FE type,
    // FE gate nút bằng `levelOptions?.allowApproverDelete === true` — nhưng **DTO không có
    // field đó** nên BE không bao giờ gửi ⇒ vế so sánh LUÔN sai ⇒ **nút không hiện với bất
    // kỳ ai**. BE build sạch, FE build sạch, 561 test xanh, `grep` tên ROUTE cũng khớp —
    // không lưới nào bắt được, vì lỗ nằm ở tầng **tên FIELD**, không phải tên route.
    //
    // Ca này đóng CẢ LỚP đó: mọi cờ `Allow*` trên slot Cấp duyệt PHẢI có đường ra DTO.
    // Thêm cờ mới vào entity mà quên DTO ⇒ đỏ NGAY, kèm tên cờ bị thiếu.
    // =====================================================================
    [Fact]
    public void T27_EveryLevelAllowFlag_MustBeExposedOnOptionsDto()
    {
        var entityFlags = typeof(ApprovalWorkflowLevel)
            .GetProperties(BindingFlags.Public | BindingFlags.Instance)
            .Where(p => p.PropertyType == typeof(bool) && p.Name.StartsWith("Allow", StringComparison.Ordinal))
            .Select(p => p.Name)
            .OrderBy(n => n)
            .ToList();

        entityFlags.Should().NotBeEmpty("nếu rỗng thì phép đo này vô nghĩa — chứng-nhân cho chính nó");

        var dtoMembers = typeof(ApprovalWorkflowOptionsDto)
            .GetProperties(BindingFlags.Public | BindingFlags.Instance)
            .Select(p => p.Name)
            .ToHashSet(StringComparer.Ordinal);

        var missing = entityFlags.Where(f => !dtoMembers.Contains(f)).ToList();

        missing.Should().BeEmpty(
            "mỗi cờ Allow* trên ApprovalWorkflowLevel phải có đường ra ApprovalWorkflowOptionsDto — "
            + "thiếu field nào thì FE đọc `undefined`, gate fail-closed, nút/quyền tương ứng "
            + "KHÔNG BAO GIỜ hiện, mà cả BE lẫn FE đều build sạch. Cờ đang thiếu: {0}",
            string.Join(", ", missing));
    }

    private static AuthorizeAttribute? GetActionAuthorize(Type controllerType, string methodName)
        => controllerType
            .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
            .FirstOrDefault(m => m.Name == methodName)
            ?.GetCustomAttributes<AuthorizeAttribute>(inherit: false)
            .FirstOrDefault();

    // =====================================================================
    // T25b ⭐ CHIỀU THUẬN — đủ quyền ∧ đúng lượt ∧ đúng cờ ⇒ THÀNH CÔNG.
    // Không có ca này thì T23/T24/T24b vẫn xanh trọn vẹn kể cả khi tính năng CHẾT HẲN
    // (một handler `throw` vô điều kiện làm mọi ca phủ định pass).
    // Đo trọn hợp đồng: không ném · row Ở LẠI với IsDeleted/DeletedAt/DeletedBy · biến mất khỏi
    // truy vấn thường · hiện ra ở màn "Đã xóa".
    // =====================================================================
    [Fact]
    public async Task T25b_RightPersonRightTurnRightFlag_SoftDeletesSuccessfully_AndShowsInDeletedScreen()
    {
        using var f = new SoftDeleteFixture();
        var db = f.Db;
        var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T25b");
        var alice = await f.CreateUserAsync("alice.t25b@t.vn", "Trần Thị An");
        var drafter = await f.CreateUserAsync("drafter.t25b@t.vn", "Người soạn", AppRoles.Drafter);

        var wf = await SeedWorkflowAsync(db, [[(1, alice.Id, true)]], "QT-DEL-T25b");
        var pe = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T25b",
            phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id);

        f.ActAs(alice);
        var act = async () => await NewDeleteHandler(f).Handle(
            new DeletePurchaseEvaluationByApproverCommand(pe.Id), CancellationToken.None);
        await act.Should().NotThrowAsync("đủ cả 3 điều kiện thì đường này phải đi được — nếu không, tính năng chết");

        var row = await db.PurchaseEvaluations.AsNoTracking().IgnoreQueryFilters()
            .SingleAsync(p => p.Id == pe.Id);
        row.IsDeleted.Should().BeTrue();
        row.DeletedAt.Should().Be(f.Clock.UtcNow, "AuditingInterceptor đóng dấu thời điểm xóa");
        row.DeletedBy.Should().Be(alice.Id, "phải ghi ĐÚNG người bấm xóa");
        row.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "xóa mềm KHÔNG đổi trạng thái phiếu");

        (await db.PurchaseEvaluations.AsNoTracking().AnyAsync(p => p.Id == pe.Id))
            .Should().BeFalse("global filter phải giấu phiếu khỏi mọi truy vấn thường");

        var admin = new TestCurrentUser(Guid.NewGuid(), "Quản trị", null, AppRoles.Admin);
        var deleted = await NewDeletedListHandler(f, admin)
            .Handle(new ListDeletedPurchaseEvaluationsQuery(), CancellationToken.None);
        deleted.Items.Should().ContainSingle().Which.Id.Should().Be(pe.Id,
            "xóa mềm = phiếu đi sang màn Đã xóa, không bốc hơi");
    }

    // =====================================================================
    // T26 — GHI VẾT. `PurchaseEvaluationChangelog` là chỗ DUY NHẤT chứa LÝ DO
    // (`DeletedBy`/`DeletedAt` chỉ trả lời ai + lúc nào). Xóa một chứng-từ tài chính mà mất vết
    // lý do thì màn "Đã xóa" chỉ còn là danh sách xác không giải thích được.
    // Đối chứng trong cùng ca: xóa KHÔNG nhập lý do ⇒ `ContextNote` phải là null (nhánh trim/null),
    // để "có ContextNote" ở phiếu trên không thể là hằng số.
    //
    // 🔴🔴 CA NÀY ĐANG ĐỎ — BUG PRODUCTION, CỐ Ý GIỮ ĐỎ (test-specialist KHÔNG sửa src/Backend).
    // Vết KHÔNG BAO GIỜ được ghi. Cơ chế đã đo bằng chẩn đoán riêng (ChangeTracker), không suy đoán:
    //   `PeSoftDeleteFeatures.cs:126` Add changelog  → state = Added, EF fixup gắn nó vào
    //        `pe.Changelogs` (navigation 2 chiều, `PurchaseEvaluationConfiguration.cs:78`).
    //   `PeSoftDeleteFeatures.cs:142` Remove(pe)     → `CascadeDeleteTiming.Immediate` (mặc định)
    //        cascade ngay TẠI DÒNG NÀY; dependent đang ở `Added` khi bị cascade thì EF chuyển
    //        thành **Detached** (xóa một entity Added = gỡ theo dõi).  ⇒ đo được: Added → Detached.
    //   `:143` SaveChanges → 0 row changelog. `AuditingInterceptor` chạy ở SavingChanges, tức SAU
    //        khi cascade đã xảy ra ⇒ việc nó đổi Deleted→Modified KHÔNG cứu được vết.
    // ⇒ Prod dính y hệt: khác biệt duy nhất giữa harness và prod là interceptor, mà interceptor
    //    nằm SAU điểm mất mát. Xóa mềm vẫn chạy đúng (T25b xanh) — chỉ LÝ DO bị nuốt IM LẶNG.
    // Đã grep cùng lớp: đây là site DUY NHẤT trong `src/Backend` có hình "Add con → Remove cha →
    // 1 SaveChanges" (đường xóa nháp cũ `PurchaseEvaluationFeatures.cs:1408` không ghi changelog).
    // =====================================================================
    [Fact]
    public async Task T26_Delete_WritesChangelogWithDeleteAction_AndCarriesReasonWhenProvided()
    {
        using var f = new SoftDeleteFixture();
        var db = f.Db;
        var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T26");
        var alice = await f.CreateUserAsync("alice.t26@t.vn", "Trần Thị An");
        var drafter = await f.CreateUserAsync("drafter.t26@t.vn", "Người soạn", AppRoles.Drafter);

        var wf = await SeedWorkflowAsync(db, [[(1, alice.Id, true)]], "QT-DEL-T26");
        var levelId = wf.Steps.Single().Levels.Single().Id;

        var withReason = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T26-REASON",
            phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id);
        var noReason = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T26-NOREASON",
            phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id);

        f.ActAs(alice);
        await NewDeleteHandler(f).Handle(
            new DeletePurchaseEvaluationByApproverCommand(withReason.Id, "  Bấm sai gói thầu  "),
            CancellationToken.None);
        f.Db.ChangeTracker.Clear();
        await NewDeleteHandler(f).Handle(
            new DeletePurchaseEvaluationByApproverCommand(noReason.Id, "   "),
            CancellationToken.None);

        var logs = await db.PurchaseEvaluationChangelogs.AsNoTracking()
            .Where(c => c.Action == ChangelogAction.Delete)
            .ToListAsync(CancellationToken.None);

        logs.Should().HaveCount(2, "mỗi lần xóa phải để lại đúng 1 vết");

        var a = logs.Single(l => l.PurchaseEvaluationId == withReason.Id);
        a.EntityType.Should().Be(PurchaseEvaluationEntityType.Workflow);
        a.EntityId.Should().Be(levelId, "vết trỏ đúng dòng Cấp mà người xóa đang đứng");
        a.PhaseAtChange.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "trạng thái TẠI LÚC xóa");
        a.UserId.Should().Be(alice.Id);
        a.UserName.Should().Be("Trần Thị An");
        a.Summary.Should().Contain("Xóa phiếu khi đang duyệt");
        a.ContextNote.Should().Be("Bấm sai gói thầu", "lý do phải được lưu và cắt khoảng trắng thừa");

        var b = logs.Single(l => l.PurchaseEvaluationId == noReason.Id);
        b.ContextNote.Should().BeNull("chuỗi toàn khoảng trắng = không nhập lý do ⇒ lưu null, không lưu rỗng");

        // Vết phải ĐỌC ĐƯỢC sau khi phiếu biến mất khỏi global filter (changelog không có IsDeleted
        // và truy vấn changelog lọc theo PurchaseEvaluationId chứ không join phiếu).
        (await db.PurchaseEvaluations.AsNoTracking().AnyAsync(p => p.Id == withReason.Id))
            .Should().BeFalse("tiền đề: phiếu đã bị giấu — vết vẫn phải còn đọc được ở trên");
    }
}
