using FluentValidation;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.ContractSigningPlans;   // [S182 W1d] ContractSigningPlanWorkflowGuards.ParseGroup
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.ContractSigningPlans;        // [S182 W1c] changelog + phase KHKK
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.PurchaseEvaluations;

namespace SolutionErp.Application.ApprovalWorkflowsV2;

// Quy trình duyệt MỚI (Mig 22 — Session 17, 2026-05-08).
// Schema riêng để UAT trước khi drop legacy WorkflowDefinition cũ.
//
// Cấu trúc:
//   Quy trình (Code + Name + ApplicableType)
//     Bước 1 - Phòng A
//       Cấp 1 - NV X (ApproverUserId 1 user cụ thể)
//       Cấp 2 - NV Y
//
// Khác Mig 21: Levels match 1 NV CHÍNH XÁC qua ApproverUserId, không match
// group Dept+PositionLevel/Role/User.

public record AwLevelDto(
    Guid Id,
    int Order,
    string? Name,
    Guid ApproverUserId,
    string? ApproverUserName,
    string? ApproverEmail,
    // Mig 29 (S21 t5) — 5 advanced options per slot Approver (F1 mode Trả lại
    // + F3 Edit Section 2). Mig 30 (S22+5) — F4 +AllowApproverEditBudget.
    // Mig 31 (S23 t1) — F2 +AllowApproverSkipToFinal (refactor sang Approver scope ChoDuyet).
    // Mỗi NV trong workflow có quyền riêng — 7 Allow* cumulative.
    bool AllowReturnOneLevel,
    bool AllowReturnOneStep,
    bool AllowReturnToAssignee,
    bool AllowReturnToDrafter,
    bool AllowApproverEditDetails,
    bool AllowApproverEditBudget,
    bool AllowApproverSkipToFinal,
    bool AllowApproverFinalize,                  // [Mig 58] slot này Approve = KẾT THÚC (không trình CEO)
    bool AllowApproverDelete,                    // [Mig 68 S155] slot này được XÓA MỀM phiếu ChoDuyet khi tới lượt mình
    // [S76] Hiển thị-only: approver này được nhập/điều chỉnh ngân sách cột nào (suy
    // từ ROLE — KHÔNG đổi quyền). CanEditProBudget = Admin|Procurement (cột PRO);
    // CanEditCcmBudget = Admin|CostControl (cột CCM). Badge "✎ NS PRO/CCM" trong Designer.
    bool CanEditProBudget,
    bool CanEditCcmBudget);

public record AwStepDto(
    Guid Id,
    int Order,
    string Name,
    Guid? DepartmentId,
    string? DepartmentName,
    List<AwLevelDto> Levels);

public record AwDefinitionDto(
    Guid Id,
    string Code,
    int Version,
    int ApplicableType,
    string ApplicableTypeLabel,
    string Name,
    string? Description,
    bool IsActive,
    bool IsUserSelectable,
    // [S69 2026-06-17] Ngưỡng giá trị "gói CEO" — admin Designer hiển thị + edit.
    // Null = không áp ngưỡng (luồng tuyến tính cũ).
    decimal? CeoApprovalThreshold,
    // Mig 29 (S21 t5) — 6 advanced options đã MOVE per-NV: F1+F3 xuống AwLevelDto
    // (per slot Approver). Workflow-level Mig 28 dropped.
    // Mig 31 (S23 t1) — F2 cũng refactor xuống Level slot (AllowApproverSkipToFinal
    // trong AwLevelDto) per-Approver scope ChoDuyet — KHÔNG còn per-Drafter user scope.
    DateTime? ActivatedAt,
    DateTime CreatedAt,
    List<AwStepDto> Steps);

public record AwTypeSummaryDto(
    int ApplicableType,
    string ApplicableTypeLabel,
    // `Active` = bản đang áp dụng ĐẦU TIÊN của loại (giữ nguyên nghĩa từ Mig 22 — FE đọc
    // thẳng ô này: ApprovalWorkflowsV2Page.tsx thẻ landing + panel "đang áp dụng").
    // ⚠️ Với loại per-Code-family, đây chỉ là MỘT trong N bản đang áp dụng (bản Version cao
    //    nhất gặp trước), KHÔNG đại diện cho `Code` nào cả ⇒ panel per-Code phải tự cắt theo
    //    `Code` (KHKK đang làm vậy: ApprovalWorkflowsV2Page.tsx:321-339), đừng đọc ô này.
    AwDefinitionDto? Active,
    // [S187 W1b] Các bản ĐANG ÁP DỤNG CÒN LẠI của loại — KHÔNG lặp lại bản trong `Active`.
    //   Tập đầy đủ những bản đang áp dụng = [Active] ∪ ActiveOthers.
    //
    // Vì sao cần: loại per-Code-family có NHIỀU quy trình cùng IsActive trong CÙNG một
    // ApplicableType (type-10 KHKK 8 nhóm N1..N8 · type-3 HĐ 7 loại). Trước bản này overview
    // chỉ trả 1 và N-1 bản còn lại chỉ còn đường ra qua `History` — mà `History` là KHO PHIÊN
    // BẢN, FE dựng mục "phiên bản cũ" bằng `history.filter(d => !d.isActive)` ⇒ chúng KHÔNG
    // xuất hiện ở đâu trên màn hình. Loại 1-active-per-type thì danh sách này RỖNG (không đổi gì).
    List<AwDefinitionDto> ActiveOthers,
    // KHO PHIÊN BẢN — MỌI version của loại, kể cả bản đang áp dụng (không lọc `!IsActive`).
    List<AwDefinitionDto> History);

public record AwAdminOverviewDto(List<AwTypeSummaryDto> Types);

internal static class AwLabels
{
    public static readonly Dictionary<ApprovalWorkflowApplicableType, string> Type = new()
    {
        [ApprovalWorkflowApplicableType.DuyetNcc] = "Duyệt NCC",
        [ApprovalWorkflowApplicableType.DuyetNccPhuongAn] = "Duyệt NCC + Giải pháp",
        [ApprovalWorkflowApplicableType.Contract] = "Hợp đồng",
        // [W1 S161 — reviewer F-7] Thiếu entry = fallback ToString() tiếng Anh lên tiêu đề
        // Designer VÀ ghi vào cột Name DB (useState `Quy trình ${label}` phía FE).
        [ApprovalWorkflowApplicableType.ContractSigningPlan] = "Kế hoạch ký kết HĐ",
    };
}

// ========== GET overview ==========
// Filter `applicableType=null` → return tất cả, `=N` → chỉ type đó.

// [Plan AA S24 t1] `IsUserSelectable` filter cho user read-only matrix view —
// chỉ workflow ghim (Mig 25 admin pin). Admin Designer KHÔNG truyền param →
// return tất cả như cũ.
public record GetAwAdminOverviewQuery(
    int? ApplicableType = null,
    bool? IsUserSelectable = null) : IRequest<AwAdminOverviewDto>;

public class GetAwAdminOverviewQueryHandler(
    IApplicationDbContext db,
    UserManager<User> userManager) : IRequestHandler<GetAwAdminOverviewQuery, AwAdminOverviewDto>
{
    public async Task<AwAdminOverviewDto> Handle(GetAwAdminOverviewQuery request, CancellationToken ct)
    {
        var query = db.ApprovalWorkflows.AsNoTracking()
            .Include(d => d.Steps.OrderBy(s => s.Order))
                .ThenInclude(s => s.Levels.OrderBy(l => l.Order))
            .OrderByDescending(d => d.Version)
            .AsQueryable();

        if (request.ApplicableType is int t)
        {
            var typeEnum = (ApprovalWorkflowApplicableType)t;
            query = query.Where(d => d.ApplicableType == typeEnum);
        }

        if (request.IsUserSelectable is bool ius)
        {
            query = query.Where(d => d.IsUserSelectable == ius);
        }

        var definitions = await query.ToListAsync(ct);

        // Resolve dept names
        var deptIds = definitions
            .SelectMany(d => d.Steps)
            .Where(s => s.DepartmentId != null)
            .Select(s => s.DepartmentId!.Value)
            .Distinct().ToList();
        var deptNames = deptIds.Count == 0
            ? new Dictionary<Guid, string>()
            : await db.Departments.AsNoTracking()
                .Where(d => deptIds.Contains(d.Id))
                .ToDictionaryAsync(d => d.Id, d => d.Name, ct);

        // Resolve user names
        var userIds = definitions
            .SelectMany(d => d.Steps)
            .SelectMany(s => s.Levels)
            .Select(l => l.ApproverUserId)
            .Distinct().ToList();
        var users = userIds.Count == 0
            ? new Dictionary<Guid, (string FullName, string? Email)>()
            : await userManager.Users.AsNoTracking()
                .Where(u => userIds.Contains(u.Id))
                .Select(u => new { u.Id, u.FullName, u.Email })
                .ToDictionaryAsync(u => u.Id, u => (u.FullName, u.Email), ct);

        // [S76] Hiển thị-only: tập user được nhập/điều chỉnh ngân sách (đảo chiều
        // GetUsersInRoleAsync → set-lookup, no N+1). Khớp gate canEditPro/canEditCcm
        // (PurchaseEvaluationFeatures.cs:800-801): PRO = Admin|Procurement, CCM = Admin|CostControl.
        var adminUserIds = (await userManager.GetUsersInRoleAsync(AppRoles.Admin)).Select(u => u.Id).ToHashSet();
        var proBudgetEditors = (await userManager.GetUsersInRoleAsync(AppRoles.Procurement)).Select(u => u.Id).Concat(adminUserIds).ToHashSet();
        var ccmBudgetEditors = (await userManager.GetUsersInRoleAsync(AppRoles.CostControl)).Select(u => u.Id).Concat(adminUserIds).ToHashSet();

        AwDefinitionDto ToDto(ApprovalWorkflow d) => new(
            d.Id,
            d.Code,
            d.Version,
            (int)d.ApplicableType,
            AwLabels.Type.GetValueOrDefault(d.ApplicableType, d.ApplicableType.ToString()),
            d.Name,
            d.Description,
            d.IsActive,
            d.IsUserSelectable,
            d.CeoApprovalThreshold,    // [S69] ngưỡng gói CEO
            d.ActivatedAt,
            d.CreatedAt,
            d.Steps.OrderBy(s => s.Order).Select(s => new AwStepDto(
                s.Id,
                s.Order,
                s.Name,
                s.DepartmentId,
                s.DepartmentId != null ? deptNames.GetValueOrDefault(s.DepartmentId.Value) : null,
                s.Levels.OrderBy(l => l.Order).Select(l =>
                {
                    users.TryGetValue(l.ApproverUserId, out var info);
                    // Mig 29 (S21 t5) + Mig 30 (S22+5) + Mig 31 (S23 t1) — 7 Allow* flag per slot Level
                    return new AwLevelDto(l.Id, l.Order, l.Name, l.ApproverUserId, info.FullName, info.Email,
                        l.AllowReturnOneLevel, l.AllowReturnOneStep, l.AllowReturnToAssignee,
                        l.AllowReturnToDrafter, l.AllowApproverEditDetails, l.AllowApproverEditBudget,
                        l.AllowApproverSkipToFinal, l.AllowApproverFinalize, l.AllowApproverDelete,
                        proBudgetEditors.Contains(l.ApproverUserId), ccmBudgetEditors.Contains(l.ApproverUserId));
                }).ToList()
            )).ToList());

        var typesToReturn = request.ApplicableType is int onlyT
            ? new[] { (ApprovalWorkflowApplicableType)onlyT }
            : Enum.GetValues<ApprovalWorkflowApplicableType>();

        var result = typesToReturn
            .Select(type =>
            {
                var versions = definitions.Where(d => d.ApplicableType == type).Select(ToDto).ToList();

                // 🔴 [S187 W1b] Trả ĐỦ mọi bản đang áp dụng của loại, không chỉ bản đầu.
                //
                // Loại per-Code-family (type-10 KHKK 8 nhóm · type-3 HĐ 7 loại) có N bản cùng
                // IsActive; bản trước lấy `versions.FirstOrDefault(v => v.IsActive)` ⇒ N-1 bản kia
                // rơi hết vào `History`, nơi FE lọc `!isActive` để dựng danh sách phiên bản cũ ⇒
                // chúng biến mất khỏi UI, im lặng.
                //
                // Chia 2 ô thay vì gộp 1: `Active` PHẢI giữ nguyên giá trị cũ (FE đang đọc thẳng),
                // nên `ActiveOthers` gánh phần còn lại — hợp của 2 ô = trọn tập, KHÔNG phần tử nào
                // xuất hiện hai lần. Không hỏi ApplicableType ở đây là CỐ Ý: luật là "có mấy bản
                // đang áp dụng thì trả bấy nhiêu", tự đúng cho loại family thêm sau này; loại
                // 1-active-per-type giữ y hệt hành vi cũ (`ActiveOthers` rỗng).
                var actives = versions.Where(v => v.IsActive).ToList();

                return new AwTypeSummaryDto(
                    (int)type,
                    AwLabels.Type.GetValueOrDefault(type, type.ToString()),
                    actives.FirstOrDefault(),
                    actives.Skip(1).ToList(),
                    versions);
            })
            .ToList();

        return new AwAdminOverviewDto(result);
    }
}

// ========== POST new version ==========

public record CreateAwLevelInput(
    int Order,
    string? Name,
    Guid ApproverUserId,
    // Mig 29 (S21 t5) + Mig 30 (S22+5) + Mig 31 (S23 t1) — 7 Allow* options per
    // slot. Admin Designer tick per Level row. Default backward compat:
    // AllowReturnToDrafter=true (S17 fallback), 6 còn lại false (admin opt-in
    // từng slot). Pattern proven 3× cumulative.
    bool AllowReturnOneLevel = false,
    bool AllowReturnOneStep = false,
    bool AllowReturnToAssignee = false,
    bool AllowReturnToDrafter = true,
    bool AllowApproverEditDetails = false,
    bool AllowApproverEditBudget = false,
    bool AllowApproverSkipToFinal = false,
    bool AllowApproverFinalize = false,
    // [Mig 68 S155] F6 — slot này được XÓA MỀM phiếu đang treo ChoDuyet khi tới
    // lượt mình duyệt. Default false (admin opt-in per slot).
    bool AllowApproverDelete = false);

public record CreateAwStepInput(
    int Order,
    string Name,
    Guid? DepartmentId,
    List<CreateAwLevelInput> Levels);

public record CreateAwDefinitionCommand(
    int ApplicableType,
    string Code,
    string Name,
    string? Description,
    List<CreateAwStepInput> Steps,
    // [S69 2026-06-17] Ngưỡng giá trị "gói CEO" — admin nhập trong Workflow Designer.
    // CCM (CostControl) duyệt-final không cần lên CEO khi tổng giá NCC được chọn <
    // ngưỡng; ≥ ngưỡng → vẫn lên CEO (Director). NULL = không áp ngưỡng (luồng cũ).
    // Nullable passthrough — trailing-optional default backward-compat call-site cũ.
    decimal? CeoApprovalThreshold = null) : IRequest<Guid>;

public class CreateAwDefinitionCommandValidator : AbstractValidator<CreateAwDefinitionCommand>
{
    // Convention Mig 22 + UAT iter 2 (2026-05-08):
    //   Tối đa 3 Cấp/Bước (Order ∈ {1,2,3}). Mỗi Cấp có N approver (multiple
    //   Level rows cùng Order = same Cấp, mỗi row = 1 NV). Sequential gating:
    //   có Cấp k yêu cầu Cấp k-1 đã có ≥1 NV. Không duplicate (Order, UserId)
    //   cùng 1 Bước.
    public const int MaxLevelsPerStep = 3;

    public CreateAwDefinitionCommandValidator()
    {
        RuleFor(x => x.ApplicableType).Must(t => Enum.IsDefined(typeof(ApprovalWorkflowApplicableType), t))
            .WithMessage("ApplicableType không hợp lệ.");
        RuleFor(x => x.Code).NotEmpty().MaximumLength(100)
            .Matches("^[A-Za-z0-9._-]+$")
            .WithMessage("Code chỉ dùng chữ, số, và các ký tự . _ -");
        RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
        RuleFor(x => x.Description).MaximumLength(1000);
        RuleFor(x => x.Steps).NotEmpty()
            .WithMessage("Quy trình phải có ít nhất 1 bước.");
        // [S69] Ngưỡng gói CEO — nếu nhập phải >= 0 (NULL = không áp ngưỡng OK).
        RuleFor(x => x.CeoApprovalThreshold).GreaterThanOrEqualTo(0)
            .When(x => x.CeoApprovalThreshold.HasValue)
            .WithMessage("Ngưỡng giá trị gói CEO không được âm.");
        RuleForEach(x => x.Steps).ChildRules(step =>
        {
            step.RuleFor(s => s.Order).GreaterThanOrEqualTo(1);
            step.RuleFor(s => s.Name).NotEmpty().MaximumLength(200);
            step.RuleFor(s => s.Levels).NotEmpty()
                .WithMessage("Mỗi bước phải có ít nhất 1 cấp duyệt.");
            step.RuleForEach(s => s.Levels).ChildRules(level =>
            {
                level.RuleFor(l => l.Order).InclusiveBetween(1, MaxLevelsPerStep)
                    .WithMessage($"Cấp duyệt chỉ trong khoảng 1..{MaxLevelsPerStep}.");
                level.RuleFor(l => l.Name).MaximumLength(200);
                level.RuleFor(l => l.ApproverUserId).NotEmpty()
                    .WithMessage("Mỗi dòng cấp phải chỉ định 1 NV duyệt.");
            });
            // Sequential gating + no-duplicate (Order, UserId).
            step.RuleFor(s => s.Levels).Must(HaveSequentialOrders)
                .WithMessage($"Cấp duyệt phải tuần tự từ 1 (có Cấp k cần Cấp k-1). Tối đa {MaxLevelsPerStep} cấp.");
            step.RuleFor(s => s.Levels).Must(HaveNoDuplicateApproverInSameLevel)
                .WithMessage("Một NV không được duyệt hai lần trong cùng một Cấp.");
        });
    }

    // Cho phép 1 / 1+2 / 1+2+3 — KHÔNG cho 2 (thiếu 1) hoặc 1+3 (thiếu 2).
    private static bool HaveSequentialOrders(List<CreateAwLevelInput> levels)
    {
        if (levels.Count == 0) return false;
        var orders = levels.Select(l => l.Order).Distinct().OrderBy(o => o).ToList();
        if (orders.Count == 0 || orders.Count > MaxLevelsPerStep) return false;
        for (int i = 0; i < orders.Count; i++)
        {
            if (orders[i] != i + 1) return false;
        }
        return true;
    }

    private static bool HaveNoDuplicateApproverInSameLevel(List<CreateAwLevelInput> levels)
    {
        return levels
            .GroupBy(l => new { l.Order, l.ApproverUserId })
            .All(g => g.Count() == 1);
    }
}

public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
    : IRequestHandler<CreateAwDefinitionCommand, Guid>
{
    // 🔴 [S187 W1a] Những loại tắt bản active theo phạm vi **per-CODE** — TẬP TYPE TƯỜNG MINH.
    //
    // Tiêu chí vào danh sách: loại đó có NHIỀU quy trình sống song song trong CÙNG một
    // ApplicableType, mỗi cái một `Code` riêng, và mỗi cái phải tự giữ bản active của mình:
    //   • type-10 `ContractSigningPlan` (KHKK) — 8 nhóm duyệt `KHKK-N1..N8`
    //     (`DbInitializer.SeedKhkkGroupWorkflowsAsync`, seed cả 8 với IsActive=1);
    //   • type-3  `Contract` (HĐ) — 7 loại hợp đồng, mỗi loại một quy trình riêng.
    //
    // Thêm loại vào đây = quyết định về NGỮ NGHĨA của loại đó, không phải về hình dạng mã:
    // mọi `Code` của loại đều đi nhánh per-Code, kể cả mã ngoài họ (R-6).
    private static readonly ApprovalWorkflowApplicableType[] PerCodeDeactivationTypes =
    [
        ApprovalWorkflowApplicableType.ContractSigningPlan,
        ApprovalWorkflowApplicableType.Contract,
    ];

    public async Task<Guid> Handle(CreateAwDefinitionCommand request, CancellationToken ct)
    {
        var typeEnum = (ApprovalWorkflowApplicableType)request.ApplicableType;

        // 🔴 [S182 MAJOR-2] CHUẨN HOÁ `Code` TẠI ĐƯỜNG GHI — nguồn duy nhất cho cả handler.
        //
        // Trước bản này BA thành phần hỏi cùng câu "code này thuộc nhóm nào" bằng BA luật khác nhau:
        //   • `ContractSigningPlanWorkflowGuards.GroupCodePattern` — regex .NET, case-SENSITIVE;
        //   • `w.Code == request.Code` — EF → SQL Server, theo collation `*_CI_AS` = case-INSENSITIVE;
        //   • FE `KhkkCreatePage` — `.trim().toUpperCase()` = case-INSENSITIVE.
        // ⇒ admin gõ `khkk-n1` ở panel type-10 gộp (ô Mã KHÔNG khoá vì `lockedCode = null`):
        //   DB coi là trùng ⇒ hạ `IsActive` của `KHKK-N1` THẬT · FE vẫn auto-pin sang bản mới ·
        //   nhưng regex trả `null` ⇒ rào nhóm 3-site và D5 IM LẶNG TẮT cho toàn bộ phiếu nhóm 1.
        //   Không lỗi nào báo.
        // 🔴 Test KHÔNG thể bắt: 14 ca chạy SQLite, nơi `=` trên TEXT là BINARY (case-SENSITIVE)
        //   ⇒ ngữ nghĩa NGƯỢC prod. Mù theo CẤU TRÚC, không assertion nào bịt được ⇒ phải chặn
        //   bằng chuẩn-hoá, đừng chặn bằng test.
        var code = request.Code.Trim().ToUpperInvariant();

        // Auto-increment version theo Code (cùng Code = cùng "logical" workflow)
        var nextVersion = await db.ApprovalWorkflows
            .Where(w => w.Code == code)
            .MaxAsync(w => (int?)w.Version, ct) ?? 0;
        nextVersion++;

        // Tắt bản đang active — PHẠM VI phụ thuộc loại (bản gốc Mig 22: LUÔN per-type).
        //
        //  • Loại nằm trong `PerCodeDeactivationTypes` (khai báo + tiêu chí ở ĐẦU class này):
        //    **per-CODE**. Chúng có NHIỀU quy trình SỐNG SONG SONG trong cùng một type, mỗi
        //    cái một `Code`. Tắt theo type ⇒ tạo version mới cho MỘT cái sẽ hạ IsActive của
        //    TẤT CẢ những cái còn lại (mỗi cái mất quy trình active ⇒ FE hết auto-pin ⇒
        //    không lập được phiếu), im lặng và KHÔNG có endpoint bật lại.
        //  • các loại còn lại: **per-TYPE** như cũ — với CHÚNG, 1 active/type vẫn là bất biến.
        //    🔴 Đây là câu tự-khai từng SAI: bản trước viết "các loại khác: per-TYPE như cũ —
        //    1 active/type là bất biến của chúng" khi tập per-Code chỉ có type-10, rồi type-3
        //    mọc thêm 7 quy trình `HD-*` sống song song ⇒ câu đó bao luôn type-3 và thành lời
        //    khai sai. Nay câu này neo vào TẬP TYPE tường minh, không neo vào con số.
        //
        // 🔴 [S182 R-6 · giữ nguyên hiệu lực @S187] Vị ngữ per-Code KHOÁ đúng 3 vế `Code == code
        //    && ApplicableType == typeEnum && IsActive`. CẤM thay bằng biến-thể regex trên `Code`
        //    (`^KHKK-N[1-8]$`, `^HD-...$`): mã nằm ngoài họ (vd `QT-KHKK-V2-001`, `QT-HD-V2-001`)
        //    sẽ rơi lại nhánh per-type và nuke đúng tập mà rào này sinh ra để cứu. Phân loại theo
        //    ApplicableType, KHÔNG theo hình dạng mã. (`nextVersion` phía trên vốn đã tính
        //    per-Code toàn cục ⇒ nhánh này làm 2 vế khớp nhau.)
        var actives = PerCodeDeactivationTypes.Contains(typeEnum)
            ? await db.ApprovalWorkflows
                .Where(w => w.Code == code && w.ApplicableType == typeEnum && w.IsActive)
                .ToListAsync(ct)
            : await db.ApprovalWorkflows
                .Where(w => w.ApplicableType == typeEnum && w.IsActive)
                .ToListAsync(ct);
        foreach (var old in actives) old.IsActive = false;

        var def = new ApprovalWorkflow
        {
            Code = code,                // [S182 MAJOR-2] bản đã chuẩn hoá, KHÔNG dùng request.Code thô
            Version = nextVersion,
            ApplicableType = typeEnum,
            Name = request.Name,
            Description = request.Description,
            IsActive = true,
            IsUserSelectable = true,    // Mig 25 — version mới mặc định cho user pick
            // Mig 29 (S21 t5) — Allow* options đã move xuống Level slot (per-NV)
            CeoApprovalThreshold = request.CeoApprovalThreshold,  // [S69] ngưỡng gói CEO (nullable passthrough)
            ActivatedAt = DateTime.UtcNow,
            Steps = request.Steps.OrderBy(s => s.Order)
                .Select(s => new ApprovalWorkflowStep
                {
                    Order = s.Order,
                    Name = s.Name,
                    DepartmentId = s.DepartmentId,
                    Levels = s.Levels.OrderBy(l => l.Order)
                        .Select(l => new ApprovalWorkflowLevel
                        {
                            Order = l.Order,
                            Name = l.Name,
                            ApproverUserId = l.ApproverUserId,
                            // Mig 29 (S21 t5) + Mig 30 (S22+5) + Mig 31 (S23 t1) — 7 Allow* per slot
                            AllowReturnOneLevel = l.AllowReturnOneLevel,
                            AllowReturnOneStep = l.AllowReturnOneStep,
                            AllowReturnToAssignee = l.AllowReturnToAssignee,
                            AllowReturnToDrafter = l.AllowReturnToDrafter,
                            AllowApproverEditDetails = l.AllowApproverEditDetails,
                            AllowApproverEditBudget = l.AllowApproverEditBudget,
                            // [W1 S161 — reviewer F-8, F-B3 containment TẦNG BE] type=10 KHKK: 2 đường
                            // early-finalize anh phán "PHÁ VỠ" (S155), W3 không port code path ⇒ ép false
                            // 🔄 [K3 S165 — OG-9 SUPERSEDE "PHÁ VỠ" @S155, sweep vá-3] GỠ ép-false
                            // type-10 cho `AllowApproverFinalize`: owner @S164 đảo phán quyết ("a Chương
                            // quyết cái gì cần CEO") — service KHKK ĐÃ port nhánh level-finalize cùng
                            // wave ⇒ cờ hết config-lie #78. Đường bật cờ = TẠO VERSION MỚI (đây);
                            // PUT sửa-tại-chỗ vẫn chặn đổi cờ (luật versioning CHUNG mọi type — spec
                            // vá-3 liệt nhầm vào "cụm ép-false", lead giữ + khai lệch sub-implbe-k3.md).
                            // 🔴 `AllowApproverSkipToFinal` GIỮ ép-false CHO RIÊNG type-10 (K3-test O-1):
                            // service KHKK 0 lần đọc cờ này ("giữ nguyên không port" — 3-ĐIỂM-ADAPT (b))
                            // ⇒ echo nó cho type-10 là ĐẺ config-lie #78 mới (DB true, máy im).
                            AllowApproverSkipToFinal = typeEnum != ApprovalWorkflowApplicableType.ContractSigningPlan && l.AllowApproverSkipToFinal,
                            AllowApproverFinalize = l.AllowApproverFinalize,
                            AllowApproverDelete = l.AllowApproverDelete,     // [Mig 68 S155] F6
                        }).ToList(),
                })
                .ToList(),
        };
        db.ApprovalWorkflows.Add(def);
        await db.SaveChangesAsync(ct);
        return def.Id;
    }
}

// ============================================================================
// ========== PUT sửa TẠI CHỖ (S155 2026-07-27 — hạng mục F, owner chốt (7)) ==========
// ============================================================================
// Owner: "Chỗ quy trình duyệt -> Cho thêm tính năng thêm người/điều chỉnh quyền
// -> Chứ ko cần phải tạo mới. Khi nào cần thay đổi quy trình duyệt thì mới bắt
// buộc tạo mới."
//
// 🔴 VÌ SAO PHẢI CÓ (§F.0 spec S155 — phụ thuộc CỨNG, không phải tiện nghi):
//   POST tạo version mới sinh Id MỚI (:336) → phiếu đang treo pin Id CŨ
//   (`PurchaseEvaluationWorkflowService.cs:674` `w.Id == awId`, tham chiếu SỐNG
//   không snapshot) → bản cũ chỉ bị IsActive=false (:334) nên vẫn resolve được →
//   phiếu chạy tiếp trên cấu hình CŨ, IM LẶNG. Không có đường re-pin
//   (`PE.ApprovalWorkflowId` chỉ ghi lúc create/update-nháp). ⇒ Tick cờ hôm nay
//   bằng POST thì phiếu cần cứu KHÔNG BAO GIỜ thấy cờ.
//   ⇒ Lệnh này GIỮ NGUYÊN Id + Version + IsActive. Giữ Id là TOÀN BỘ mục đích.
//
// 🔴 PHÂN LOẠI (§F.2) — AN TOÀN ⟺ thoả CẢ HAI phép thử:
//   (T1) Khung không đổi — tập {Step.Order} và {Level.Order} giữ nguyên (con trỏ
//        phiếu LAI: StepIndex = INDEX vào list sort `:686`, LevelOrder = so GIÁ
//        TRỊ `:694` ⇒ giữ 2 tập Order thì không con trỏ nào dịch).
//        Ở đây SIẾT MẠNH HƠN set-equality: Order của TỪNG row là BẤT BIẾN. Lý do:
//        hoán vị Order giữa 2 Bước giữ nguyên TẬP nhưng đổi thứ tự thật (#11 PHÁ VỠ),
//        set-equality một mình KHÔNG bắt được.
//   (T2) Luật chơi không đổi — cấm trường quyết định ĐƯỜNG ĐI: CeoApprovalThreshold,
//        F5 AllowApproverFinalize, Code, ApplicableType.
//
// ✅ Cho sửa: thêm/bớt người trong Cấp CÓ SẴN · đổi ApproverUserId · 7 cờ
//    QUYỀN-TẠI-CHỖ (F1×4, F3, F4, F6) · tên hiển thị (workflow/step/level) ·
//    Step.DepartmentId (owner chốt AN TOÀN — BE chỉ dùng làm hint, không khớp người duyệt).
// ❌ Từ chối 409 + bảo tạo version mới: thêm/bớt/đổi thứ tự Bước hoặc Cấp ·
//    Code · ApplicableType · CeoApprovalThreshold · F5 · F2.
//
// 🔸 KHAI QUYẾT ĐỊNH (spec KHÔNG xếp hạng F2 `AllowApproverSkipToFinal`): xếp F2
//    vào nhóm CẤM-ĐỔI cùng F5 vì nó cho phép nhảy thẳng Cấp cuối = đổi đường đi
//    phiếu, không phải "quyền tại chỗ". Chọn phía CHẶT khi spec im lặng; nới sau
//    rẻ hơn thu hồi. Cả F2 lẫn F5 nhận theo kiểu ECHO: gửi lên phải KHỚP giá trị
//    đang lưu (null = không khai báo, giữ nguyên), lệch ⇒ 409 — KHÔNG bao giờ ghi.

public record UpdateAwLevelInput(
    // null = dòng MỚI (thêm người vào Cấp CÓ SẴN). Có Id = dòng đang tồn tại.
    Guid? Id,
    int Order,
    string? Name,
    Guid ApproverUserId,
    // 7 cờ QUYỀN-TẠI-CHỖ — sửa được tại chỗ.
    bool AllowReturnOneLevel = false,
    bool AllowReturnOneStep = false,
    bool AllowReturnToAssignee = false,
    bool AllowReturnToDrafter = true,
    bool AllowApproverEditDetails = false,
    bool AllowApproverEditBudget = false,
    bool AllowApproverDelete = false,
    // 2 cờ ĐỔI-ĐƯỜNG-ĐI — ECHO-only (null = không khai báo). Lệch ⇒ 409.
    bool? AllowApproverSkipToFinal = null,
    bool? AllowApproverFinalize = null);

public record UpdateAwStepInput(
    Guid Id,                    // BẮT BUỘC — không có đường thêm/bớt Bước ở lệnh này
    int Order,
    string Name,
    Guid? DepartmentId,
    List<UpdateAwLevelInput> Levels);

public record UpdateAwDefinitionCommand(
    Guid Id,
    string Name,
    string? Description,
    List<UpdateAwStepInput> Steps,
    // F-1c — lý do sửa, đi vào VẾT (changelog phiếu đang chạy). Optional.
    string? Reason = null,
    // (T2) ECHO-only. Code/ApplicableType: null = không khai báo → bỏ qua.
    string? Code = null,
    int? ApplicableType = null,
    // 🔴 CeoApprovalThreshold ECHO NGHIÊM: phải KHỚP TUYỆT ĐỐI giá trị đang lưu
    // (cả 2 cùng null, hoặc cùng giá trị). KHÔNG dùng "null = bỏ qua" như 2 trường
    // trên vì null LÀ một giá trị có nghĩa (= bỏ ngưỡng) ⇒ nếu bỏ qua thì admin xoá
    // trắng ô ngưỡng sẽ nhận 204 mà số KHÔNG đổi = sai IM LẶNG, đúng cái class lỗi
    // spec này đang đi chữa. Client PHẢI echo lại ngưỡng hiện tại.
    decimal? CeoApprovalThreshold = null) : IRequest;

public class UpdateAwDefinitionCommandValidator : AbstractValidator<UpdateAwDefinitionCommand>
{
    public UpdateAwDefinitionCommandValidator()
    {
        RuleFor(x => x.Id).NotEmpty();
        RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
        RuleFor(x => x.Description).MaximumLength(1000);
        RuleFor(x => x.Reason).MaximumLength(500);
        RuleFor(x => x.Steps).NotEmpty()
            .WithMessage("Quy trình phải có ít nhất 1 bước.");
        RuleForEach(x => x.Steps).ChildRules(step =>
        {
            step.RuleFor(s => s.Id).NotEmpty()
                .WithMessage("Mỗi Bước phải kèm Id của Bước đang tồn tại (sửa tại chỗ không tạo Bước mới).");
            step.RuleFor(s => s.Order).GreaterThanOrEqualTo(1);
            step.RuleFor(s => s.Name).NotEmpty().MaximumLength(200);
            step.RuleFor(s => s.Levels).NotEmpty()
                .WithMessage("Mỗi bước phải có ít nhất 1 cấp duyệt.");
            step.RuleForEach(s => s.Levels).ChildRules(level =>
            {
                level.RuleFor(l => l.Order)
                    .InclusiveBetween(1, CreateAwDefinitionCommandValidator.MaxLevelsPerStep);
                level.RuleFor(l => l.Name).MaximumLength(200);
                level.RuleFor(l => l.ApproverUserId).NotEmpty()
                    .WithMessage("Mỗi dòng cấp phải chỉ định 1 NV duyệt.");
            });
            step.RuleFor(s => s.Levels).Must(HaveNoDuplicateApproverInSameLevel)
                .WithMessage("Một NV không được duyệt hai lần trong cùng một Cấp.");
        });
    }

    private static bool HaveNoDuplicateApproverInSameLevel(List<UpdateAwLevelInput> levels) =>
        levels.GroupBy(l => new { l.Order, l.ApproverUserId }).All(g => g.Count() == 1);
}

public class UpdateAwDefinitionCommandHandler(
    IApplicationDbContext db,
    ICurrentUser currentUser,
    IDateTime dateTime,
    UserManager<User> userManager) : IRequestHandler<UpdateAwDefinitionCommand>
{
    private const string NewVersionHint =
        "Thay đổi này đổi CẤU TRÚC/ĐƯỜNG ĐI của quy trình nên không sửa tại chỗ được — hãy tạo phiên bản quy trình MỚI.";

    private sealed record LevelOpinionCount(Guid LevelId, int Count);

    // F2 — một chữ ký CÒN LƯU trên dòng Cấp, kèm đủ thứ để NÓI TÊN hồ sơ đang giữ nó.
    // `Code` nullable vì mọi module đều cho hồ sơ nháp chưa sinh mã.
    private sealed record RetainedOpinionRef(Guid LevelId, string Module, string? Code, bool ParentDeleted);

    public async Task Handle(UpdateAwDefinitionCommand request, CancellationToken ct)
    {
        var def = await db.ApprovalWorkflows
            .Include(w => w.Steps)
                .ThenInclude(s => s.Levels)
            .FirstOrDefaultAsync(w => w.Id == request.Id, ct)
            ?? throw new NotFoundException("Quy trình duyệt", request.Id);

        // ================= (T2) LUẬT CHƠI KHÔNG ĐỔI =================
        if (request.Code is string code && !string.Equals(code, def.Code, StringComparison.Ordinal))
            throw new ConflictException(
                $"Không được đổi Mã quy trình khi sửa tại chỗ ({def.Code} → {code}): mã là khoá của chuỗi phiên bản. {NewVersionHint}");

        if (request.ApplicableType is int applicableType && applicableType != (int)def.ApplicableType)
            throw new ConflictException(
                $"Không được đổi Loại áp dụng khi sửa tại chỗ: phiếu đang pin quy trình này sẽ chạy sang module khác. {NewVersionHint}");

        if (request.CeoApprovalThreshold != def.CeoApprovalThreshold)
            throw new ConflictException(
                $"Không được đổi Ngưỡng giá trị gói CEO khi sửa tại chỗ ({FormatMoney(def.CeoApprovalThreshold)} → {FormatMoney(request.CeoApprovalThreshold)}): " +
                $"phiếu đang chờ sẽ đổi nhánh cần/không-cần CEO giữa chừng, trong khi người đã duyệt thì duyệt theo ngưỡng họ nhìn thấy lúc đó. {NewVersionHint}");

        // ================= (T1) KHUNG KHÔNG ĐỔI — Bước =================
        var dbSteps = def.Steps.OrderBy(s => s.Order).ToList();
        var reqStepIds = request.Steps.Select(s => s.Id).ToList();
        if (reqStepIds.Distinct().Count() != reqStepIds.Count)
            throw new ConflictException("Danh sách Bước gửi lên có Id trùng nhau.");
        if (!dbSteps.Select(s => s.Id).ToHashSet().SetEquals(reqStepIds))
            throw new ConflictException(
                $"Không được thêm/bớt Bước khi sửa tại chỗ (quy trình đang có {dbSteps.Count} bước, gửi lên {reqStepIds.Count}). {NewVersionHint}");

        // ================= Phân tích diff theo Level.Id (F-3: CẤM delete-then-insert) =================
        var toRemove = new List<ApprovalWorkflowLevel>();                                  // bỏ người khỏi Cấp
        var toUpdate = new List<(ApprovalWorkflowLevel Db, UpdateAwLevelInput Req, ApprovalWorkflowStep Step)>();
        var toAdd = new List<(ApprovalWorkflowStep Step, UpdateAwLevelInput Req)>();
        var approverChanged = new List<ApprovalWorkflowLevel>();                            // đổi ApproverUserId (hạng NỬA #5)

        foreach (var reqStep in request.Steps)
        {
            var dbStep = dbSteps.First(s => s.Id == reqStep.Id);
            var where = $"Bước {dbStep.Order} ({dbStep.Name})";

            if (reqStep.Order != dbStep.Order)
                throw new ConflictException(
                    $"Không được đổi thứ tự Bước ({where}: {dbStep.Order} → {reqStep.Order}): con trỏ phiếu trỏ theo INDEX của danh sách đã sắp xếp nên sẽ trỏ sang bước khác, ÂM THẦM. {NewVersionHint}");

            var dbLevels = dbStep.Levels.OrderBy(l => l.Order).ToList();
            var reqLevelIds = reqStep.Levels.Where(l => l.Id is Guid).Select(l => l.Id!.Value).ToList();
            if (reqLevelIds.Distinct().Count() != reqLevelIds.Count)
                throw new ConflictException($"{where}: danh sách cấp duyệt gửi lên có Id trùng nhau.");

            var dbLevelById = dbLevels.ToDictionary(l => l.Id);
            var unknown = reqLevelIds.Where(id => !dbLevelById.ContainsKey(id)).ToList();
            if (unknown.Count > 0)
                throw new ConflictException($"{where}: có dòng cấp duyệt không thuộc bước này (Id {string.Join(", ", unknown)}).");

            foreach (var reqLevel in reqStep.Levels)
            {
                if (reqLevel.Id is Guid lid)
                {
                    var dbLevel = dbLevelById[lid];

                    // Order của TỪNG dòng là BẤT BIẾN (siết mạnh hơn set-equality).
                    if (reqLevel.Order != dbLevel.Order)
                        throw new ConflictException(
                            $"{where}: không được chuyển người duyệt từ Cấp {dbLevel.Order} sang Cấp {reqLevel.Order} khi sửa tại chỗ. {NewVersionHint}");

                    // (T2) 2 cờ ĐỔI-ĐƯỜNG-ĐI — echo phải khớp.
                    if (reqLevel.AllowApproverFinalize is bool f && f != dbLevel.AllowApproverFinalize)
                        throw new ConflictException(
                            $"{where} — Cấp {dbLevel.Order}: không được bật/tắt cờ \"Duyệt là kết thúc (không trình CEO)\" khi sửa tại chỗ — cờ này đổi ĐIỂM KẾT THÚC của phiếu và đóng băng số tiền ngay lúc duyệt. {NewVersionHint}");
                    if (reqLevel.AllowApproverSkipToFinal is bool s && s != dbLevel.AllowApproverSkipToFinal)
                        throw new ConflictException(
                            $"{where} — Cấp {dbLevel.Order}: không được bật/tắt cờ \"Duyệt vượt cấp tới Cấp cuối\" khi sửa tại chỗ — cờ này cho bỏ qua các Bước/Cấp trung gian. {NewVersionHint}");

                    toUpdate.Add((dbLevel, reqLevel, dbStep));
                    if (reqLevel.ApproverUserId != dbLevel.ApproverUserId)
                        approverChanged.Add(dbLevel);
                }
                else
                {
                    // Dòng MỚI — người mới KHÔNG được mang cờ đổi-đường-đi.
                    if (reqLevel.AllowApproverFinalize == true)
                        throw new ConflictException(
                            $"{where} — Cấp {reqLevel.Order}: người mới thêm không được mang cờ \"Duyệt là kết thúc (không trình CEO)\". {NewVersionHint}");
                    if (reqLevel.AllowApproverSkipToFinal == true)
                        throw new ConflictException(
                            $"{where} — Cấp {reqLevel.Order}: người mới thêm không được mang cờ \"Duyệt vượt cấp tới Cấp cuối\". {NewVersionHint}");

                    toAdd.Add((dbStep, reqLevel));
                }
            }

            var removedHere = dbLevels.Where(l => !reqLevelIds.Contains(l.Id)).ToList();
            foreach (var gone in removedHere)
            {
                // Bỏ người đang giữ cờ đổi-đường-đi = đổi điểm kết thúc / bỏ đường tắt.
                if (gone.AllowApproverFinalize)
                    throw new ConflictException(
                        $"{where} — Cấp {gone.Order}: không được bỏ người đang giữ cờ \"Duyệt là kết thúc (không trình CEO)\" — bỏ đi là đổi ĐIỂM KẾT THÚC của phiếu. {NewVersionHint}");
                if (gone.AllowApproverSkipToFinal)
                    throw new ConflictException(
                        $"{where} — Cấp {gone.Order}: không được bỏ người đang giữ cờ \"Duyệt vượt cấp tới Cấp cuối\". {NewVersionHint}");
            }
            toRemove.AddRange(removedHere);

            // (T1) tập {Level.Order} SAU khi sửa phải trùng khít tập TRƯỚC.
            var finalOrders = reqStep.Levels.Select(l => l.Order).ToHashSet();
            var dbOrders = dbLevels.Select(l => l.Order).ToHashSet();
            if (!dbOrders.SetEquals(finalOrders))
            {
                var lost = dbOrders.Except(finalOrders).OrderBy(o => o).ToList();
                if (lost.Count > 0)
                    throw new ConflictException(
                        $"{where}: Cấp {string.Join(", ", lost)} sẽ KHÔNG CÒN người duyệt nào — phiếu đang chờ ở cấp đó sẽ KẸT. Hãy giữ lại ít nhất 1 người ở mỗi Cấp. {NewVersionHint}");
                var added = finalOrders.Except(dbOrders).OrderBy(o => o).ToList();
                throw new ConflictException(
                    $"{where}: không được thêm Cấp mới (Cấp {string.Join(", ", added)}) khi sửa tại chỗ. {NewVersionHint}");
            }
        }

        // ================= NV được trỏ tới phải tồn tại (chặn FK 547 → 500) =================
        var referencedUserIds = toUpdate.Select(t => t.Req.ApproverUserId)
            .Concat(toAdd.Select(t => t.Req.ApproverUserId))
            .Distinct().ToList();
        if (referencedUserIds.Count > 0)
        {
            var existing = await db.Users.AsNoTracking()
                .Where(u => referencedUserIds.Contains(u.Id))
                .Select(u => u.Id).ToListAsync(ct);
            var missing = referencedUserIds.Except(existing).ToList();
            if (missing.Count > 0)
                throw new ConflictException($"Có NV duyệt không tồn tại trong hệ thống (Id {string.Join(", ", missing)}).");
        }

        // ================= F-5 / F-5a — hạng NỬA: chặn LỊCH SỰ trước khi FK nổ =================
        // Row đã có CHỮ KÝ (LevelOpinion) mà bị bỏ đi → FK Restrict nổ 547 → 500.
        // Row đã có chữ ký mà bị đổi ApproverUserId → chữ ký của người A hoá thành
        // của người B, IM LẶNG (opinion khoá theo LevelId, không theo UserId).
        // Guard chạy HAI TẦNG (tầng 2 thêm ở wave 5 theo F2 của reviewer):
        //   tầng 1 `CountLiveOpinionsAsync` — chữ ký trên hồ sơ CÒN SỐNG (thông điệp cũ).
        //   tầng 2 `ListRetainedOpinionsAsync` — chữ ký còn LƯU nhưng không "sống"
        //          (hồ sơ cha xoá mềm, hoặc ý kiến tự nó xoá mềm).
        // 🔴 ĐẢO QUYẾT ĐỊNH F-5a (bản trước): trước đây coi "phiếu đã xoá thì chặn admin là
        // OAN" nên để purge xoá CỨNG chữ ký của phiếu xoá mềm. Sai — xoá mềm là KHÔI PHỤC
        // ĐƯỢC và §A spec S155 có màn "Đã xoá" đọc lịch sử; xoá cứng chữ ký của nó là mất
        // trắng, im lặng, không dấu vết. Nay chặn cả hai tầng; chỉ hồ sơ đã KHÔNG CÒN ROW
        // mới bị coi là mồ côi (xem `PurgeOrphanOpinionsAsync`).
        var guardedLevelIds = toRemove.Select(l => l.Id).Concat(approverChanged.Select(l => l.Id)).Distinct().ToList();
        if (guardedLevelIds.Count > 0)
        {
            var liveOpinions = await CountLiveOpinionsAsync(guardedLevelIds, ct);
            var blocked = toRemove.Where(l => liveOpinions.GetValueOrDefault(l.Id) > 0).ToList();
            if (blocked.Count > 0)
            {
                var first = blocked[0];
                var step = dbSteps.First(s => s.Id == first.ApprovalWorkflowStepId);
                throw new ConflictException(
                    $"Bước {step.Order} ({step.Name}) — Cấp {first.Order}: không bỏ được người này vì đã có {liveOpinions[first.Id]} chữ ký/ý kiến trên phiếu còn hiệu lực. Bỏ đi là xoá mất lịch sử duyệt. {NewVersionHint}");
            }
            var blockedSwap = approverChanged.Where(l => liveOpinions.GetValueOrDefault(l.Id) > 0).ToList();
            if (blockedSwap.Count > 0)
            {
                var first = blockedSwap[0];
                var step = dbSteps.First(s => s.Id == first.ApprovalWorkflowStepId);
                throw new ConflictException(
                    $"Bước {step.Order} ({step.Name}) — Cấp {first.Order}: không đổi được người duyệt vì dòng này đã có {liveOpinions[first.Id]} chữ ký/ý kiến trên phiếu còn hiệu lực — đổi người sẽ khiến ý kiến của người cũ hoá thành của người mới. {NewVersionHint}");
            }

            // ===== 🔴 F2 — TẦNG 2: chữ ký CÒN LƯU nhưng không "sống" =====
            // Hai ca mà tầng 1 CỐ TÌNH bỏ qua, và trước bản vá bị `PurgeOrphanOpinionsAsync`
            // xoá CỨNG (mất vĩnh viễn, chỉ để lại một con số đếm trong changelog):
            //   (a) hồ sơ cha XOÁ MỀM — row còn nguyên, khôi phục được, màn "Đã xoá" phải
            //       đọc được lịch sử ký;
            //   (b) chính opinion đã xoá mềm trên hồ sơ CÒN SỐNG — bản ghi KIỂM TOÁN của
            //       tài liệu đang hiệu lực.
            // Nay chặn admin y như phiếu sống, và NÓI RÕ hồ sơ nào đang giữ chữ ký để họ
            // xử lý được (khôi phục, hoặc xoá hẳn) thay vì đoán.
            var retained = await ListRetainedOpinionsAsync(guardedLevelIds, ct);
            if (retained.Count > 0)
            {
                var goneFirst = toRemove.FirstOrDefault(l => retained.ContainsKey(l.Id));
                var target = goneFirst ?? approverChanged.FirstOrDefault(l => retained.ContainsKey(l.Id));
                if (target is not null)
                {
                    var step = dbSteps.First(s => s.Id == target.ApprovalWorkflowStepId);
                    var rows = retained[target.Id];
                    var verb = goneFirst is not null ? "không bỏ được người này" : "không đổi được người duyệt";
                    throw new ConflictException(
                        $"Bước {step.Order} ({step.Name}) — Cấp {target.Order}: {verb} vì còn {rows.Count} chữ ký/ý kiến đang LƯU, thuộc: {DescribeRetained(rows)}. " +
                        $"Chữ ký của hồ sơ đã xoá vẫn là LỊCH SỬ DUYỆT (màn \"Đã xoá\" còn đọc được) nên hệ thống KHÔNG tự xoá cứng. " +
                        $"Hãy khôi phục hồ sơ rồi xử lý, hoặc xoá HẲN hồ sơ đó khỏi hệ thống trước. {NewVersionHint}");
                }
            }
        }

        // ================= 🔴 F1 (reviewer đợt 1) — RÀO GIAO DỊCH =================
        // TRƯỚC bản vá: `PurgeOrphanOpinionsAsync` dùng `ExecuteDeleteAsync`, mà
        // ExecuteDelete phát DELETE thẳng xuống SQL và **auto-commit NGAY** khi không có
        // transaction ambient. `SaveChangesAsync` thì mãi cuối method. Giữa hai điểm đó có
        // ÍT NHẤT 4 nguồn ném ĐỘC LẬP: `ResolveUserNamesAsync` (round-trip DB) ·
        // `db.ApprovalWorkflowLevels.Remove` (severed-association nếu detach bắt hụt) ·
        // truy vấn phiếu bị ảnh hưởng (round-trip DB) · chính `SaveChanges` (FK/unique/
        // concurrency/timeout/huỷ-token). Ném ở bất kỳ chỗ nào ⇒ mọi thay đổi sau đó
        // rollback, NHƯNG chữ ký đã bị xoá CỨNG thì không quay lại — admin nhận 409/500 và
        // tưởng "không có gì xảy ra". Đúng class lỗi SAI IM LẶNG mà spec S155 đang đi chữa.
        //
        // Mở transaction TẠI ĐÂY — ngay trước thao tác KHÔNG-HOÀN-TÁC đầu tiên. Mọi guard
        // phía trên chỉ ĐỌC (AsNoTracking) và ném trước khi chạm dữ liệu, nên không cần
        // nằm trong rào; mở sớm hơn chỉ kéo dài thời gian giữ khoá trên SQL Server.
        // KHÔNG dùng IsolationLevel.Serializable như các code-generator (`ContractCodeGenerator.cs:37`):
        // ở đó Serializable để chống ĐUA cấp-số; ở đây yêu cầu là NGUYÊN TỬ (all-or-nothing),
        // không phải chống đua — siết isolation chỉ làm màn quản trị dễ deadlock.
        // `await using` + dispose = rollback nếu chưa Commit (khuôn nhà: `LeaveOtApprovalFeatures.cs:34-44`).
        var context = (DbContext)db;
        await using var tx = await context.Database.BeginTransactionAsync(ct);

        // ================= Dọn rác chữ ký MỒ CÔI trước khi xoá dòng =================
        // 🔴 F2 (reviewer đợt 1) đã THU HẸP nhãn "mồ côi": chỉ còn opinion mà hồ sơ cha
        // KHÔNG CÒN ROW trong DB. Hồ sơ xoá MỀM giữ nguyên chữ ký và bị guard chặn ở trên.
        // 🔴 LevelOpinion là AuditableEntity ⇒ Remove() chỉ xoá MỀM (interceptor đổi
        // Deleted→Modified) ⇒ row ở lại ⇒ FK vẫn chặn. Phải ExecuteDelete (SQL thẳng).
        var purgedOrphans = 0;
        if (toRemove.Count > 0)
            purgedOrphans = await PurgeOrphanOpinionsAsync(toRemove.Select(l => l.Id).ToList(), ct);

        // ================= ÁP THAY ĐỔI (chỉ trường hạng AN TOÀN) =================
        var userNames = await ResolveUserNamesAsync(
            toUpdate.Select(t => t.Db.ApproverUserId)
                .Concat(toUpdate.Select(t => t.Req.ApproverUserId))
                .Concat(toAdd.Select(t => t.Req.ApproverUserId))
                .Concat(toRemove.Select(l => l.ApproverUserId))
                .Distinct().ToList(), ct);
        string NameOf(Guid id) => userNames.GetValueOrDefault(id) ?? id.ToString();

        var changes = new List<string>();

        if (def.Name != request.Name) changes.Add($"Đổi tên quy trình: \"{def.Name}\" → \"{request.Name}\"");
        def.Name = request.Name;
        def.Description = request.Description;

        foreach (var reqStep in request.Steps)
        {
            var dbStep = dbSteps.First(s => s.Id == reqStep.Id);
            if (dbStep.Name != reqStep.Name) changes.Add($"Bước {dbStep.Order}: đổi tên \"{dbStep.Name}\" → \"{reqStep.Name}\"");
            if (dbStep.DepartmentId != reqStep.DepartmentId) changes.Add($"Bước {dbStep.Order} ({reqStep.Name}): đổi phòng ban phụ trách");
            dbStep.Name = reqStep.Name;
            dbStep.DepartmentId = reqStep.DepartmentId;
        }

        foreach (var (dbLevel, reqLevel, step) in toUpdate)
        {
            if (dbLevel.ApproverUserId != reqLevel.ApproverUserId)
                changes.Add($"Bước {step.Order} Cấp {dbLevel.Order}: đổi NV duyệt {NameOf(dbLevel.ApproverUserId)} → {NameOf(reqLevel.ApproverUserId)}");

            var flagDiff = DescribeFlagChanges(dbLevel, reqLevel);
            if (flagDiff.Count > 0)
                changes.Add($"Bước {step.Order} Cấp {dbLevel.Order} — {NameOf(reqLevel.ApproverUserId)}: {string.Join("; ", flagDiff)}");

            dbLevel.Name = reqLevel.Name;
            dbLevel.ApproverUserId = reqLevel.ApproverUserId;
            dbLevel.AllowReturnOneLevel = reqLevel.AllowReturnOneLevel;
            dbLevel.AllowReturnOneStep = reqLevel.AllowReturnOneStep;
            dbLevel.AllowReturnToAssignee = reqLevel.AllowReturnToAssignee;
            dbLevel.AllowReturnToDrafter = reqLevel.AllowReturnToDrafter;
            dbLevel.AllowApproverEditDetails = reqLevel.AllowApproverEditDetails;
            dbLevel.AllowApproverEditBudget = reqLevel.AllowApproverEditBudget;
            dbLevel.AllowApproverDelete = reqLevel.AllowApproverDelete;
            // ❌ KHÔNG gán AllowApproverFinalize / AllowApproverSkipToFinal — echo-only.
        }

        foreach (var (step, reqLevel) in toAdd)
        {
            var newLevel = new ApprovalWorkflowLevel
            {
                ApprovalWorkflowStepId = step.Id,
                Order = reqLevel.Order,
                Name = reqLevel.Name,
                ApproverUserId = reqLevel.ApproverUserId,
                AllowReturnOneLevel = reqLevel.AllowReturnOneLevel,
                AllowReturnOneStep = reqLevel.AllowReturnOneStep,
                AllowReturnToAssignee = reqLevel.AllowReturnToAssignee,
                AllowReturnToDrafter = reqLevel.AllowReturnToDrafter,
                AllowApproverEditDetails = reqLevel.AllowApproverEditDetails,
                AllowApproverEditBudget = reqLevel.AllowApproverEditBudget,
                AllowApproverDelete = reqLevel.AllowApproverDelete,
                AllowApproverSkipToFinal = false,   // đổi-đường-đi: dòng mới luôn tắt
                AllowApproverFinalize = false,
            };

            // 🔴 PHẢI đăng ký TƯỜNG MINH qua DbSet.Add — KHÔNG được chỉ `step.Levels.Add(...)`.
            // `BaseEntity.Id = Guid.NewGuid()` (BaseEntity.cs:5) ⇒ khoá LUÔN khác default.
            // Khi DetectChanges gặp một entity lạ trong collection của cha ĐANG ĐƯỢC THEO DÕI,
            // EF quyết Added/Modified bằng heuristic "khoá đã set ⇒ coi như đã tồn tại" ⇒ nó
            // gán state **Modified** ⇒ phát UPDATE cho dòng CHƯA CÓ ⇒ 0 row ⇒
            // DbUpdateConcurrencyException. DbSet.Add ép state Added, không qua heuristic.
            // (POST tạo mới không dính vì Add từ GỐC — cả đồ thị bị ép Added.)
            db.ApprovalWorkflowLevels.Add(newLevel);
            step.Levels.Add(newLevel);   // giữ nav in-memory nhất quán cho phần đọc phía sau

            changes.Add($"Bước {step.Order} Cấp {reqLevel.Order}: THÊM NV duyệt {NameOf(reqLevel.ApproverUserId)}");
        }

        // 🔴 Gỡ THEO DÕI mọi bản sao chữ ký còn nằm trong change-tracker và trỏ vào các dòng
        // Cấp sắp xoá — BẮT BUỘC làm TRƯỚC `Remove` bên dưới. Xem `DetachTrackedOpinionsOn`.
        if (toRemove.Count > 0)
            DetachTrackedOpinionsOn(db, toRemove.Select(l => l.Id).ToHashSet());

        foreach (var gone in toRemove)
        {
            var step = dbSteps.First(s => s.Id == gone.ApprovalWorkflowStepId);
            changes.Add($"Bước {step.Order} Cấp {gone.Order}: BỎ NV duyệt {NameOf(gone.ApproverUserId)}");
            db.ApprovalWorkflowLevels.Remove(gone);   // BaseEntity → xoá CỨNG (không dính soft-delete)
        }

        if (purgedOrphans > 0)
            changes.Add($"Dọn {purgedOrphans} chữ ký mồ côi (hồ sơ mang chữ ký đã KHÔNG CÒN trong hệ thống) để gỡ được người khỏi Cấp");

        // ================= F-1c — GHI VẾT =================
        // (a) Chạm entity cha để AuditingInterceptor đóng dấu UpdatedAt/UpdatedBy kể
        //     cả khi chỉ có dòng Cấp đổi (ai + lúc nào).
        def.UpdatedAt = dateTime.UtcNow;

        // (b) LÝ DO + NỘI DUNG đổi → changelog của CHÍNH những phiếu đang chịu ảnh
        //     hưởng (phiếu chưa kết thúc, đang pin quy trình này). Đối xứng với yêu
        //     cầu ghi changelog khi xoá phiếu (§B4): người đọc lịch sử phiếu thấy
        //     được "quy trình vừa bị sửa lúc nào, ai sửa, vì sao".
        //     ⚠️ KHAI GIỚI HẠN: repo CHƯA có bảng changelog cho quy trình duyệt, mà
        //     đợt 1 bị chốt "no new table" ⇒ vết cho workflow thuộc 5 module Văn
        //     phòng số (Proposal/Leave/Ot/Travel/Vehicle) hiện CHỈ có UpdatedAt/
        //     UpdatedBy ở (a). Bảng `ApprovalWorkflowChangelogs` = việc tách riêng.
        //     🔄 [S182 W1c] KHKK (type-10) ĐÃ WIRE — xem nhánh `else if` bên dưới. Câu cũ ở
        //     đây xếp KHKK chung rọ "chỉ có UpdatedAt/UpdatedBy" và hẹn "wire nếu cần ở W2/W3";
        //     đợt này wire thật nên câu đó gỡ, không để lại neo thối.
        var note = string.Join(" | ", changes);
        if (!string.IsNullOrWhiteSpace(request.Reason))
            note = $"Lý do: {request.Reason.Trim()} | {note}";
        var summary = $"Sửa quy trình duyệt tại chỗ: {def.Code} v{def.Version} (giữ nguyên phiên bản)";
        var actorName = currentUser.FullName ?? currentUser.Email ?? "Hệ thống";

        if (changes.Count > 0 &&
            def.ApplicableType is ApprovalWorkflowApplicableType.DuyetNcc
                               or ApprovalWorkflowApplicableType.DuyetNccPhuongAn)
        {
            var affected = await db.PurchaseEvaluations.AsNoTracking()
                .Where(p => p.ApprovalWorkflowId == def.Id
                         && p.Phase != PurchaseEvaluationPhase.DaDuyet
                         && p.Phase != PurchaseEvaluationPhase.TuChoi)
                .Select(p => new { p.Id, p.Phase })
                .ToListAsync(ct);

            foreach (var pe in affected)
            {
                db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
                {
                    PurchaseEvaluationId = pe.Id,
                    EntityType = PurchaseEvaluationEntityType.Workflow,
                    Action = SolutionErp.Domain.Contracts.ChangelogAction.Update,
                    EntityId = def.Id,
                    PhaseAtChange = pe.Phase,
                    UserId = currentUser.UserId,
                    UserName = actorName,
                    Summary = summary,
                    ContextNote = note,
                });
            }
        }
        // [S182 W1c] KHKK (type-10) — mirror khối PE ngay trên, cùng shape 8 field.
        //
        // VÌ SAO CẦN: dialog PUT hứa nguyên văn với admin "ghi vào lịch sử phiếu đang chạy"
        // (`ApprovalWorkflowsV2Page.tsx:963-977`). Với type-10 lời hứa đó trước nay là RỖNG —
        // sửa quy trình xong lịch sử phiếu KHKK không có dòng nào. Đây là fix cho lời hứa
        // thành thật, không phải tính năng mới.
        //
        // 🔴 Vị ngữ phiếu "đang chịu ảnh hưởng" = CHƯA kết thúc: loại 2 phase TERMINAL
        //    (`DaDuyet` chốt giá + `TuChoi`). `TraLai` KHÔNG loại — phiếu trả lại sẽ được
        //    trình LẠI và chạy đúng quy trình vừa sửa ⇒ người soạn cần thấy vết.
        // 🔴 CỐ Ý KHÔNG `IgnoreQueryFilters()`: `ContractSigningPlans` có global filter
        //    (`ContractSigningPlanConfiguration.cs:55`) ⇒ phiếu XOÁ MỀM tự rụng khỏi tập.
        //    Đúng ý muốn: ghi changelog cho phiếu đã xoá là đẻ rác không ai đọc. (Ngược với
        //    rào DELETE bên dưới — ở đó xoá mềm VẪN phải chặn vì FK còn thật.)
        else if (changes.Count > 0 &&
                 def.ApplicableType is ApprovalWorkflowApplicableType.ContractSigningPlan)
        {
            var affectedPlans = await db.ContractSigningPlans.AsNoTracking()
                .Where(p => p.ApprovalWorkflowId == def.Id
                         && p.Phase != ContractSigningPlanPhase.DaDuyet
                         && p.Phase != ContractSigningPlanPhase.TuChoi)
                .Select(p => new { p.Id, p.Phase })
                .ToListAsync(ct);

            foreach (var plan in affectedPlans)
            {
                db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
                {
                    ContractSigningPlanId = plan.Id,
                    EntityType = ContractSigningPlanEntityType.Workflow,
                    Action = SolutionErp.Domain.Contracts.ChangelogAction.Update,
                    EntityId = def.Id,
                    PhaseAtChange = plan.Phase,
                    UserId = currentUser.UserId,
                    UserName = actorName,
                    Summary = summary,
                    ContextNote = note,
                });
            }
        }

        await db.SaveChangesAsync(ct);
        // 🔴 F1 — CHỐT rào: tới đây purge + gỡ dòng Cấp + changelog mới cùng nhau có hiệu lực.
        // Ném ở BẤT KỲ đâu phía trên ⇒ dispose của `tx` rollback TRỌN, kể cả các câu DELETE
        // của `PurgeOrphanOpinionsAsync` (ExecuteDelete tự enlist vào transaction đang mở).
        await tx.CommitAsync(ct);
    }

    // Đếm chữ ký CÒN HIỆU LỰC trên các dòng Cấp — quét CẢ 8 bảng LevelOpinion đang
    // FK vào ApprovalWorkflowLevels (PE + HĐ + 5 module Văn phòng số + KHKK W1). Chỉ soi PE là
    // vẫn nổ FK 547 khi quy trình thuộc module khác (§F.3 blast radius ≥6 module).
    // 🔴 `!p.IsDeleted` viết TƯỜNG MINH: chỉ PE + Contract có global query filter,
    // 5 bảng Office KHÔNG có ⇒ dựa filter là đúng PE mà sai 5 module kia.
    private async Task<Dictionary<Guid, int>> CountLiveOpinionsAsync(List<Guid> levelIds, CancellationToken ct)
    {
        var acc = levelIds.ToDictionary(id => id, _ => 0);
        void Merge(List<LevelOpinionCount> rows)
        {
            foreach (var r in rows) acc[r.LevelId] = acc.GetValueOrDefault(r.LevelId) + r.Count;
        }

        Merge(await db.PurchaseEvaluationLevelOpinions.AsNoTracking()
            .Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId) && !o.IsDeleted
                     && db.PurchaseEvaluations.Any(p => p.Id == o.PurchaseEvaluationId && !p.IsDeleted))
            .GroupBy(o => o.ApprovalWorkflowLevelId)
            .Select(g => new LevelOpinionCount(g.Key, g.Count())).ToListAsync(ct));

        Merge(await db.ContractLevelOpinions.AsNoTracking()
            .Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId) && !o.IsDeleted
                     && db.Contracts.Any(p => p.Id == o.ContractId && !p.IsDeleted))
            .GroupBy(o => o.ApprovalWorkflowLevelId)
            .Select(g => new LevelOpinionCount(g.Key, g.Count())).ToListAsync(ct));

        Merge(await db.ProposalLevelOpinions.AsNoTracking()
            .Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId) && !o.IsDeleted
                     && db.Proposals.Any(p => p.Id == o.ProposalId && !p.IsDeleted))
            .GroupBy(o => o.ApprovalWorkflowLevelId)
            .Select(g => new LevelOpinionCount(g.Key, g.Count())).ToListAsync(ct));

        Merge(await db.LeaveRequestLevelOpinions.AsNoTracking()
            .Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId) && !o.IsDeleted
                     && db.LeaveRequests.Any(p => p.Id == o.LeaveRequestId && !p.IsDeleted))
            .GroupBy(o => o.ApprovalWorkflowLevelId)
            .Select(g => new LevelOpinionCount(g.Key, g.Count())).ToListAsync(ct));

        Merge(await db.OtRequestLevelOpinions.AsNoTracking()
            .Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId) && !o.IsDeleted
                     && db.OtRequests.Any(p => p.Id == o.OtRequestId && !p.IsDeleted))
            .GroupBy(o => o.ApprovalWorkflowLevelId)
            .Select(g => new LevelOpinionCount(g.Key, g.Count())).ToListAsync(ct));

        Merge(await db.TravelRequestLevelOpinions.AsNoTracking()
            .Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId) && !o.IsDeleted
                     && db.TravelRequests.Any(p => p.Id == o.TravelRequestId && !p.IsDeleted))
            .GroupBy(o => o.ApprovalWorkflowLevelId)
            .Select(g => new LevelOpinionCount(g.Key, g.Count())).ToListAsync(ct));

        Merge(await db.VehicleBookingLevelOpinions.AsNoTracking()
            .Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId) && !o.IsDeleted
                     && db.VehicleBookings.Any(p => p.Id == o.VehicleBookingId && !p.IsDeleted))
            .GroupBy(o => o.ApprovalWorkflowLevelId)
            .Select(g => new LevelOpinionCount(g.Key, g.Count())).ToListAsync(ct));

        // [W1 Mig 69 — S161] GĐ2 Kế hoạch ký kết HĐ (type=10). Site liệt-kê-thủ-công:
        // module mới có LevelOpinions PHẢI vào CẢ 3 danh sách trong file này
        // (count-sống · retained-ref · orphan-delete) — thiếu 1 = admin xoá Level nổ FK 500.
        // Tự kiểm: grep -n "LevelOpinions.AsNoTracking\|LevelOpinions$" file này.
        Merge(await db.ContractSigningPlanLevelOpinions.AsNoTracking()
            .Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId) && !o.IsDeleted
                     && db.ContractSigningPlans.Any(p => p.Id == o.ContractSigningPlanId && !p.IsDeleted))
            .GroupBy(o => o.ApprovalWorkflowLevelId)
            .Select(g => new LevelOpinionCount(g.Key, g.Count())).ToListAsync(ct));

        return acc;
    }

    // Chữ ký CÒN LƯU trên các dòng Cấp nhưng KHÔNG thuộc diện "sống" của
    // `CountLiveOpinionsAsync` — tức hồ sơ cha VẪN CÒN ROW, mà (cha xoá mềm HOẶC ý kiến
    // tự nó xoá mềm). Đây chính là tập mà bản trước đem đi xoá CỨNG (F2 của reviewer).
    //
    // 🔴 `IgnoreQueryFilters()` LÀ BẮT BUỘC, không phải trang trí: PE và Contract có global
    // filter `!IsDeleted` (`PurchaseEvaluationConfiguration.cs:84`). Không tắt filter thì hồ
    // sơ xoá mềm biến mất khỏi phép JOIN ⇒ ta lại kết luận "mồ côi" ⇒ đúng cái sai đang chữa.
    // (5 bảng Văn phòng số + KHKK-LevelOpinions KHÔNG có filter — gọi vẫn vô hại, giữ 8 khối cùng một hình dạng.)
    private async Task<Dictionary<Guid, List<RetainedOpinionRef>>> ListRetainedOpinionsAsync(
        List<Guid> levelIds, CancellationToken ct)
    {
        var rows = new List<RetainedOpinionRef>();

        rows.AddRange(await db.PurchaseEvaluationLevelOpinions.AsNoTracking().IgnoreQueryFilters()
            .Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId))
            .Join(db.PurchaseEvaluations, o => o.PurchaseEvaluationId, p => p.Id, (o, p) => new { o, p })
            .Where(x => x.o.IsDeleted || x.p.IsDeleted)
            .Select(x => new RetainedOpinionRef(x.o.ApprovalWorkflowLevelId, "Phiếu duyệt NCC", x.p.MaPhieu, x.p.IsDeleted))
            .ToListAsync(ct));

        rows.AddRange(await db.ContractLevelOpinions.AsNoTracking().IgnoreQueryFilters()
            .Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId))
            .Join(db.Contracts, o => o.ContractId, p => p.Id, (o, p) => new { o, p })
            .Where(x => x.o.IsDeleted || x.p.IsDeleted)
            .Select(x => new RetainedOpinionRef(x.o.ApprovalWorkflowLevelId, "Hợp đồng", x.p.MaHopDong, x.p.IsDeleted))
            .ToListAsync(ct));

        rows.AddRange(await db.ProposalLevelOpinions.AsNoTracking().IgnoreQueryFilters()
            .Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId))
            .Join(db.Proposals, o => o.ProposalId, p => p.Id, (o, p) => new { o, p })
            .Where(x => x.o.IsDeleted || x.p.IsDeleted)
            .Select(x => new RetainedOpinionRef(x.o.ApprovalWorkflowLevelId, "Đề xuất", x.p.MaDeXuat, x.p.IsDeleted))
            .ToListAsync(ct));

        rows.AddRange(await db.LeaveRequestLevelOpinions.AsNoTracking().IgnoreQueryFilters()
            .Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId))
            .Join(db.LeaveRequests, o => o.LeaveRequestId, p => p.Id, (o, p) => new { o, p })
            .Where(x => x.o.IsDeleted || x.p.IsDeleted)
            .Select(x => new RetainedOpinionRef(x.o.ApprovalWorkflowLevelId, "Đơn nghỉ phép", x.p.MaDonTu, x.p.IsDeleted))
            .ToListAsync(ct));

        rows.AddRange(await db.OtRequestLevelOpinions.AsNoTracking().IgnoreQueryFilters()
            .Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId))
            .Join(db.OtRequests, o => o.OtRequestId, p => p.Id, (o, p) => new { o, p })
            .Where(x => x.o.IsDeleted || x.p.IsDeleted)
            .Select(x => new RetainedOpinionRef(x.o.ApprovalWorkflowLevelId, "Đơn tăng ca", x.p.MaDonTu, x.p.IsDeleted))
            .ToListAsync(ct));

        rows.AddRange(await db.TravelRequestLevelOpinions.AsNoTracking().IgnoreQueryFilters()
            .Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId))
            .Join(db.TravelRequests, o => o.TravelRequestId, p => p.Id, (o, p) => new { o, p })
            .Where(x => x.o.IsDeleted || x.p.IsDeleted)
            .Select(x => new RetainedOpinionRef(x.o.ApprovalWorkflowLevelId, "Đơn công tác", x.p.MaDonTu, x.p.IsDeleted))
            .ToListAsync(ct));

        rows.AddRange(await db.VehicleBookingLevelOpinions.AsNoTracking().IgnoreQueryFilters()
            .Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId))
            .Join(db.VehicleBookings, o => o.VehicleBookingId, p => p.Id, (o, p) => new { o, p })
            .Where(x => x.o.IsDeleted || x.p.IsDeleted)
            .Select(x => new RetainedOpinionRef(x.o.ApprovalWorkflowLevelId, "Đơn xe", x.p.MaDonTu, x.p.IsDeleted))
            .ToListAsync(ct));

        // [W1 Mig 69 — S161] GĐ2 Kế hoạch ký kết HĐ — mirror nhánh trên (site 2/3).
        rows.AddRange(await db.ContractSigningPlanLevelOpinions.AsNoTracking().IgnoreQueryFilters()
            .Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId))
            .Join(db.ContractSigningPlans, o => o.ContractSigningPlanId, p => p.Id, (o, p) => new { o, p })
            .Where(x => x.o.IsDeleted || x.p.IsDeleted)
            .Select(x => new RetainedOpinionRef(x.o.ApprovalWorkflowLevelId, "Kế hoạch ký kết HĐ", x.p.MaKeHoach, x.p.IsDeleted))
            .ToListAsync(ct));

        return rows.GroupBy(r => r.LevelId).ToDictionary(g => g.Key, g => g.ToList());
    }

    private static string DescribeRetained(List<RetainedOpinionRef> rows)
    {
        var head = string.Join("; ", rows.Take(3).Select(r =>
            $"{r.Module} {(string.IsNullOrWhiteSpace(r.Code) ? "(chưa có mã)" : r.Code)}" +
            (r.ParentDeleted ? " — đã xoá" : " — ý kiến đã thu hồi")));
        return rows.Count > 3 ? $"{head}; … và {rows.Count - 3} hồ sơ khác" : head;
    }

    // Xoá CỨNG những chữ ký THẬT SỰ MỒ CÔI — hồ sơ cha KHÔNG CÒN ROW trong DB (bị xoá cứng
    // ngoài luồng ứng dụng: sửa tay, nhập liệu, hoặc lúc FK từng bị tắt). ExecuteDelete đi
    // thẳng SQL, KHÔNG qua change-tracker nên không bị AuditingInterceptor đổi thành xoá mềm.
    //
    // 🔴 F2 (reviewer đợt 1) — ĐÃ THU HẸP hai vế, đây là chỗ dễ mất dữ liệu nhất của lệnh:
    //   • BỎ vế `o.IsDeleted` — opinion tự nó xoá mềm trên hồ sơ CÒN SỐNG là bản ghi KIỂM
    //     TOÁN của tài liệu đang hiệu lực, không phải rác.
    //   • `!Any(p => p.Id == … && !p.IsDeleted)` → `!IgnoreQueryFilters().Any(p => p.Id == …)`
    //     — hồ sơ XOÁ MỀM vẫn CÒN ROW, vẫn khôi phục và xem được ⇒ chữ ký của nó KHÔNG mồ côi.
    //   Hai ca đó nay bị guard tầng 2 (`ListRetainedOpinionsAsync`) CHẶN admin bằng 409, kèm
    //   tên hồ sơ đang giữ chữ ký — thay vì xoá lén rồi chỉ để lại một con số đếm.
    //
    // ⇒ BẤT BIẾN sau bản vá: tới được đây nghĩa là mọi dòng còn trỏ vào `levelIds` đều có cha
    //   đã biến mất khỏi DB. Xoá chúng là đường DUY NHẤT để FK Restrict cho gỡ dòng Cấp, và
    //   không màn hình nào còn đọc được chúng nữa. Điều kiện dưới đây vẫn viết TƯỜNG MINH
    //   (không dựa vào việc guard đã chạy) để purge tự phòng vệ nếu hai bên có ngày lệch nhau.
    private async Task<int> PurgeOrphanOpinionsAsync(List<Guid> levelIds, CancellationToken ct)
    {
        var n = 0;
        n += await db.PurchaseEvaluationLevelOpinions
            .Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId)
                     && !db.PurchaseEvaluations.IgnoreQueryFilters().Any(p => p.Id == o.PurchaseEvaluationId))
            .ExecuteDeleteAsync(ct);
        n += await db.ContractLevelOpinions
            .Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId)
                     && !db.Contracts.IgnoreQueryFilters().Any(p => p.Id == o.ContractId))
            .ExecuteDeleteAsync(ct);
        n += await db.ProposalLevelOpinions
            .Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId)
                     && !db.Proposals.IgnoreQueryFilters().Any(p => p.Id == o.ProposalId))
            .ExecuteDeleteAsync(ct);
        n += await db.LeaveRequestLevelOpinions
            .Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId)
                     && !db.LeaveRequests.IgnoreQueryFilters().Any(p => p.Id == o.LeaveRequestId))
            .ExecuteDeleteAsync(ct);
        n += await db.OtRequestLevelOpinions
            .Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId)
                     && !db.OtRequests.IgnoreQueryFilters().Any(p => p.Id == o.OtRequestId))
            .ExecuteDeleteAsync(ct);
        n += await db.TravelRequestLevelOpinions
            .Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId)
                     && !db.TravelRequests.IgnoreQueryFilters().Any(p => p.Id == o.TravelRequestId))
            .ExecuteDeleteAsync(ct);
        n += await db.VehicleBookingLevelOpinions
            .Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId)
                     && !db.VehicleBookings.IgnoreQueryFilters().Any(p => p.Id == o.VehicleBookingId))
            .ExecuteDeleteAsync(ct);
        // [W1 Mig 69 — S161] GĐ2 Kế hoạch ký kết HĐ — mirror nhánh trên (site 3/3).
        n += await db.ContractSigningPlanLevelOpinions
            .Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId)
                     && !db.ContractSigningPlans.IgnoreQueryFilters().Any(p => p.Id == o.ContractSigningPlanId))
            .ExecuteDeleteAsync(ct);
        return n;
    }

    // Gỡ theo dõi (detach) mọi entity chữ ký đang nằm trong change-tracker và trỏ vào các
    // dòng Cấp sắp bị XOÁ CỨNG.
    //
    // VÌ SAO CẦN: `PurgeOrphanOpinionsAsync` dùng `ExecuteDelete` — đi thẳng SQL, KHÔNG cập
    // nhật change-tracker (giới hạn có ghi trong tài liệu EF). Nếu context đang giữ một bản
    // sao của chính những chữ ký vừa bị xoá (hoặc bất kỳ chữ ký nào trỏ vào dòng Cấp sắp gỡ),
    // thì `db.ApprovalWorkflowLevels.Remove(level)` sẽ kích cascade-check CLIENT-SIDE
    // (CascadeDeleteTiming.Immediate = mặc định): EF thấy dependent còn tracked + FK
    // `Restrict` + cột không nullable ⇒ ném InvalidOperationException "association ... has
    // been severed" NGAY TẠI `Remove()`, chưa kịp chạm DB ⇒ 500 thay vì thao tác hợp lệ.
    //
    // AN TOÀN: tới thời điểm gọi, guard F-5 đã đọc DB (AsNoTracking) và xác nhận các dòng
    // này có 0 chữ ký CÒN HIỆU LỰC ⇒ mọi bản sao còn tracked đều là rác (thuộc phiếu đã xoá,
    // hoặc chính nó đã bị purge khỏi DB) ⇒ gỡ theo dõi là làm tracker khớp lại với DB.
    // Quét theo TÊN CỘT nên phủ đủ cả 7 bảng chữ ký (PE + HĐ + 5 module Văn phòng số) mà
    // không phải liệt kê tay từng DbSet.
    private static void DetachTrackedOpinionsOn(IApplicationDbContext db, HashSet<Guid> levelIds)
    {
        if (levelIds.Count == 0) return;
        const string fkName = nameof(PurchaseEvaluationLevelOpinion.ApprovalWorkflowLevelId);

        var stale = ((DbContext)db).ChangeTracker.Entries()
            .Where(e => e.State != EntityState.Detached
                     && e.Metadata.FindProperty(fkName) is not null
                     && e.Property(fkName).CurrentValue is Guid levelId
                     && levelIds.Contains(levelId))
            .ToList();

        foreach (var entry in stale)
            entry.State = EntityState.Detached;
    }

    private async Task<Dictionary<Guid, string>> ResolveUserNamesAsync(List<Guid> userIds, CancellationToken ct)
    {
        if (userIds.Count == 0) return new Dictionary<Guid, string>();
        return await userManager.Users.AsNoTracking()
            .Where(u => userIds.Contains(u.Id))
            .ToDictionaryAsync(u => u.Id, u => u.FullName ?? u.Email ?? u.Id.ToString(), ct);
    }

    private static List<string> DescribeFlagChanges(ApprovalWorkflowLevel db, UpdateAwLevelInput req)
    {
        var list = new List<string>();
        void Cmp(string label, bool before, bool after)
        {
            if (before != after) list.Add($"{label} {(after ? "BẬT" : "TẮT")}");
        }
        Cmp("Trả lại 1 Cấp", db.AllowReturnOneLevel, req.AllowReturnOneLevel);
        Cmp("Trả lại 1 Bước", db.AllowReturnOneStep, req.AllowReturnOneStep);
        Cmp("Trả lại NV đã ký", db.AllowReturnToAssignee, req.AllowReturnToAssignee);
        Cmp("Trả lại người soạn", db.AllowReturnToDrafter, req.AllowReturnToDrafter);
        Cmp("Sửa Hạng mục/NCC/Báo giá", db.AllowApproverEditDetails, req.AllowApproverEditDetails);
        Cmp("Sửa ngân sách", db.AllowApproverEditBudget, req.AllowApproverEditBudget);
        Cmp("Xoá phiếu khi đang duyệt", db.AllowApproverDelete, req.AllowApproverDelete);
        return list;
    }

    private static string FormatMoney(decimal? v) => v is decimal d ? d.ToString("#,##0.##") : "(không đặt)";
}

// ========== PATCH user-selectable toggle (Mig 25 — admin pin/unpin cho user pick) ==========
// Independent với IsActive: cho phép multiple version cùng selectable. Default
// version mới IsUserSelectable=true (mirror IsActive default), admin có thể
// unstick để dấu khỏi Workspace dropdown, hoặc stick lại version cũ archived.

public record SetAwUserSelectableCommand(Guid Id, bool IsUserSelectable) : IRequest;

public class SetAwUserSelectableCommandHandler(IApplicationDbContext db)
    : IRequestHandler<SetAwUserSelectableCommand>
{
    public async Task Handle(SetAwUserSelectableCommand request, CancellationToken ct)
    {
        var def = await db.ApprovalWorkflows
            .FirstOrDefaultAsync(d => d.Id == request.Id, ct)
            ?? throw new KeyNotFoundException($"ApprovalWorkflow {request.Id} không tồn tại.");

        def.IsUserSelectable = request.IsUserSelectable;
        await db.SaveChangesAsync(ct);
    }
}

// ========== DELETE version — CHẶN khi còn phiếu pin / còn chữ ký ==========
//
// 🔴 [S182 W1d] Câu cũ ở đây ("chưa có phiếu nào pin schema mới → unconditional delete OK cho
//    UAT") đã HẾT ĐÚNG từ lâu: 8 module pin `ApprovalWorkflowId` thật trên prod. Xoá trần để
//    lại 2 hậu quả khác hẳn nhau tuỳ module — và chính vì khác nhau nên rào này có HAI lý do,
//    không phải một:
//      • PE / Hợp đồng / KHKK có **FK vật lý** ⇒ xoá trần nổ SQL 547 = 500 thô. Rào = đổi
//        500 khó hiểu thành 409 đọc được. (UX)
//      • Proposal / Leave / Ot / Travel / Vehicle pin **LOOSE, 0-FK** ⇒ DB không đỡ gì cả,
//        xoá trần đi lọt ÊM và để lại con trỏ treo. Rào này là **hàng phòng thủ DUY NHẤT**.
//        (TOÀN VẸN DỮ LIỆU — nặng hơn vế trên)
//
// Thứ tự kiểm: usage TRƯỚC (i+ii), D5 SAU (iv). Cả hai đều 409; usage đứng trước vì khi cả hai
// cùng đúng thì "5 phiếu đang gắn" là câu admin xử lý được, còn "nhóm phải còn ≥1 version" là
// luật chính sách — nói cái hành-động-được trước.

public record DeleteAwDefinitionCommand(Guid Id) : IRequest;

public class DeleteAwDefinitionCommandHandler(IApplicationDbContext db)
    : IRequestHandler<DeleteAwDefinitionCommand>
{
    public async Task Handle(DeleteAwDefinitionCommand request, CancellationToken ct)
    {
        // (iii) `NotFoundException` thay `KeyNotFoundException`: BCL exception KHÔNG được
        //       `GlobalExceptionMiddleware` map ⇒ id lạ trước nay ra 500 thô. Nay 404.
        var def = await db.ApprovalWorkflows
            .FirstOrDefaultAsync(d => d.Id == request.Id, ct)
            ?? throw new NotFoundException("ApprovalWorkflow", request.Id);

        // ---- (i) Đếm phiếu ĐANG PIN, quét đủ 8 module có cột `ApprovalWorkflowId`.
        //
        // 🔴 `IgnoreQueryFilters()` cho PE / Contract / KHKK — 3 entity DUY NHẤT có global
        //    filter `!IsDeleted` (đã grep `HasQueryFilter` trong Configurations để chốt danh
        //    sách, không suy từ trí nhớ). Phiếu XOÁ MỀM vẫn CÒN ROW ⇒ FK vẫn thật ⇒ bỏ filter
        //    thì ta đếm ra 0 rồi xoá và nổ 547 đúng lúc chạy. 5 module Văn phòng số không có
        //    filter nên đếm thẳng.
        var pinned = 0;
        pinned += await db.PurchaseEvaluations.IgnoreQueryFilters()
            .CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
        pinned += await db.Contracts.IgnoreQueryFilters()
            .CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
        pinned += await db.ContractSigningPlans.IgnoreQueryFilters()
            .CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
        pinned += await db.Proposals.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
        pinned += await db.LeaveRequests.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
        pinned += await db.OtRequests.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
        pinned += await db.TravelRequests.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
        pinned += await db.VehicleBookings.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);

        // ---- (ii) Đếm CHỮ KÝ còn trỏ vào các dòng Cấp của quy trình này — 8 bảng
        //      `*LevelOpinion`. Phiếu có thể đã bỏ pin mà chữ ký vẫn còn (FK Restrict về
        //      `ApprovalWorkflowLevels`) ⇒ đếm pin thôi là chưa đủ để dám xoá.
        //      `IgnoreQueryFilters()` cả 8 cho ĐỒNG HÌNH — bảng nào không có filter thì gọi
        //      vô hại, bảng có filter mà quên gọi thì đếm hụt.
        var levelIds = await db.ApprovalWorkflowLevels.AsNoTracking().IgnoreQueryFilters()
            .Where(l => l.Step!.ApprovalWorkflowId == def.Id)
            .Select(l => l.Id)
            .ToListAsync(ct);

        var signatures = 0;
        if (levelIds.Count > 0)
        {
            signatures += await db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters()
                .CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
            signatures += await db.ContractLevelOpinions.IgnoreQueryFilters()
                .CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
            signatures += await db.ContractSigningPlanLevelOpinions.IgnoreQueryFilters()
                .CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
            signatures += await db.ProposalLevelOpinions.IgnoreQueryFilters()
                .CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
            signatures += await db.LeaveRequestLevelOpinions.IgnoreQueryFilters()
                .CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
            signatures += await db.OtRequestLevelOpinions.IgnoreQueryFilters()
                .CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
            signatures += await db.TravelRequestLevelOpinions.IgnoreQueryFilters()
                .CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
            signatures += await db.VehicleBookingLevelOpinions.IgnoreQueryFilters()
                .CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
        }

        if (pinned > 0 || signatures > 0)
            throw new ConflictException(
                $"Quy trình '{def.Code}' v{def.Version} đang được dùng: {pinned} hồ sơ đang gắn " +
                $"+ {signatures} chữ ký đã lưu — không xoá được. " +
                "Muốn ngừng dùng thì bỏ kích hoạt (hoặc bỏ ghim) thay vì xoá.");

        // ---- (iv) [D5] Nhóm duyệt KHKK phải LUÔN còn ít nhất 1 version.
        //
        // Seeder `SeedKhkkGroupWorkflowsAsync` idempotent PER-CODE (`DbInitializer.cs:610`:
        // `if (Any(w.Code == code)) continue;`) ⇒ xoá SẠCH một mã `KHKK-N{n}` thì lần khởi
        // động sau seeder đẻ LẠI bản v01 mặc định — roster + cờ mà admin đã chỉnh biến mất
        // âm thầm, không có lỗi nào báo. Rào ở đây đóng đường UI dẫn tới ca đó.
        // (Version 0-usage mà KHÔNG phải bản cuối của mã ⇒ vẫn xoá bình thường.)
        if (ContractSigningPlanWorkflowGuards.ParseGroup(def.Code) is int group)
        {
            // [S182 MINOR-3] Lọc CẢ `ApplicableType`: UNIQUE là `(Code, Version)` chứ không kèm
            // type ⇒ một row `KHKK-N{n}` ở type KHÁC (Version khác) sẽ bị tính là "anh em" và
            // cho phép xoá bản type-10 CUỐI CÙNG — đúng ca mà D5 sinh ra để chặn.
            var siblings = await db.ApprovalWorkflows
                .CountAsync(w => w.Code == def.Code
                              && w.ApplicableType == def.ApplicableType
                              && w.Id != def.Id, ct);
            if (siblings == 0)
                throw new ConflictException(
                    $"Nhóm N{group} phải còn ≥1 version — đây là version cuối cùng của quy trình " +
                    $"'{def.Code}'. Tạo version mới trước, rồi mới xoá bản này.");
        }

        db.ApprovalWorkflows.Remove(def);
        await db.SaveChangesAsync(ct);
    }
}
