using FluentValidation;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Common.Models;
using SolutionErp.Application.PurchaseEvaluations.Dtos;
using SolutionErp.Application.PurchaseEvaluations.Services;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.PurchaseEvaluations;

namespace SolutionErp.Application.PurchaseEvaluations;

// ========== CREATE ==========

public record CreatePurchaseEvaluationCommand(
    PurchaseEvaluationType Type,
    string TenGoiThau,
    Guid ProjectId,
    Guid? DepartmentId,
    string? DiaDiem,
    string? MoTa,
    string? PaymentTerms,
    decimal? BudgetPeriodAmount,                           // [S61 Mig 50] "Ngân sách - kỳ này" — drafter nhập, optional lúc tạo (guard lúc submit)
    Guid? ApprovalWorkflowId = null,                       // [Mig 23] User chọn quy trình duyệt V2 lúc tạo
    Guid? WorkItemId = null,                               // [Mig 49 S57bis] Hạng mục công việc — flow create PHẢI chọn (validator NotEmpty)
    string? HoSoLink = null) : IRequest<Guid>;             // [HoSoLink] hyperlink thư mục hồ sơ NAS — optional, max 1000

public class CreatePurchaseEvaluationCommandValidator : AbstractValidator<CreatePurchaseEvaluationCommand>
{
    public CreatePurchaseEvaluationCommandValidator()
    {
        RuleFor(x => x.Type).IsInEnum();
        RuleFor(x => x.TenGoiThau).NotEmpty().MaximumLength(500);
        RuleFor(x => x.ProjectId).NotEmpty();
        // [Mig 49 S57bis] Sếp yêu cầu flow create PHẢI chọn hạng mục công việc.
        // DB cột nullable chỉ để backward-compat 4 phiếu cũ — create mới bắt buộc.
        // FK-exists check trong handler → ConflictException (validator sync, không
        // async-db; mirror S43 CreateLeaveRequestHandler FK-invariant guard).
        RuleFor(x => x.WorkItemId).NotEmpty()
            .WithMessage("Phải chọn hạng mục công việc.");
        // [S83 bug fix] BE enforce quy trình duyệt V2 — đóng lỗ hổng validate
        // FE-only (FE canSubmit bắt buộc nhưng BE chỉ validate-nếu-có-truyền →
        // phiếu null-workflow lọt vào ChoDuyet = kẹt V1-legacy "quy trình cũ").
        // NotEmpty chặn cả null lẫn Guid.Empty.
        RuleFor(x => x.ApprovalWorkflowId).NotEmpty()
            .WithMessage("Phải chọn quy trình duyệt.");
        RuleFor(x => x.DiaDiem).MaximumLength(500);
        RuleFor(x => x.MoTa).MaximumLength(2000);
        // [HoSoLink] MaxLength MATCH EF config HasMaxLength(1000) (S35 lesson).
        RuleFor(x => x.HoSoLink).MaximumLength(1000);
        // [S61] >0 khi có nhập — KHÔNG bắt buộc lúc tạo, submit guard mới chặn.
        RuleFor(x => x.BudgetPeriodAmount).GreaterThanOrEqualTo(0)   // [Mig 58 item 2] 0 hợp lệ
            .When(x => x.BudgetPeriodAmount.HasValue)
            .WithMessage("Ngân sách kỳ này phải lớn hơn 0.");
    }
}

public class CreatePurchaseEvaluationCommandHandler(
    IApplicationDbContext db,
    ICurrentUser currentUser,
    IPurchaseEvaluationWorkflowService workflow,
    IPurchaseEvaluationCodeGenerator codeGen) : IRequestHandler<CreatePurchaseEvaluationCommand, Guid>
{
    public async Task<Guid> Handle(CreatePurchaseEvaluationCommand request, CancellationToken ct)
    {
        _ = await db.Projects.FirstOrDefaultAsync(p => p.Id == request.ProjectId, ct)
            ?? throw new NotFoundException("Project", request.ProjectId);

        // [Mig 49 S57bis] FK-invariant guard hạng mục công việc (mirror S43
        // CreateLeaveRequestHandler). Validator đã NotEmpty → ở đây WorkItemId
        // chắc chắn có value; check tồn tại + đang hoạt động.
        if (request.WorkItemId is Guid wiId)
        {
            var wiOk = await db.WorkItems.AsNoTracking()
                .AnyAsync(w => w.Id == wiId && w.IsActive, ct);
            if (!wiOk)
                throw new ConflictException("Hạng mục công việc không tồn tại hoặc ngưng hoạt động.");
        }

        var activeWfId = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking()
            .Where(w => w.EvaluationType == request.Type && w.IsActive)
            .Select(w => (Guid?)w.Id)
            .FirstOrDefaultAsync(ct);

        // Validate ApprovalWorkflowId V2 (Mig 23) — User chọn lúc create.
        // Phải tồn tại + ApplicableType khớp với PE.Type.
        if (request.ApprovalWorkflowId is Guid awId)
        {
            var aw = await db.ApprovalWorkflows.AsNoTracking()
                .FirstOrDefaultAsync(w => w.Id == awId, ct)
                ?? throw new NotFoundException("ApprovalWorkflow", awId);
            var expectedType = request.Type switch
            {
                PurchaseEvaluationType.DuyetNcc => Domain.ApprovalWorkflowsV2.ApprovalWorkflowApplicableType.DuyetNcc,
                PurchaseEvaluationType.DuyetNccPhuongAn => Domain.ApprovalWorkflowsV2.ApprovalWorkflowApplicableType.DuyetNccPhuongAn,
                _ => throw new ConflictException($"PurchaseEvaluationType {request.Type} chưa map sang ApprovalWorkflowApplicableType."),
            };
            if (aw.ApplicableType != expectedType)
                throw new ConflictException($"Quy trình {aw.Code} áp dụng cho {aw.ApplicableType}, không khớp với loại phiếu {request.Type}.");
        }

        // [S61 Mig 50] AUTO-CREATE PeWorkItemBudget cho cặp (ProjectId, WorkItemId)
        // nếu chưa có — 1 record/cặp dùng chung mọi phiếu cùng gói thầu. Race-safe:
        // pre-check AnyAsync + UNIQUE filtered index làm defense cuối; 2 phiếu cùng
        // cặp tạo song song → catch DbUpdateException, detach record local + dùng
        // record bên kia thắng race. SaveChanges RIÊNG trước khi add PE — insert
        // record fail không lan sang phiếu.
        if (request.WorkItemId is Guid wiBudget)
        {
            var pairExists = await db.PeWorkItemBudgets
                .AnyAsync(b => b.ProjectId == request.ProjectId && b.WorkItemId == wiBudget && !b.IsDeleted, ct);
            if (!pairExists)
            {
                var pairRec = new PeWorkItemBudget { ProjectId = request.ProjectId, WorkItemId = wiBudget };
                db.PeWorkItemBudgets.Add(pairRec);
                try
                {
                    await db.SaveChangesAsync(ct);
                }
                catch (DbUpdateException)
                {
                    ((DbContext)db).Entry(pairRec).State = EntityState.Detached;
                    // [S61 advise database-agent] CHỈ nuốt khi đúng thua-race (record
                    // đã tồn tại từ request song song — UNIQUE filtered index arbiter).
                    // Lỗi khác (connection/constraint khác) phải nổi lên.
                    var nowExists = await db.PeWorkItemBudgets
                        .AnyAsync(b => b.ProjectId == request.ProjectId && b.WorkItemId == wiBudget && !b.IsDeleted, ct);
                    if (!nowExists) throw;
                }
            }
        }

        var entity = new PurchaseEvaluation
        {
            Type = request.Type,
            Phase = PurchaseEvaluationPhase.DangSoanThao,
            TenGoiThau = request.TenGoiThau,
            ProjectId = request.ProjectId,
            WorkItemId = request.WorkItemId,                         // Mig 49 S57bis
            DepartmentId = request.DepartmentId,
            DiaDiem = request.DiaDiem,
            MoTa = request.MoTa,
            HoSoLink = request.HoSoLink,                            // [HoSoLink] hyperlink thư mục hồ sơ NAS
            DrafterUserId = currentUser.UserId,
            WorkflowDefinitionId = activeWfId,
            ApprovalWorkflowId = request.ApprovalWorkflowId,         // Mig 23 — schema mới V2
            PaymentTerms = request.PaymentTerms,
            BudgetPeriodAmount = request.BudgetPeriodAmount,         // S61 — "Ngân sách - kỳ này"
            SlaDeadline = DateTime.UtcNow.Add(
                workflow.GetPhaseSla(PurchaseEvaluationPhase.DangSoanThao) ?? TimeSpan.FromDays(3)),
        };

        // Atomic MaPhieu sequence — format PE/{YYYY}/{TypeLetter}/{Seq:D3}
        // (mirror IContractCodeGenerator pattern, transaction SERIALIZABLE).
        entity.MaPhieu = await codeGen.GenerateAsync(entity, ct);

        db.PurchaseEvaluations.Add(entity);

        db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
        {
            PurchaseEvaluationId = entity.Id,
            EntityType = PurchaseEvaluationEntityType.Header,
            Action = ChangelogAction.Insert,
            PhaseAtChange = entity.Phase,
            UserId = currentUser.UserId,
            UserName = currentUser.FullName ?? currentUser.Email,
            Summary = $"Tạo phiếu {entity.MaPhieu} — {entity.TenGoiThau}",
        });

        // Auto-seed 1 Hạng mục mặc định lấy tên + giá trị từ gói thầu / ngân sách
        // — user yêu cầu Session 20: hạng mục là đơn vị làm việc chính, NCC expand
        // dưới hạng mục → cần có sẵn 1 row khi vào Detail. Có thể edit lại sau.
        // [S61] nguồn giá trị = "Ngân sách - kỳ này" drafter nhập (Budget link cũ đã bỏ).
        var defaultBudgetValue = request.BudgetPeriodAmount ?? 0m;
        var defaultDetail = new PurchaseEvaluationDetail
        {
            PurchaseEvaluationId = entity.Id,
            GroupCode = "01",
            GroupName = "Hạng mục chính",
            NoiDung = request.TenGoiThau,
            DonViTinh = "gói",
            KhoiLuongNganSach = 1m,
            KhoiLuongThiCong = 1m,
            DonGiaNganSach = defaultBudgetValue,
            ThanhTienNganSach = defaultBudgetValue,
            Order = 1,
        };
        db.PurchaseEvaluationDetails.Add(defaultDetail);

        db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
        {
            PurchaseEvaluationId = entity.Id,
            EntityType = PurchaseEvaluationEntityType.Detail,
            EntityId = defaultDetail.Id,
            Action = ChangelogAction.Insert,
            PhaseAtChange = entity.Phase,
            UserId = currentUser.UserId,
            UserName = currentUser.FullName ?? currentUser.Email,
            Summary = $"Hạng mục mặc định — {defaultDetail.NoiDung}",
        });

        await db.SaveChangesAsync(ct);
        return entity.Id;
    }
}

// ========== UPDATE draft ==========

public record UpdatePurchaseEvaluationDraftCommand(
    Guid Id,
    string TenGoiThau,
    string? DiaDiem,
    string? MoTa,
    string? PaymentTerms,
    decimal? BudgetPeriodAmount = null,               // [S61] null-safe: null = GIỮ giá trị cũ (chống null-hóa bug-class S42)
    decimal? ExpectedRemainingAmount = null,          // [S61] null-safe: null = GIỮ giá trị cũ
    Guid? ApprovalWorkflowId = null,                  // [Mig 23] cho User đổi quy trình khi sửa Nháp
    Guid? WorkItemId = null,                           // [Mig 49 S57bis] cho User đổi hạng mục công việc khi sửa Nháp/Trả lại
    string? HoSoLink = null) : IRequest;              // [HoSoLink] hyperlink thư mục hồ sơ NAS — absolute-set như MoTa/DiaDiem (null = clear link)

public class UpdatePurchaseEvaluationDraftCommandValidator : AbstractValidator<UpdatePurchaseEvaluationDraftCommand>
{
    public UpdatePurchaseEvaluationDraftCommandValidator()
    {
        RuleFor(x => x.TenGoiThau).NotEmpty().MaximumLength(500);
        RuleFor(x => x.DiaDiem).MaximumLength(500);
        RuleFor(x => x.MoTa).MaximumLength(2000);
        // [HoSoLink] MaxLength MATCH EF config HasMaxLength(1000) (S35 lesson).
        RuleFor(x => x.HoSoLink).MaximumLength(1000);
        RuleFor(x => x.BudgetPeriodAmount).GreaterThanOrEqualTo(0)   // [Mig 58 item 2] 0 hợp lệ
            .When(x => x.BudgetPeriodAmount.HasValue)
            .WithMessage("Ngân sách kỳ này phải lớn hơn 0.");
    }
}

public class UpdatePurchaseEvaluationDraftCommandHandler(
    IApplicationDbContext db,
    ICurrentUser currentUser) : IRequestHandler<UpdatePurchaseEvaluationDraftCommand>
{
    public async Task Handle(UpdatePurchaseEvaluationDraftCommand request, CancellationToken ct)
    {
        var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
            ?? throw new NotFoundException("PurchaseEvaluation", request.Id);

        if (entity.Phase != PurchaseEvaluationPhase.DangSoanThao
            && entity.Phase != PurchaseEvaluationPhase.TraLai)
            throw new ConflictException("Chỉ sửa được phiếu khi ở phase Nháp hoặc Trả lại.");

        // Validate ApprovalWorkflowId V2 nếu thay đổi (Mig 23).
        if (request.ApprovalWorkflowId is Guid awId && awId != entity.ApprovalWorkflowId)
        {
            var aw = await db.ApprovalWorkflows.AsNoTracking()
                .FirstOrDefaultAsync(w => w.Id == awId, ct)
                ?? throw new NotFoundException("ApprovalWorkflow", awId);
            var expectedType = entity.Type switch
            {
                PurchaseEvaluationType.DuyetNcc => Domain.ApprovalWorkflowsV2.ApprovalWorkflowApplicableType.DuyetNcc,
                PurchaseEvaluationType.DuyetNccPhuongAn => Domain.ApprovalWorkflowsV2.ApprovalWorkflowApplicableType.DuyetNccPhuongAn,
                _ => throw new ConflictException($"PurchaseEvaluationType {entity.Type} chưa map sang ApprovalWorkflowApplicableType."),
            };
            if (aw.ApplicableType != expectedType)
                throw new ConflictException($"Quy trình {aw.Code} áp dụng cho {aw.ApplicableType}, không khớp với loại phiếu {entity.Type}.");
        }

        // [Mig 49 S57bis] FK-invariant guard hạng mục nếu đổi (mirror S43).
        if (request.WorkItemId is Guid wiId && wiId != entity.WorkItemId)
        {
            var wiOk = await db.WorkItems.AsNoTracking()
                .AnyAsync(w => w.Id == wiId && w.IsActive, ct);
            if (!wiOk)
                throw new ConflictException("Hạng mục công việc không tồn tại hoặc ngưng hoạt động.");
        }

        entity.TenGoiThau = request.TenGoiThau;
        entity.DiaDiem = request.DiaDiem;
        entity.MoTa = request.MoTa;
        entity.PaymentTerms = request.PaymentTerms;
        // [S88 fix bug-class S42/#73] HoSoLink null-safe + clear-via-empty: link sửa ở
        // HoSoLinkRow RIÊNG (form "Sửa header" + InfoTab pencil OMIT field) → absolute-set
        // cũ null-hóa link MỖI lần Sửa header (mất dữ liệu). null = form không gửi → GIỮ;
        // "" = HoSoLinkRow chủ động xoá → clear; có giá trị → set. (FE HoSoLinkRow gửi "" khi xoá.)
        if (request.HoSoLink is not null)
            entity.HoSoLink = string.IsNullOrWhiteSpace(request.HoSoLink) ? null : request.HoSoLink;
        // [S85 anh Kiet ROOT FIX bug-class S42] null-safe: form Sua KHONG gui approvalWorkflowId
        // (tao co WF -> bam Sua = MAT WF -> ket submit "chua chon quy trinh" = root-cause whack-a-mole).
        // Client khong gui field -> GIU WF cu (giong BudgetPeriodAmount duoi); doi WF thi FE phai gui gia tri.
        if (request.ApprovalWorkflowId is not null)
            entity.ApprovalWorkflowId = request.ApprovalWorkflowId;     // Mig 23 — User đổi quy trình
        // [S61] null-safe 2 field ngân sách mới (mirror WorkItemId bên dưới):
        // client không gửi → GIỮ giá trị cũ, KHÔNG null-hóa (bug-class S42).
        if (request.BudgetPeriodAmount is not null)
            entity.BudgetPeriodAmount = request.BudgetPeriodAmount;
        if (request.ExpectedRemainingAmount is not null)
            entity.ExpectedRemainingAmount = request.ExpectedRemainingAmount;
        // Mig 49 S57bis — null-safe: CHỈ đổi hạng mục khi client gửi giá trị.
        // Client cũ / PeDetailTabs inline-edit không gửi field này → GIỮ nguyên
        // (tránh null-hóa mất hạng mục vừa chọn lúc create — bug-class S42 picker).
        if (request.WorkItemId is not null)
            entity.WorkItemId = request.WorkItemId;

        db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
        {
            PurchaseEvaluationId = entity.Id,
            EntityType = PurchaseEvaluationEntityType.Header,
            Action = ChangelogAction.Update,
            PhaseAtChange = entity.Phase,
            UserId = currentUser.UserId,
            UserName = currentUser.FullName ?? currentUser.Email,
            Summary = "Cập nhật thông tin phiếu",
        });

        await db.SaveChangesAsync(ct);
    }
}

// ========== ADJUST BUDGET (S22+4 — Feature 2) ==========
// Section "Điều chỉnh ngân sách" cho phép sửa BudgetId + BudgetManualName +
// BudgetManualAmount khi:
//   - Drafter scope (Phase=DangSoanThao OR TraLai) — actor là Drafter của phiếu
//   - Approver scope (Phase=ChoDuyet) — actor là ApproverUserId của currentLevel
//   - Admin → bypass
//
// Tách endpoint riêng (KHÔNG dùng UpdatePeDraft) vì UpdatePeDraft chỉ accept
// DangSoanThao/TraLai phase + cập nhật cả Section 1 (TenGoiThau, DiaDiem,
// MoTa, PaymentTerms) — Approver KHÔNG nên được edit các field đó khi đang duyệt.
// AdjustBudget chỉ adjust Budget* — narrow scope hợp lý.
// [S61 Mig 50] Body đổi sang 2 field mới (BudgetId/BudgetManual* DROP cùng module
// Budget cũ). ABSOLUTE-SET cả 2 (FE bảng "Tổng hợp ngân sách" gửi cả 2 giá trị
// state mỗi lần Lưu — null = clear; ExpectedRemaining null → FE hiển thị default
// = NS còn lại row 7). Actor guard GIỮ NGUYÊN 3-scope: Drafter (Nháp/Trả lại) ·
// Approver đúng cấp khi ChoDuyet + AllowApproverEditBudget · Admin mọi lúc.
public record AdjustPurchaseEvaluationBudgetCommand(
    Guid Id,
    decimal? BudgetPeriodAmount,
    decimal? ExpectedRemainingAmount) : IRequest;

public class AdjustPurchaseEvaluationBudgetCommandValidator : AbstractValidator<AdjustPurchaseEvaluationBudgetCommand>
{
    public AdjustPurchaseEvaluationBudgetCommandValidator()
    {
        RuleFor(x => x.BudgetPeriodAmount).GreaterThanOrEqualTo(0).When(x => x.BudgetPeriodAmount.HasValue);   // [Mig 58 item 2] 0 hợp lệ
        // [S62] "Giá trị thực hiện dự kiến còn lại" (row 8) CHO PHÉP ÂM. Khi giá trị NCC
        // vượt ngân sách → số dư còn lại tự ra âm; nghiệp vụ (anh Kiệt FDC 2026-06-13)
        // yêu cầu VẪN lưu được, chỉ cảnh báo (FE banner "Vượt ngân sách"). Gỡ ràng buộc
        // GreaterThanOrEqualTo(0) cũ — mirror tiền lệ LeaveBalance (cho phép số dư âm).
        // [Mig 58 item 2 CCM] BudgetPeriodAmount cho phép = 0 (SSG ngân sách 0 hợp lệ); submit
        // guard chỉ chặn khi NULL (chưa nhập gì), KHÔNG chặn 0 cố ý.
    }
}

public class AdjustPurchaseEvaluationBudgetCommandHandler(
    IApplicationDbContext db,
    ICurrentUser currentUser) : IRequestHandler<AdjustPurchaseEvaluationBudgetCommand>
{
    public async Task Handle(AdjustPurchaseEvaluationBudgetCommand request, CancellationToken ct)
    {
        var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
            ?? throw new NotFoundException("PurchaseEvaluation", request.Id);

        // [S133] Phiếu KẾT THÚC (DaDuyet / TuChoi) = ngân sách BẤT BIẾN — chặn CẢ Admin (đặt TRƯỚC
        // toàn khối phân-quyền, áp mọi role). Số NS đã chốt snapshot tại thời điểm duyệt.
        if (entity.Phase is PurchaseEvaluationPhase.DaDuyet or PurchaseEvaluationPhase.TuChoi)
            throw new ConflictException(
                "Phiếu đã kết thúc — ngân sách đã chốt tại thời điểm duyệt, không sửa được nữa.");

        var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
        var isDrafter = currentUser.UserId is Guid uid && entity.DrafterUserId == uid;
        var actorTag = string.Empty;

        if (!isAdmin)
        {
            if (entity.Phase == PurchaseEvaluationPhase.DangSoanThao
                || entity.Phase == PurchaseEvaluationPhase.TraLai)
            {
                // Drafter scope — chỉ Drafter của phiếu được adjust
                if (!isDrafter)
                    throw new ForbiddenException("Chỉ Drafter của phiếu được điều chỉnh ngân sách khi Nháp/Trả lại.");
                actorTag = "[Drafter]";
            }
            else if (entity.Phase == PurchaseEvaluationPhase.ChoDuyet)
            {
                // F4 (Mig 30 — S22+5) — Approver scope chỉ accept khi
                // currentLevel.AllowApproverEditBudget=true (admin Designer tick
                // per slot) AND actor match ApproverUserId.
                if (entity.ApprovalWorkflowId is not Guid awId)
                    throw new ConflictException("Phiếu V1 legacy không hỗ trợ điều chỉnh ngân sách lúc đang duyệt.");
                if (entity.CurrentWorkflowStepIndex is not int csi || entity.CurrentApprovalLevelOrder is not int curLvl)
                    throw new ConflictException("Phiếu chưa init pointer workflow.");
                if (currentUser.UserId is not Guid actorId)
                    throw new ConflictException("Yêu cầu authenticated user.");

                var workflow = await db.ApprovalWorkflows.AsNoTracking()
                    .Include(w => w.Steps).ThenInclude(s => s.Levels)
                    .FirstOrDefaultAsync(w => w.Id == awId, ct)
                    ?? throw new NotFoundException("ApprovalWorkflow", awId);
                var stepsOrdered = workflow.Steps.OrderBy(s => s.Order).ToList();
                if (csi < 0 || csi >= stepsOrdered.Count)
                    throw new ConflictException("Pointer step out of range — schema lỗi.");
                var step = stepsOrdered[csi];
                // Plan O S23 t5 — Per-NV lookup site MUST discriminate ApproverUserId.
                // Schema Mig 29 OR-of-N: N rows cùng Order. Filter actor match
                // ngay trong FirstOrDefault tránh chọn nhầm row đầu DB.
                var level = step.Levels.FirstOrDefault(l =>
                    l.Order == curLvl && l.ApproverUserId == actorId);
                if (level is null)
                    throw new ForbiddenException(
                        $"Chỉ NV phụ trách Bước {step.Order} / Cấp {curLvl} mới được điều chỉnh ngân sách lúc đang duyệt.");
                if (!level.AllowApproverEditBudget)
                    throw new ConflictException(
                        $"Cấp Approver hiện tại (Bước {step.Order} / Cấp {curLvl}) " +
                        "không được cấp quyền chỉnh sửa Section ngân sách. " +
                        "Liên hệ Admin Designer cấp quyền slot.");
                actorTag = $"[Approver Bước {step.Order}/Cấp {curLvl}]";
            }
            else
            {
                throw new ConflictException(
                    $"Phase={entity.Phase} không cho phép điều chỉnh ngân sách. " +
                    "Chỉ cho phép khi Nháp/Trả lại (Drafter) hoặc Đang duyệt (Approver).");
            }
        }
        else
        {
            actorTag = "[Admin]";
        }

        // Capture old + apply (absolute-set cả 2 — xem comment record)
        var oldPeriod = entity.BudgetPeriodAmount;
        var oldExpected = entity.ExpectedRemainingAmount;

        entity.BudgetPeriodAmount = request.BudgetPeriodAmount;
        entity.ExpectedRemainingAmount = request.ExpectedRemainingAmount;

        // Audit changelog with diff (Vietnamese friendly format)
        var parts = new List<string>();
        if (oldPeriod != request.BudgetPeriodAmount)
        {
            var oldAmt = oldPeriod?.ToString("N0") ?? "(trống)";
            var newAmt = request.BudgetPeriodAmount?.ToString("N0") ?? "(trống)";
            parts.Add($"NS kỳ này {oldAmt}đ → {newAmt}đ");
        }
        if (oldExpected != request.ExpectedRemainingAmount)
        {
            var oldAmt = oldExpected?.ToString("N0") ?? "(mặc định = NS còn lại)";
            var newAmt = request.ExpectedRemainingAmount?.ToString("N0") ?? "(mặc định = NS còn lại)";
            parts.Add($"dự kiến còn lại {oldAmt}đ → {newAmt}đ");
        }
        var diffSummary = parts.Count == 0 ? "không đổi" : string.Join(", ", parts);

        // Plan AE S25 — set UserName để FE hiện đúng tên user thay vì "Hệ thống"
        // fallback. ICurrentUser đã có FullName + Email sẵn từ JWT claims.
        db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
        {
            PurchaseEvaluationId = entity.Id,
            EntityType = PurchaseEvaluationEntityType.Header,
            Action = ChangelogAction.Update,
            PhaseAtChange = entity.Phase,
            UserId = currentUser.UserId,
            UserName = currentUser.FullName ?? currentUser.Email,
            Summary = $"Điều chỉnh ngân sách: {diffSummary} {actorTag}",
        });

        await db.SaveChangesAsync(ct);
    }
}

// ========== TRANSITION ==========

public record TransitionPurchaseEvaluationCommand(
    Guid Id,
    PurchaseEvaluationPhase TargetPhase,
    ApprovalDecision Decision,
    string? Comment,
    // Mig 28 (S21 t4) — F1 mode Trả lại (optional, null = default Drafter)
    WorkflowReturnMode? ReturnMode = null,
    Guid? ReturnTargetUserId = null,
    // F2 — Approver skip thẳng Cấp cuối lúc duyệt ChoDuyet (Mig 31 admin opt-in
    // per slot, AllowApproverSkipToFinal). Default false.
    bool SkipToFinal = false,
    // [Mig 54 2026-06-18 — anh Kiệt FDC] ③ CCM tích "Duyệt done miễn CEO" + ① giá CHỐT
    // người duyệt cuối chọn (amount + source ∈ Ncc/ProMin/ProMax/Ccm).
    bool FinalizeByCcmDelegation = false,
    decimal? ApprovedPriceAmount = null,
    string? ApprovedPriceSource = null,
    // [S117 2026-07-14 — anh Kiệt FDC] Max của khoảng khi source="ProMinMax" (duyệt theo Min–Max).
    decimal? ApprovedPriceMaxAmount = null,
    // [S96 2026-07-01 — anh Kiệt FDC] false = người duyệt BỎ tick "Cấp này KẾT THÚC" → vẫn trình CEO.
    bool ApplyLevelFinalize = true) : IRequest;

public class TransitionPurchaseEvaluationCommandValidator : AbstractValidator<TransitionPurchaseEvaluationCommand>
{
    public TransitionPurchaseEvaluationCommandValidator()
    {
        RuleFor(x => x.Id).NotEmpty();
        RuleFor(x => x.TargetPhase).IsInEnum();
        RuleFor(x => x.Decision).IsInEnum();
        RuleFor(x => x.Comment).MaximumLength(1000);
        RuleFor(x => x.ReturnMode!.Value).IsInEnum().When(x => x.ReturnMode.HasValue);
        // Assignee mode → returnTargetUserId required
        RuleFor(x => x.ReturnTargetUserId).NotEmpty()
            .When(x => x.ReturnMode == WorkflowReturnMode.Assignee)
            .WithMessage("ReturnTargetUserId yêu cầu khi mode=Assignee.");
        // [Mig 54] Giá chốt ≥ 0; nguồn ∈ {Ncc,ProMin,ProMax,Ccm}. Quy tắc "bắt-buộc-
        // chọn-khi-duyệt-cuối" enforce ở service (ApplyApprovedPriceOnFinalize — chỉ nó
        // biết nhánh DaDuyet); validator chỉ chặn giá trị rác.
        RuleFor(x => x.ApprovedPriceAmount).GreaterThanOrEqualTo(0)
            .When(x => x.ApprovedPriceAmount.HasValue);
        RuleFor(x => x.ApprovedPriceSource)
            .Must(s => s is "Ncc" or "ProMin" or "ProMax" or "Ccm" or "ProMinMax")
            .When(x => x.ApprovedPriceSource is not null)
            .WithMessage("Nguồn giá chốt phải là Ncc/ProMin/ProMax/Ccm/ProMinMax.");
        // [S117] Max của khoảng ≥ 0; chỉ kèm khi source=ProMinMax (bất-biến Max⟺ProMinMax); Min ≤ Max.
        // Bắt-buộc-khi-duyệt-cuối vẫn enforce ở service (biết nhánh DaDuyet) — validator chặn rác.
        RuleFor(x => x.ApprovedPriceMaxAmount).GreaterThanOrEqualTo(0)
            .When(x => x.ApprovedPriceMaxAmount.HasValue);
        RuleFor(x => x.ApprovedPriceMaxAmount)
            .Null()
            .When(x => x.ApprovedPriceSource != "ProMinMax")
            .WithMessage("Chỉ nguồn 'cả Min–Max' (ProMinMax) mới được kèm giá Max.");
        RuleFor(x => x.ApprovedPriceMaxAmount)
            .Must((cmd, max) => max is null || cmd.ApprovedPriceAmount is null || cmd.ApprovedPriceAmount <= max)
            .WithMessage("Giá Min không được lớn hơn giá Max khi duyệt theo khoảng.");
    }
}

public class TransitionPurchaseEvaluationCommandHandler(
    IApplicationDbContext db,
    ICurrentUser currentUser,
    IPurchaseEvaluationWorkflowService workflow) : IRequestHandler<TransitionPurchaseEvaluationCommand>
{
    public async Task Handle(TransitionPurchaseEvaluationCommand request, CancellationToken ct)
    {
        if (!currentUser.IsAuthenticated || currentUser.UserId is null)
            throw new UnauthorizedException();

        var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
            ?? throw new NotFoundException("PurchaseEvaluation", request.Id);

        await workflow.TransitionAsync(
            entity,
            request.TargetPhase,
            currentUser.UserId,
            currentUser.Roles,
            request.Decision,
            request.Comment,
            request.ReturnMode,
            request.ReturnTargetUserId,
            request.SkipToFinal,
            request.FinalizeByCcmDelegation,
            request.ApprovedPriceAmount,
            request.ApprovedPriceSource,
            request.ApprovedPriceMaxAmount,
            request.ApplyLevelFinalize,
            ct);
    }
}

// ========== LIST ==========

public record ListPurchaseEvaluationsQuery(
    PurchaseEvaluationType? Type = null,
    PurchaseEvaluationPhase? Phase = null,
    Guid? ProjectId = null,
    Guid? ApprovalWorkflowId = null) : PagedRequest, IRequest<PagedResult<PurchaseEvaluationListItemDto>>;

public class ListPurchaseEvaluationsQueryHandler(
    IApplicationDbContext db,
    ICurrentUser currentUser) : IRequestHandler<ListPurchaseEvaluationsQuery, PagedResult<PurchaseEvaluationListItemDto>>
{
    public async Task<PagedResult<PurchaseEvaluationListItemDto>> Handle(
        ListPurchaseEvaluationsQuery request, CancellationToken ct)
    {
        // Plan AG4: JOIN Users + Departments LEFT (cả 2 nullable theo PE entity).
        // Mig 49 S57bis: LEFT join WorkItems (loose-Guid nullable, mirror Project).
        var q = from e in db.PurchaseEvaluations.AsNoTracking()
                join p in db.Projects.AsNoTracking() on e.ProjectId equals p.Id
                join s in db.Suppliers.AsNoTracking() on e.SelectedSupplierId equals s.Id into sj
                from s in sj.DefaultIfEmpty()
                join u in db.Users.AsNoTracking() on e.DrafterUserId equals u.Id into uj
                from u in uj.DefaultIfEmpty()
                join d in db.Departments.AsNoTracking() on e.DepartmentId equals d.Id into dj
                from d in dj.DefaultIfEmpty()
                join wi in db.WorkItems.AsNoTracking() on e.WorkItemId equals wi.Id into wij
                from wi in wij.DefaultIfEmpty()
                select new { e, p, s, u, d, wi };

        // IDOR strict (Plan E S22 — Session 21 +1): non-admin chỉ thấy phiếu khi:
        //   1. là Drafter (mình tạo)
        //   2. role eligible cho phase legacy V1 (eligiblePhases)
        //   3. V2 approver: pin workflow có actor.UserId trong any Step.Level.ApproverUserId
        // Trước đây UAT loose `|| x.e.ApprovalWorkflowId != null` cho mọi
        // authenticated thấy phiếu V2 — đã thắt chặt sau UAT confirm flow ổn.
        if (!currentUser.Roles.Contains(AppRoles.Admin))
        {
            var userId = currentUser.UserId;
            var eligiblePhases = GetEligiblePhases(currentUser.Roles);
            // Pre-compute V2 workflow IDs nơi user là approver trong any Step.Level
            var userApprovalWfIds = userId is null
                ? new List<Guid>()
                : await db.ApprovalWorkflowLevels.AsNoTracking()
                    .Where(l => l.ApproverUserId == userId.Value)
                    .Select(l => l.Step!.ApprovalWorkflowId)
                    .Distinct()
                    .ToListAsync(ct);
            // [S89] Nháp = owner-only (cond1 DrafterUserId). cond2 role: eligiblePhases đã bỏ
            // DangSoanThao. cond3 V2-approver: guard !=DangSoanThao → approver KHÔNG thấy phiếu
            // còn nháp (workflow đã pin lúc tạo nhưng chưa gửi duyệt), chỉ thấy khi đã gửi.
            q = q.Where(x =>
                x.e.DrafterUserId == userId
                || eligiblePhases.Contains(x.e.Phase)
                || (x.e.Phase != PurchaseEvaluationPhase.DangSoanThao
                    && x.e.ApprovalWorkflowId != null
                    && userApprovalWfIds.Contains(x.e.ApprovalWorkflowId.Value)));
        }

        if (request.Type is not null) q = q.Where(x => x.e.Type == request.Type);
        if (request.Phase is not null) q = q.Where(x => x.e.Phase == request.Phase);
        if (request.ProjectId is not null) q = q.Where(x => x.e.ProjectId == request.ProjectId);
        if (request.ApprovalWorkflowId is not null) q = q.Where(x => x.e.ApprovalWorkflowId == request.ApprovalWorkflowId);

        if (!string.IsNullOrWhiteSpace(request.Search))
        {
            var s = request.Search.Trim();
            q = q.Where(x =>
                (x.e.MaPhieu != null && x.e.MaPhieu.Contains(s)) ||
                x.e.TenGoiThau.Contains(s) ||
                x.p.Name.Contains(s) ||
                x.p.Code.Contains(s));
        }

        // S23 t2 UAT: sort theo "phiếu vừa update" — UpdatedAt fallback CreatedAt
        // (phiếu mới Insert UpdatedAt=null → CreatedAt làm proxy). Latest event lên
        // đầu (Tạo / Gửi duyệt / Trả lại / Approve advance pointer — mọi SaveChanges
        // AuditingInterceptor refresh UpdatedAt). SortDesc=true default (mới đầu).
        q = request.SortDesc
            ? q.OrderByDescending(x => x.e.UpdatedAt ?? x.e.CreatedAt)
            : q.OrderBy(x => x.e.UpdatedAt ?? x.e.CreatedAt);

        var total = await q.CountAsync(ct);
        var items = await q
            .Skip((request.Page - 1) * request.PageSize).Take(request.PageSize)
            .Select(x => new PurchaseEvaluationListItemDto(
                x.e.Id, x.e.MaPhieu, x.e.TenGoiThau, x.e.Type, x.e.Phase,
                x.e.ProjectId, x.p.Name, x.p.Code,
                x.e.WorkItemId, x.wi != null ? x.wi.Name : null, x.wi != null ? x.wi.Code : null,
                x.e.SelectedSupplierId, x.s != null ? x.s.Name : null,
                x.e.ContractId, x.e.SlaDeadline, x.e.CreatedAt, x.e.UpdatedAt,
                x.e.DrafterUserId, x.u != null ? x.u.FullName : null,
                x.e.DepartmentId, x.d != null ? x.d.Name : null,
                x.e.BudgetPeriodAmount, x.e.ExpectedRemainingAmount,
                x.e.IsUrgentByPro, x.e.IsUrgentByCcm,
                // [Mig 58 multi-winner] tên đơn vị IsWinner (≥2 = liên-danh) cho card list.
                (from sw in db.PurchaseEvaluationSuppliers
                 where sw.PurchaseEvaluationId == x.e.Id && sw.IsWinner
                 join sup in db.Suppliers on sw.SupplierId equals sup.Id
                 orderby sup.Name
                 select sup.Name).ToList(),
                // [S89 Mig 58] "Duyệt là KẾT THÚC (không trình CEO)" qua 3 subquery riêng
                // (mirror winner-names — EF-safe; record-ctor projection KHÔNG cho biến trung
                // gian nên không gộp tuple). lv.Step!.Name = simple navigation chắc translate.
                // ApprovalWorkflowId==null → subquery rỗng → false/null (V1 tự đúng).
                // [S97] runtime: phiếu DaDuyet đọc EndedByLevelFinalize (finalize sớm THẬT).
                // [S117] phiếu chưa xong POINTER-AWARE: config-subquery lọc thêm cấp finalize
                // ở-hoặc-SAU con-trỏ (rank cấp = Step.Order-1, Order tuần-tự s+1 bởi seed/Designer).
                // Phiếu đã QUA cấp finalize (approver opt-out → lên CEO) → rỗng → false, KHÔNG badge
                // "Kết thúc" sai khi đang chờ CEO. Con-trỏ null (Nháp) → ?? 0/1 = mọi cấp reachable.
                x.e.Phase == PurchaseEvaluationPhase.DaDuyet
                    ? x.e.EndedByLevelFinalize
                    : (from lv in db.ApprovalWorkflowLevels
                       where lv.AllowApproverFinalize && lv.Step!.ApprovalWorkflowId == x.e.ApprovalWorkflowId
                           && (lv.Step!.Order - 1 > (x.e.CurrentWorkflowStepIndex ?? 0)
                               || (lv.Step!.Order - 1 == (x.e.CurrentWorkflowStepIndex ?? 0)
                                   && lv.Order >= (x.e.CurrentApprovalLevelOrder ?? 1)))
                       select 1).Any(),
                (from lv in db.ApprovalWorkflowLevels
                 where lv.AllowApproverFinalize && lv.Step!.ApprovalWorkflowId == x.e.ApprovalWorkflowId
                 orderby lv.Step!.Order, lv.Order
                 select lv.Step!.Name).FirstOrDefault(),
                (from lv in db.ApprovalWorkflowLevels
                 where lv.AllowApproverFinalize && lv.Step!.ApprovalWorkflowId == x.e.ApprovalWorkflowId
                 orderby lv.Step!.Order, lv.Order
                 select lv.Name).FirstOrDefault()))
            .ToListAsync(ct);

        return new PagedResult<PurchaseEvaluationListItemDto>(items, total, request.Page, request.PageSize);
    }

    internal static List<PurchaseEvaluationPhase> GetEligiblePhases(IReadOnlyList<string> userRoles)
    {
        var phases = new HashSet<PurchaseEvaluationPhase>();
        void AddIfAny(string[] required, params PurchaseEvaluationPhase[] toAdd)
        {
            if (userRoles.Any(r => required.Contains(r)))
                foreach (var p in toAdd) phases.Add(p);
        }
        // [S89 anh Kiệt FDC] DangSoanThao KHÔNG còn là role-eligible phase: phiếu NHÁP =
        // RIÊNG TƯ người tạo (thấy qua DrafterUserId ownership + Admin), KHÔNG lộ qua role
        // Drafter/DeptManager. Trước đây map DangSoanThao → mọi Drafter/DeptManager thấy
        // nháp của nhau (rối — màn hình CEO đầy nháp người khác). Approver chỉ thấy phiếu khi
        // ĐÃ gửi duyệt (V2-approver cond cũng guard !=DangSoanThao ở list + detail authz).
        AddIfAny([AppRoles.Procurement], PurchaseEvaluationPhase.ChoPurchasing);
        AddIfAny([AppRoles.ProjectManager], PurchaseEvaluationPhase.ChoDuAn);
        AddIfAny([AppRoles.CostControl], PurchaseEvaluationPhase.ChoCCM);
        AddIfAny([AppRoles.Director, AppRoles.AuthorizedSigner],
            PurchaseEvaluationPhase.ChoCEODuyetPA, PurchaseEvaluationPhase.ChoCEODuyetNCC);
        return phases.ToList();
    }
}

// ========== INBOX ==========

public record GetMyPurchaseEvaluationInboxQuery(
    PurchaseEvaluationType? Type = null,
    Guid? ApprovalWorkflowId = null) : IRequest<List<PurchaseEvaluationListItemDto>>;

public class GetMyPurchaseEvaluationInboxQueryHandler(
    IApplicationDbContext db,
    ICurrentUser currentUser) : IRequestHandler<GetMyPurchaseEvaluationInboxQuery, List<PurchaseEvaluationListItemDto>>
{
    public async Task<List<PurchaseEvaluationListItemDto>> Handle(
        GetMyPurchaseEvaluationInboxQuery request, CancellationToken ct)
    {
        if (!currentUser.IsAuthenticated) throw new UnauthorizedException();

        var userRoles = currentUser.Roles;
        var userId = currentUser.UserId;
        var isAdmin = userRoles.Contains(AppRoles.Admin);
        var eligiblePhases = isAdmin
            ? [
                // [S89] Nháp (DangSoanThao) BỎ khỏi Inbox kể cả Admin: Inbox = "việc chờ duyệt",
                // nháp xem ở danh sách/workspace (anh Kiệt: bỏ nháp khỏi Inbox hẳn).
                PurchaseEvaluationPhase.ChoPurchasing,
                PurchaseEvaluationPhase.ChoDuAn,
                PurchaseEvaluationPhase.ChoCCM,
                PurchaseEvaluationPhase.ChoCEODuyetPA,
                PurchaseEvaluationPhase.ChoCEODuyetNCC,
                PurchaseEvaluationPhase.ChoDuyet,
            ]
            : ListPurchaseEvaluationsQueryHandler.GetEligiblePhases(userRoles);

        // V2-aware (Mig 22-24): tìm phiếu pin V2 + Phase=ChoDuyet + actor là
        // approver Cấp hiện tại (CurrentWorkflowStepIndex + CurrentApprovalLevelOrder).
        var v2InboxIds = isAdmin
            ? new HashSet<Guid>()
            : await ResolveV2InboxIdsAsync(userId, ct);

        // Plan AG4: JOIN Users + Departments LEFT (mirror ListPurchaseEvaluations).
        // Mig 49 S57bis: LEFT join WorkItems (loose-Guid nullable, mirror List).
        var q = from e in db.PurchaseEvaluations.AsNoTracking()
                join p in db.Projects.AsNoTracking() on e.ProjectId equals p.Id
                join s in db.Suppliers.AsNoTracking() on e.SelectedSupplierId equals s.Id into sj
                from s in sj.DefaultIfEmpty()
                join u in db.Users.AsNoTracking() on e.DrafterUserId equals u.Id into uj
                from u in uj.DefaultIfEmpty()
                join d in db.Departments.AsNoTracking() on e.DepartmentId equals d.Id into dj
                from d in dj.DefaultIfEmpty()
                join wi in db.WorkItems.AsNoTracking() on e.WorkItemId equals wi.Id into wij
                from wi in wij.DefaultIfEmpty()
                where eligiblePhases.Contains(e.Phase) || v2InboxIds.Contains(e.Id)
                select new { e, p, s, u, d, wi };

        if (request.Type is not null) q = q.Where(x => x.e.Type == request.Type);
        if (request.ApprovalWorkflowId is not null)
            q = q.Where(x => x.e.ApprovalWorkflowId == request.ApprovalWorkflowId);

        // S23 t2 UAT: Inbox cũng sort theo "vừa update" lên đầu (mirror List sort).
        return await q
            .OrderByDescending(x => x.e.UpdatedAt ?? x.e.CreatedAt)
            .Select(x => new PurchaseEvaluationListItemDto(
                x.e.Id, x.e.MaPhieu, x.e.TenGoiThau, x.e.Type, x.e.Phase,
                x.e.ProjectId, x.p.Name, x.p.Code,
                x.e.WorkItemId, x.wi != null ? x.wi.Name : null, x.wi != null ? x.wi.Code : null,
                x.e.SelectedSupplierId, x.s != null ? x.s.Name : null,
                x.e.ContractId, x.e.SlaDeadline, x.e.CreatedAt, x.e.UpdatedAt,
                x.e.DrafterUserId, x.u != null ? x.u.FullName : null,
                x.e.DepartmentId, x.d != null ? x.d.Name : null,
                x.e.BudgetPeriodAmount, x.e.ExpectedRemainingAmount,
                x.e.IsUrgentByPro, x.e.IsUrgentByCcm,
                // [Mig 58 multi-winner] tên đơn vị IsWinner (≥2 = liên-danh) cho card list.
                (from sw in db.PurchaseEvaluationSuppliers
                 where sw.PurchaseEvaluationId == x.e.Id && sw.IsWinner
                 join sup in db.Suppliers on sw.SupplierId equals sup.Id
                 orderby sup.Name
                 select sup.Name).ToList(),
                // [S89 Mig 58] "Duyệt là KẾT THÚC (không trình CEO)" — 3 subquery riêng mirror
                // winner-names (EF-safe). lv.Step!.Name simple navigation. ApprovalWorkflowId
                // ==null → subquery rỗng → false/null (V1 tự đúng).
                // [S97] runtime: phiếu DaDuyet đọc EndedByLevelFinalize (finalize sớm THẬT).
                // [S117] phiếu chưa xong POINTER-AWARE: config-subquery lọc thêm cấp finalize
                // ở-hoặc-SAU con-trỏ (rank = Step.Order-1). Phiếu đã QUA cấp finalize → lên CEO →
                // rỗng → false, KHÔNG badge "Kết thúc" sai (mirror List :647).
                x.e.Phase == PurchaseEvaluationPhase.DaDuyet
                    ? x.e.EndedByLevelFinalize
                    : (from lv in db.ApprovalWorkflowLevels
                       where lv.AllowApproverFinalize && lv.Step!.ApprovalWorkflowId == x.e.ApprovalWorkflowId
                           && (lv.Step!.Order - 1 > (x.e.CurrentWorkflowStepIndex ?? 0)
                               || (lv.Step!.Order - 1 == (x.e.CurrentWorkflowStepIndex ?? 0)
                                   && lv.Order >= (x.e.CurrentApprovalLevelOrder ?? 1)))
                       select 1).Any(),
                (from lv in db.ApprovalWorkflowLevels
                 where lv.AllowApproverFinalize && lv.Step!.ApprovalWorkflowId == x.e.ApprovalWorkflowId
                 orderby lv.Step!.Order, lv.Order
                 select lv.Step!.Name).FirstOrDefault(),
                (from lv in db.ApprovalWorkflowLevels
                 where lv.AllowApproverFinalize && lv.Step!.ApprovalWorkflowId == x.e.ApprovalWorkflowId
                 orderby lv.Step!.Order, lv.Order
                 select lv.Name).FirstOrDefault()))
            .Take(100)
            .ToListAsync(ct);
    }

    // Helper: precompute V2 phiếu IDs nơi actor là approver Cấp hiện tại.
    // In-memory join vì Step.Order vs Index 0-based không thẳng EF được.
    private async Task<HashSet<Guid>> ResolveV2InboxIdsAsync(Guid? userId, CancellationToken ct)
    {
        if (userId is null) return new HashSet<Guid>();
        var candidates = await (
            from e in db.PurchaseEvaluations.AsNoTracking()
            where e.Phase == PurchaseEvaluationPhase.ChoDuyet
                && e.ApprovalWorkflowId != null
                && e.CurrentWorkflowStepIndex != null
                && e.CurrentApprovalLevelOrder != null
            select new { e.Id, e.ApprovalWorkflowId, e.CurrentWorkflowStepIndex, e.CurrentApprovalLevelOrder }
        ).ToListAsync(ct);

        if (candidates.Count == 0) return new HashSet<Guid>();

        var wfIds = candidates.Select(c => c.ApprovalWorkflowId!.Value).Distinct().ToList();
        var workflows = await db.ApprovalWorkflows.AsNoTracking()
            .Where(w => wfIds.Contains(w.Id))
            .Include(w => w.Steps.OrderBy(s => s.Order))
                .ThenInclude(s => s.Levels.OrderBy(l => l.Order))
            .ToDictionaryAsync(w => w.Id, ct);

        var result = new HashSet<Guid>();
        foreach (var c in candidates)
        {
            if (!workflows.TryGetValue(c.ApprovalWorkflowId!.Value, out var wf)) continue;
            var steps = wf.Steps.OrderBy(s => s.Order).ToList();
            var idx = c.CurrentWorkflowStepIndex!.Value;
            if (idx < 0 || idx >= steps.Count) continue;
            var step = steps[idx];
            var match = step.Levels.Any(l =>
                l.Order == c.CurrentApprovalLevelOrder!.Value
                && l.ApproverUserId == userId.Value);
            if (match) result.Add(c.Id);
        }
        return result;
    }
}

// ========== GET detail bundle ==========

public record GetPurchaseEvaluationQuery(Guid Id) : IRequest<PurchaseEvaluationDetailBundleDto>;

public class GetPurchaseEvaluationQueryHandler(
    IApplicationDbContext db,
    UserManager<User> userManager,
    ICurrentUser currentUser) : IRequestHandler<GetPurchaseEvaluationQuery, PurchaseEvaluationDetailBundleDto>
{
    public async Task<PurchaseEvaluationDetailBundleDto> Handle(
        GetPurchaseEvaluationQuery request, CancellationToken ct)
    {
        var e = await db.PurchaseEvaluations.AsNoTracking()
            .Include(x => x.Suppliers)
            .Include(x => x.Details).ThenInclude(d => d.Quotes)
            .Include(x => x.Approvals)
            .Include(x => x.Attachments)
            .Include(x => x.DepartmentOpinions)
            .Include(x => x.LevelOpinions)  // Mig 26 — Section 5 V2 dynamic
            .FirstOrDefaultAsync(x => x.Id == request.Id, ct)
            ?? throw new NotFoundException("PurchaseEvaluation", request.Id);

        var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
        if (!isAdmin)
        {
            var isDrafter = e.DrafterUserId == currentUser.UserId;
            var eligiblePhases = ListPurchaseEvaluationsQueryHandler.GetEligiblePhases(currentUser.Roles);
            // V2 strict scope (Plan E S22 — Session 21 +1): actor là approver
            // trong any Step.Level của workflow đã pin. Trước đây loose
            // `isPinnedV2 = e.ApprovalWorkflowId is not null` đã thắt chặt sau
            // UAT confirm flow V2.
            var isV2Approver = false;
            if (e.ApprovalWorkflowId is Guid awIdForCheck && currentUser.UserId is Guid uidForCheck)
            {
                isV2Approver = await db.ApprovalWorkflowLevels.AsNoTracking()
                    .AnyAsync(l => l.Step!.ApprovalWorkflowId == awIdForCheck
                        && l.ApproverUserId == uidForCheck, ct);
            }
            // [S89] Nháp = RIÊNG TƯ: V2-approver KHÔNG mở được phiếu CÒN nháp (chỉ thấy khi đã
            // gửi duyệt). eligiblePhases đã bỏ DangSoanThao → cond role cũng loại; chỉ Drafter
            // (+ Admin bypass trên) mở được nháp.
            var isDraftPhase = e.Phase == PurchaseEvaluationPhase.DangSoanThao;
            if (!isDrafter && !eligiblePhases.Contains(e.Phase) && (isDraftPhase || !isV2Approver))
                throw new ForbiddenException("Bạn không có quyền xem phiếu này.");
        }

        var project = await db.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == e.ProjectId, ct);
        // [Mig 49 S57bis] Resolve hạng mục công việc giống Project (loose-Guid).
        var workItem = e.WorkItemId is null ? null : await db.WorkItems.AsNoTracking().FirstOrDefaultAsync(w => w.Id == e.WorkItemId, ct);
        var department = e.DepartmentId is null ? null : await db.Departments.AsNoTracking().FirstOrDefaultAsync(d => d.Id == e.DepartmentId, ct);
        var selectedSupplier = e.SelectedSupplierId is null ? null : await db.Suppliers.AsNoTracking().FirstOrDefaultAsync(s => s.Id == e.SelectedSupplierId, ct);

        // [S61 Mig 50] Bảng "TỔNG HỢP NGÂN SÁCH TRÌNH KÝ" theo Excel anh Kiệt —
        // record PeWorkItemBudgets dùng chung mọi phiếu cùng cặp (ProjectId,
        // WorkItemId) + lũy kế các phiếu TRƯỚC (CreatedAt < this). Phiếu cũ chưa
        // gắn Hạng mục (WorkItemId null) → null → FE banner nhắc gắn hạng mục.
        PeBudgetSummaryDto? peBudgetSummary = null;
        if (e.WorkItemId is Guid wiKey)
        {
            var canEditPro = isAdmin || currentUser.Roles.Contains(AppRoles.Procurement);
            var canEditCcm = isAdmin || currentUser.Roles.Contains(AppRoles.CostControl);

            // pairRec load NGUYÊN cho MỌI nhánh — nhánh frozen chỉ dùng .Id (FE link không gãy),
            // KHÔNG dùng số của nó (số đọc từ cột snapshot ApprovedBudget*).
            var pairRec = await db.PeWorkItemBudgets.AsNoTracking()
                .FirstOrDefaultAsync(b => b.ProjectId == e.ProjectId && b.WorkItemId == wiKey, ct);

            var curSupplierRowIds = e.Suppliers.Select(s => s.Id).ToList();

            // [S133] Display-gate: phiếu DaDuyet ĐÃ chốt snapshot → serve BẤT BIẾN từ 11 cột
            // ApprovedBudget* (KHÔNG chạy theo record PeWorkItemBudgets live — record dùng chung
            // vẫn sửa được cho phiếu chưa duyệt). Fallback: DaDuyet mà SnapshotAt null → rơi xuống
            // nhánh live như cũ — defense-in-depth; mọi đường tạo DaDuyet đã set SnapshotAt
            // (4 site finalize + admin-override site-5 + seeder demo + backfill Mig 67 — review S133).
            var frozen = e.Phase == PurchaseEvaluationPhase.DaDuyet && e.ApprovedBudgetSnapshotAt != null;
            if (frozen)
            {
                // Row 4 "Giá trị kỳ này" — SPEC S133: VẪN tính LIVE từ own quotes (KHÔNG snapshot).
                var currentProposalTotal = curSupplierRowIds.Count == 0 ? 0m
                    : await db.PurchaseEvaluationQuotes.AsNoTracking()
                        .Where(q => curSupplierRowIds.Contains(q.PurchaseEvaluationSupplierId) && q.IsSelected)
                        .SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;

                // Full mỗi cột từ SNAPSHOT (mirror công thức :957-962). Ccm* = cột CCM đã chốt.
                var sHasCcm = e.ApprovedBudgetCcmInitialAmount is not null || e.ApprovedBudgetCcmAdjustmentAmount is not null;
                var sHasPro = e.ApprovedBudgetProInitialAmount is not null || e.ApprovedBudgetProAdjustmentAmount is not null;
                var sProFull = (e.ApprovedBudgetProInitialAmount ?? 0m) + (e.ApprovedBudgetProAdjustmentAmount ?? 0m);
                var sFullAmount = sHasCcm
                    ? (e.ApprovedBudgetCcmInitialAmount ?? 0m) + (e.ApprovedBudgetCcmAdjustmentAmount ?? 0m)
                    : sProFull;

                peBudgetSummary = new PeBudgetSummaryDto(
                    pairRec?.Id, null, e.ApprovedBudgetProNote,
                    e.ApprovedBudgetCcmInitialAmount, e.ApprovedBudgetCcmAdjustmentAmount, e.ApprovedBudgetCcmNote,
                    sFullAmount, !sHasCcm && sHasPro,
                    false, false,   // phiếu đã chốt → KHÔNG cho sửa PRO/CCM
                    e.ApprovedBudgetPrevSubmittedTotal ?? 0m, e.ApprovedBudgetPrevSubmittedCount ?? 0,
                    e.ApprovedBudgetPrevSelectedTotal ?? 0m, e.ApprovedBudgetPrevSelectedCount ?? 0,
                    currentProposalTotal,
                    e.ApprovedBudgetProInitialAmount, e.ApprovedBudgetProAdjustmentAmount,
                    BudgetFrozen: true,
                    // [S134] phiếu DaDuyet bất biến — KHÔNG lũy kế tạm-tính (giữ defaults 0/null).
                    PendingSubmittedTotal: 0m, PendingSubmittedCount: 0,
                    PendingSelectedTotal: 0m, PendingSelectedCount: 0,
                    PendingPriorPes: null);
            }
            else
            {
                // Lũy kế phiếu TRƯỚC cùng cặp + "giá trị kỳ này" (behavior-preserving qua accumulator —
                // extract nguyên semantics :917-951). Row 1 = đã trình (ChoDuyet + DaDuyet); Row 2 =
                // đã chọn thầu (DaDuyet + có đơn vị IsWinner). TraLai/DangSoanThao KHÔNG tính.
                var acc = await PeBudgetAccumulator.ComputeAsync(
                    db, e.ProjectId, wiKey, e.Id, e.CreatedAt, curSupplierRowIds, ct);

                // [S134] Lũy kế TẠM TÍNH — phiếu TRƯỚC cùng cặp đã trình/chọn NHƯNG CHƯA duyệt xong
                // (row 5/6 "tạm tính" + list phiếu cần lưu ý). CHỈ nhánh live (phiếu chưa chốt); nhánh
                // frozen giữ defaults 0/null. KHÔNG đổi số CHÍNH XÁC của acc (ComputeAsync bất động).
                var pending = await PeBudgetAccumulator.ComputePendingAsync(
                    db, e.ProjectId, wiKey, e.Id, e.CreatedAt, ct);

                // [S76] Full mỗi cột = Initial + Adjustment (cột đó). Authoritative full cho Block B =
                // CCM nếu đã nhập, else PRO (FullIsEstimate=true → FE badge "ngân sách PRO"). PRO full =
                // ProInitial + ProAdjust (migrate từ ProEstimate cũ qua Mig 56). Cả 2 trống → full 0.
                var hasCcm = pairRec?.InitialAmount is not null || pairRec?.AdjustmentAmount is not null;
                var hasPro = pairRec?.ProInitialAmount is not null || pairRec?.ProAdjustmentAmount is not null;
                var proFull = (pairRec?.ProInitialAmount ?? 0m) + (pairRec?.ProAdjustmentAmount ?? 0m);
                var fullAmount = hasCcm
                    ? (pairRec!.InitialAmount ?? 0m) + (pairRec.AdjustmentAmount ?? 0m)
                    : proFull;

                peBudgetSummary = new PeBudgetSummaryDto(
                    pairRec?.Id, pairRec?.ProEstimateAmount, pairRec?.ProNote,
                    pairRec?.InitialAmount, pairRec?.AdjustmentAmount, pairRec?.CcmNote,
                    fullAmount, !hasCcm && hasPro,
                    canEditPro, canEditCcm,
                    acc.PrevSubmittedTotal, acc.PrevSubmittedCount,
                    acc.PrevSelectedTotal, acc.PrevSelectedCount,
                    acc.CurrentProposalTotal,
                    pairRec?.ProInitialAmount, pairRec?.ProAdjustmentAmount,
                    BudgetFrozen: false,
                    // [S134] lũy kế tạm-tính chỉ nhánh live.
                    PendingSubmittedTotal: pending.PendingSubmittedTotal,
                    PendingSubmittedCount: pending.PendingSubmittedCount,
                    PendingSelectedTotal: pending.PendingSelectedTotal,
                    PendingSelectedCount: pending.PendingSelectedCount,
                    PendingPriorPes: pending.PriorPes);
            }
        }

        // Load supplier names for PE suppliers + approver names
        var supplierIds = e.Suppliers.Select(s => s.SupplierId).ToList();
        var suppliers = await db.Suppliers.AsNoTracking().Where(s => supplierIds.Contains(s.Id))
            .ToDictionaryAsync(s => s.Id, s => s.Name, ct);

        var userIds = new HashSet<Guid>();
        if (e.DrafterUserId is Guid did) userIds.Add(did);
        foreach (var a in e.Approvals) if (a.ApproverUserId is Guid aid) userIds.Add(aid);
        var users = await userManager.Users.AsNoTracking()
            .Where(u => userIds.Contains(u.Id))
            .ToDictionaryAsync(u => u.Id, u => u.FullName, ct);

        // Resolve workflow policy (V2 ưu tiên nếu pin)
        PurchaseEvaluationPolicy policy;
        if (e.ApprovalWorkflowId is not null)
        {
            policy = PurchaseEvaluationPolicyRegistry.ForV2Schema();
        }
        else if (e.WorkflowDefinitionId is Guid wfId)
        {
            var def = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking()
                .Include(d => d.Steps.OrderBy(s => s.Order))
                    .ThenInclude(s => s.Approvers)
                .FirstOrDefaultAsync(d => d.Id == wfId, ct);
            policy = def is not null
                ? PurchaseEvaluationPolicyRegistry.FromDefinition(def)
                : PurchaseEvaluationPolicyRegistry.ForEvaluation(e);
        }
        else
        {
            policy = PurchaseEvaluationPolicyRegistry.ForEvaluation(e);
        }

        // Mig 23 — load ApprovalWorkflow V2 info nếu pin (Code/Name/Version
        // hiển thị FE detail card "QT-DN-V2-001 - Tên (v01)").
        // Mig 24 — populate CurrentApproval (cấp hiện tại) + ApprovalFlow (full
        // Bước/Cấp tree với Status) cho FE render flow vertical thay phase cards.

        string? awCode = null, awName = null;
        int? awVersion = null;
        decimal? awCeoThreshold = null;     // [S69] ngưỡng gói CEO từ workflow pin
        ApprovalWorkflowOptionsDto? currentLevelOptions = null;
        PurchaseEvaluationCurrentApprovalDto? currentApproval = null;
        PurchaseEvaluationApprovalFlowDto? approvalFlow = null;
        // [S89 Mig 58 anh Kiệt FDC] "Duyệt là KẾT THÚC quy trình (không trình CEO)" —
        // first-reachable Cấp bật AllowApproverFinalize theo Step.Order tăng → Level.Order
        // tăng. V1 (aw==null) giữ default false/null. Names dùng dept-name thân-thiện
        // (mirror step-label build flow ~:1084), fallback Step.Name.
        bool endsBeforeCeo = false;
        string? finalizeStepName = null;
        string? finalizeLevelName = null;
        if (e.ApprovalWorkflowId is Guid awId)
        {
            var aw = await db.ApprovalWorkflows.AsNoTracking()
                .Include(w => w.Steps.OrderBy(s => s.Order))
                    .ThenInclude(s => s.Levels.OrderBy(l => l.Order))
                .FirstOrDefaultAsync(w => w.Id == awId, ct);
            if (aw is not null)
            {
                awCode = aw.Code;
                awName = aw.Name;
                awVersion = aw.Version;
                awCeoThreshold = aw.CeoApprovalThreshold;     // [S69] ngưỡng gói CEO

                // Mig 29 (S21 t5) + Mig 30 (S22+5) + Mig 31 (S23 t1) — Resolve
                // Cấp hiện tại + populate 7 Allow* flag của slot Approver đang
                // duyệt. Null nếu pointer chưa init.
                //
                // Plan N S23 t4 (2026-05-15) — Per-NV lookup site MUST discriminate
                // theo ApproverUserId. Schema Mig 29: OR-of-N approvers cùng Cấp =
                // N rows cùng Order, mỗi row có Allow* riêng. Lookup KHÔNG
                // discriminate → luôn lấy row đầu DB, bỏ qua admin tick per-NV.
                if (e.CurrentWorkflowStepIndex is int curStepIdx
                    && curStepIdx >= 0 && curStepIdx < aw.Steps.Count
                    && e.CurrentApprovalLevelOrder is int curLevelOrder)
                {
                    var curStep = aw.Steps.OrderBy(s => s.Order).Skip(curStepIdx).FirstOrDefault();
                    // Match actor.UserId trong slot (per-NV admin opt-in flag).
                    // Admin / non-approver fallback row đầu (giữ behavior view detail).
                    var curLevel = curStep?.Levels.FirstOrDefault(l =>
                        l.Order == curLevelOrder && l.ApproverUserId == currentUser.UserId)
                        ?? curStep?.Levels.FirstOrDefault(l => l.Order == curLevelOrder);
                    if (curLevel is not null)
                    {
                        currentLevelOptions = new ApprovalWorkflowOptionsDto(
                            curLevel.AllowReturnOneLevel,
                            curLevel.AllowReturnOneStep,
                            curLevel.AllowReturnToAssignee,
                            curLevel.AllowReturnToDrafter,
                            curLevel.AllowApproverEditDetails,
                            curLevel.AllowApproverEditBudget,
                            curLevel.AllowApproverSkipToFinal,
                            curLevel.AllowApproverFinalize,
                            curLevel.AllowApproverDelete);   // [S155 đợt 2] F6 — FE gate nút Xóa bằng đúng field này
                    }
                }

                var steps = aw.Steps.OrderBy(s => s.Order).ToList();
                // Resolve dept names cho Steps
                var stepDeptIds = steps.Where(s => s.DepartmentId != null)
                    .Select(s => s.DepartmentId!.Value).Distinct().ToList();
                var stepDeptNames = stepDeptIds.Count == 0
                    ? new Dictionary<Guid, string>()
                    : await db.Departments.AsNoTracking()
                        .Where(d => stepDeptIds.Contains(d.Id))
                        .ToDictionaryAsync(d => d.Id, d => d.Name, ct);
                // Resolve approver user info (all levels)
                var allApproverIds = steps.SelectMany(s => s.Levels)
                    .Select(l => l.ApproverUserId).Distinct().ToList();
                var approverInfos = allApproverIds.Count == 0
                    ? new Dictionary<Guid, (string FullName, string? Email)>()
                    : await userManager.Users.AsNoTracking()
                        .Where(u => allApproverIds.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
                // (:800-801): PRO = Admin|Procurement, CCM = Admin|CostControl.
                var adminBudgetIds = (await userManager.GetUsersInRoleAsync(AppRoles.Admin)).Select(u => u.Id).ToHashSet();
                var proBudgetEditors = (await userManager.GetUsersInRoleAsync(AppRoles.Procurement)).Select(u => u.Id).Concat(adminBudgetIds).ToHashSet();
                var ccmBudgetEditors = (await userManager.GetUsersInRoleAsync(AppRoles.CostControl)).Select(u => u.Id).Concat(adminBudgetIds).ToHashSet();

                // Compute Status mỗi level theo Phase + currentStepIdx + currentLevelOrder
                var currentIdx = e.CurrentWorkflowStepIndex;
                var currentLevel = e.CurrentApprovalLevelOrder;
                var phase = e.Phase;
                bool isTerminalDone = phase == PurchaseEvaluationPhase.DaDuyet;
                bool isPending = phase == PurchaseEvaluationPhase.DangSoanThao
                              || phase == PurchaseEvaluationPhase.TraLai;
                bool isLocked = phase == PurchaseEvaluationPhase.TuChoi;

                string ComputeLevelStatus(int stepIdx0, int levelOrder)
                {
                    if (isTerminalDone) return "Done";
                    if (isPending || isLocked) return "Pending";
                    if (currentIdx is null || currentLevel is null) return "Pending";
                    if (stepIdx0 < currentIdx.Value) return "Done";
                    if (stepIdx0 == currentIdx.Value)
                    {
                        if (levelOrder < currentLevel.Value) return "Done";
                        if (levelOrder == currentLevel.Value) return "Current";
                        return "Pending";
                    }
                    return "Pending";
                }

                string ComputeStepStatus(int stepIdx0, int stepLevelCount)
                {
                    if (isTerminalDone) return "Done";
                    if (isPending || isLocked) return "Pending";
                    if (currentIdx is null) return "Pending";
                    if (stepIdx0 < currentIdx.Value) return "Done";
                    if (stepIdx0 == currentIdx.Value) return "Current";
                    return "Pending";
                }

                var flowSteps = new List<PurchaseEvaluationApprovalFlowStepDto>();
                for (int i = 0; i < steps.Count; i++)
                {
                    var step = steps[i];
                    var levelGroups = step.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList();
                    var flowLevels = levelGroups.Select(g =>
                    {
                        var approvers = g.Select(l =>
                        {
                            approverInfos.TryGetValue(l.ApproverUserId, out var info);
                            return new PurchaseEvaluationApprovalLevelApproverDto(
                                l.ApproverUserId,
                                info.FullName ?? l.ApproverUserId.ToString(),
                                info.Email,
                                proBudgetEditors.Contains(l.ApproverUserId), ccmBudgetEditors.Contains(l.ApproverUserId));
                        }).ToList();
                        var levelName = g.FirstOrDefault()?.Name;
                        return new PurchaseEvaluationApprovalFlowLevelDto(
                            g.Key, levelName, approvers,
                            ComputeLevelStatus(i, g.Key),
                            // [S89 Mig 58] OR-of-N rows cùng Order: bất kỳ NV trong cấp bật cờ
                            // → cấp này là điểm KẾT THÚC (FE render note trong flow).
                            g.Any(l => l.AllowApproverFinalize));
                    }).ToList();

                    flowSteps.Add(new PurchaseEvaluationApprovalFlowStepDto(
                        step.Order, step.Name,
                        step.DepartmentId,
                        step.DepartmentId is Guid sd && stepDeptNames.TryGetValue(sd, out var sdn) ? sdn : null,
                        ComputeStepStatus(i, levelGroups.Count),
                        flowLevels));
                }

                approvalFlow = new PurchaseEvaluationApprovalFlowDto(currentIdx, currentLevel, flowSteps);

                // [S89 Mig 58] First-reachable finalize theo Step.Order tăng → Level.Order
                // tăng (steps đã OrderBy Order). Tên Bước ưu tiên dept-name thân-thiện
                // (mirror flow step-label ~:1084), fallback Step.Name.
                var finalizeStep = steps.FirstOrDefault(s => s.Levels.Any(l => l.AllowApproverFinalize));
                if (finalizeStep is not null)
                {
                    var finalizeLevel = finalizeStep.Levels
                        .Where(l => l.AllowApproverFinalize).OrderBy(l => l.Order).FirstOrDefault();
                    // [S97] endsBeforeCeo theo RUNTIME cho phiếu DaDuyet (đọc EndedByLevelFinalize).
                    // [S117 anh Kiệt — bug "duyệt rồi không bắt lũy kế"] Phiếu CHƯA DaDuyet nay
                    // POINTER-AWARE: heads-up "kết thúc trước CEO" CHỈ khi CÒN cấp finalize ở-hoặc-SAU
                    // con-trỏ hiện tại (còn có thể finalize). Phiếu đã đi QUA mọi cấp finalize (approver
                    // opt-out S97 → trình tiếp CEO) → false: KHÔNG làm mờ Bước sau + KHÔNG nói dối
                    // "Kết thúc tại Cấp X" khi phiếu đang nằm inbox CEO (A/018: user tưởng đã duyệt
                    // xong nên trách "lũy kế không bắt"). Con-trỏ null (Nháp/TraLai) → giữ config như cũ.
                    var anyReachableFinalize = currentIdx is int finCi && currentLevel is int finCl
                        ? steps.Select((s, si) => (s, si)).Any(t => t.si >= finCi
                            && t.s.Levels.Any(l => l.AllowApproverFinalize && (t.si > finCi || l.Order >= finCl)))
                        : true;
                    endsBeforeCeo = phase == PurchaseEvaluationPhase.DaDuyet
                        ? e.EndedByLevelFinalize
                        : anyReachableFinalize;
                    finalizeStepName = finalizeStep.DepartmentId is Guid fsd
                        && stepDeptNames.TryGetValue(fsd, out var fsdn) ? fsdn : finalizeStep.Name;
                    finalizeLevelName = finalizeLevel?.Name;
                }

                // CurrentApproval (legacy banner) — chỉ populate nếu Phase=ChoDuyet
                if (phase == PurchaseEvaluationPhase.ChoDuyet
                    && currentIdx is int idxCur && currentLevel is int lvlCur
                    && idxCur >= 0 && idxCur < steps.Count)
                {
                    var step = steps[idxCur];
                    var levelGroup = step.Levels.Where(l => l.Order == lvlCur).ToList();
                    var approvers = levelGroup.Select(l =>
                    {
                        approverInfos.TryGetValue(l.ApproverUserId, out var info);
                        return new PurchaseEvaluationApprovalLevelApproverDto(
                            l.ApproverUserId,
                            info.FullName ?? l.ApproverUserId.ToString(),
                            info.Email,
                            proBudgetEditors.Contains(l.ApproverUserId), ccmBudgetEditors.Contains(l.ApproverUserId));
                    }).ToList();
                    var levelName = levelGroup.FirstOrDefault()?.Name;
                    currentApproval = new PurchaseEvaluationCurrentApprovalDto(
                        idxCur, step.Name, step.DepartmentId,
                        step.DepartmentId is Guid sd2 && stepDeptNames.TryGetValue(sd2, out var sdn2) ? sdn2 : null,
                        lvlCur, levelName, approvers);
                }
            }
        }

        // [multi-NCC 2026-07-12 — anh Kiệt LOCKED D1] Giá trị gói = SUM ThanhTien báo giá ĐƯỢC
        // CHỌN per hạng mục (Quote.IsSelected = nguồn-sự-thật). Tính từ data đã load (Details.Quotes)
        // — KHÔNG query thêm. 0 khi chưa chọn NCC nào. FE so CeoApprovalThreshold → "CCM duyệt-final".
        var winnerQuoteTotal = e.Details
            .SelectMany(det => det.Quotes)
            .Where(q => q.IsSelected)
            .Sum(q => q.ThanhTien);

        // [Mig 54] Capability nhập giá đề xuất theo role (mirror PeBudgetSummary canEdit).
        var canEditProSuggested = isAdmin || currentUser.Roles.Contains(AppRoles.Procurement);
        var canEditCcmSuggested = isAdmin || currentUser.Roles.Contains(AppRoles.CostControl);

        return new PurchaseEvaluationDetailBundleDto(
            e.Id, e.MaPhieu, e.Type, e.Phase, e.TenGoiThau, e.DiaDiem, e.MoTa,
            e.HoSoLink,                                              // [HoSoLink] hyperlink thư mục hồ sơ NAS
            e.ProjectId, project?.Name ?? "",
            e.WorkItemId, workItem?.Name, workItem?.Code,
            e.DepartmentId, department?.Name,
            e.DrafterUserId, e.DrafterUserId is Guid d && users.TryGetValue(d, out var dn) ? dn : null,
            e.SelectedSupplierId, selectedSupplier?.Name,
            e.ContractId,
            e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt,
            e.BudgetPeriodAmount, e.ExpectedRemainingAmount, e.CcmBudgetPeriodAmount, peBudgetSummary,
            e.IsUrgentByPro, e.IsUrgentByCcm, winnerQuoteTotal, awCeoThreshold,   // [S69] cờ gấp + giá trị gói + ngưỡng CEO
            e.ProSuggestedMinPrice, e.ProSuggestedMaxPrice, e.CcmSuggestedPrice,  // [Mig 54] giá đề xuất PRO/CCM
            e.ProSuggestedPriceNote, e.CcmSuggestedPriceNote,                     // [Mig 57] ghi chú giải thích giá
            e.ApprovedPriceAmount, e.ApprovedPriceSource, e.ApprovedPriceMaxAmount, // [Mig 54 + S117] giá chốt (+ Max khoảng ProMinMax)
            canEditProSuggested, canEditCcmSuggested,                             // [Mig 54] capability role-gate
            e.ApprovalWorkflowId, awCode, awName, awVersion, currentLevelOptions,
            currentApproval, approvalFlow,
            e.Suppliers
                .OrderBy(s => s.Order)
                .Select(s => new PurchaseEvaluationSupplierDto(
                    s.Id, s.SupplierId,
                    suppliers.TryGetValue(s.SupplierId, out var sn) ? sn : "",
                    s.DisplayName, s.ContactName, s.ContactEmail, s.ContactPhone,
                    s.PaymentTermText, s.Note, s.Order, s.IsWinner))
                .ToList(),
            e.Details
                .OrderBy(d => d.Order)
                .Select(d => new PurchaseEvaluationDetailDto(
                    d.Id, d.GroupCode, d.GroupName, d.ItemCode, d.NoiDung, d.DonViTinh,
                    d.KhoiLuongNganSach, d.KhoiLuongThiCong, d.DonGiaNganSach, d.ThanhTienNganSach,
                    d.Order, d.GhiChu,
                    d.Quotes.Select(q => new PurchaseEvaluationQuoteDto(
                        q.Id, q.PurchaseEvaluationDetailId, q.PurchaseEvaluationSupplierId,
                        q.BgVat, q.ChuaVat, q.ThanhTien, q.IsSelected, q.Note)).ToList()))
                .ToList(),
            e.Approvals
                .OrderBy(a => a.ApprovedAt)
                .Select(a => new PurchaseEvaluationApprovalDto(
                    a.Id, a.FromPhase, a.ToPhase, a.ApproverUserId,
                    a.ApproverUserId is Guid uid && users.TryGetValue(uid, out var an) ? an : null,
                    a.Decision, a.Comment, a.ApprovedAt))
                .ToList(),
            e.Attachments
                .OrderByDescending(a => a.CreatedAt)
                .Select(a => new PurchaseEvaluationAttachmentDto(
                    a.Id, a.PurchaseEvaluationSupplierId, a.FileName, a.StoragePath,
                    a.FileSize, a.ContentType, a.Purpose, a.Note, a.CreatedAt))
                .ToList(),
            e.DepartmentOpinions
                .OrderBy(o => (int)o.Kind)
                .Select(o => new PurchaseEvaluationDepartmentOpinionDto(
                    o.Id, o.Kind, KindLabel(o.Kind),
                    o.Opinion, o.SignedAt, o.UserId, o.UserName))
                .ToList(),
            await BuildLevelOpinionsAsync(e, ct),
            endsBeforeCeo, finalizeStepName, finalizeLevelName,   // [S89 Mig 58] Duyệt = KẾT THÚC (không trình CEO)
            new PurchaseEvaluationWorkflowSummaryDto(
                policy.Name, policy.Description,
                policy.ActivePhases.ToList(),
                policy.NextPhasesFrom(e.Phase).ToList()));
    }

    // Mig 26 (Session 19) — Build LevelOpinionDto[] cho Section 5 dynamic.
    // Phiếu V1 (no ApprovalWorkflowId) hoặc chưa có cấp duyệt nào → empty list,
    // FE hiển thị fallback message. JOIN Steps/Levels lấy meta (StepOrder, name,
    // DepartmentName, ApproverFullName) — denorm vào DTO để FE render trực tiếp.
    private async Task<List<PurchaseEvaluationLevelOpinionDto>> BuildLevelOpinionsAsync(
        PurchaseEvaluation e, CancellationToken ct)
    {
        var result = new List<PurchaseEvaluationLevelOpinionDto>();
        if (e.LevelOpinions.Count == 0 || e.ApprovalWorkflowId is not Guid wfV2Id)
            return result;

        var aw = await db.ApprovalWorkflows.AsNoTracking()
            .Include(w => w.Steps).ThenInclude(s => s.Levels)
            .FirstOrDefaultAsync(w => w.Id == wfV2Id, ct);
        if (aw is null) return result;

        var levelMap = aw.Steps
            .SelectMany(s => s.Levels.Select(l => new { Step = s, Level = l }))
            .ToDictionary(x => x.Level.Id);

        var deptIds = aw.Steps.Where(s => s.DepartmentId.HasValue)
            .Select(s => s.DepartmentId!.Value).Distinct().ToList();
        var depts = await db.Departments.AsNoTracking()
            .Where(d => deptIds.Contains(d.Id))
            .ToDictionaryAsync(d => d.Id, d => d.Name, ct);

        var userIds = new HashSet<Guid>(
            aw.Steps.SelectMany(s => s.Levels.Select(l => l.ApproverUserId)));
        // SignedByUserId có thể KHÁC ApproverUserId (Admin override) — load thêm.
        foreach (var op in e.LevelOpinions) userIds.Add(op.SignedByUserId);
        var users = await userManager.Users.AsNoTracking()
            .Where(u => userIds.Contains(u.Id))
            .ToDictionaryAsync(u => u.Id, u => u.FullName, ct);

        foreach (var op in e.LevelOpinions.OrderBy(o => o.SignedAt))
        {
            if (!levelMap.TryGetValue(op.ApprovalWorkflowLevelId, out var meta)) continue;
            string? deptName = meta.Step.DepartmentId.HasValue
                && depts.TryGetValue(meta.Step.DepartmentId.Value, out var dn)
                ? dn : null;
            string? approverFullName = users.TryGetValue(meta.Level.ApproverUserId, out var an) ? an : null;
            result.Add(new PurchaseEvaluationLevelOpinionDto(
                op.Id,
                op.ApprovalWorkflowLevelId,
                meta.Step.Order,
                meta.Step.Name,
                meta.Step.DepartmentId,
                deptName,
                meta.Level.Order,
                meta.Level.Name,
                meta.Level.ApproverUserId,
                approverFullName,
                op.Comment ?? "",
                op.SignedAt,
                op.SignedByUserId,
                op.SignedByFullName));
        }
        return result;
    }

    private static string KindLabel(PeDepartmentKind k) => k switch
    {
        PeDepartmentKind.PheDuyet => "Phê duyệt",
        PeDepartmentKind.Ccm => "P.CCM",
        PeDepartmentKind.MuaHang => "P.Mua hàng",
        PeDepartmentKind.SmPm => "SM-PM",
        _ => k.ToString(),
    };
}

// ========== DELETE ==========

public record DeletePurchaseEvaluationCommand(Guid Id) : IRequest;

public class DeletePurchaseEvaluationCommandHandler(
    IApplicationDbContext db) : IRequestHandler<DeletePurchaseEvaluationCommand>
{
    public async Task Handle(DeletePurchaseEvaluationCommand request, CancellationToken ct)
    {
        var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
            ?? throw new NotFoundException("PurchaseEvaluation", request.Id);

        if (entity.Phase != PurchaseEvaluationPhase.DangSoanThao
            && entity.Phase != PurchaseEvaluationPhase.TuChoi)
            throw new ConflictException("Chỉ xóa được phiếu ở phase Soạn thảo hoặc Từ chối.");

        db.PurchaseEvaluations.Remove(entity);
        await db.SaveChangesAsync(ct);
    }
}

// ========== CHANGELOG list ==========

public record ListPurchaseEvaluationChangelogsQuery(Guid PurchaseEvaluationId, int Take = 200)
    : IRequest<List<PurchaseEvaluationChangelogDto>>;

public class ListPurchaseEvaluationChangelogsQueryHandler(IApplicationDbContext db)
    : IRequestHandler<ListPurchaseEvaluationChangelogsQuery, List<PurchaseEvaluationChangelogDto>>
{
    public async Task<List<PurchaseEvaluationChangelogDto>> Handle(
        ListPurchaseEvaluationChangelogsQuery request, CancellationToken ct)
    {
        return await db.PurchaseEvaluationChangelogs.AsNoTracking()
            .Where(c => c.PurchaseEvaluationId == request.PurchaseEvaluationId)
            .OrderByDescending(c => c.CreatedAt)
            .Take(request.Take)
            .Select(c => new PurchaseEvaluationChangelogDto(
                c.Id, c.EntityType, c.EntityId, c.Action, c.PhaseAtChange,
                c.UserId, c.UserName, c.Summary, c.FieldChangesJson, c.ContextNote,
                c.CreatedAt))
            .ToListAsync(ct);
    }
}
