using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Notifications;
using SolutionErp.Application.PurchaseEvaluations.Services;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Notifications;
using SolutionErp.Domain.PurchaseEvaluations;

namespace SolutionErp.Infrastructure.Services;

// PE Workflow Service — Session 17 spec mới (state machine 5 trạng thái):
//   Nháp (DangSoanThao) ──trình──► Đã gửi duyệt (ChoDuyet) ─approve cấp cuối─► Đã duyệt (DaDuyet, terminal)
//                                                          ├─ Trả lại ────────► Trả lại (TraLai)
//                                                          └─ Từ chối ────────► Từ chối (TuChoi, terminal)
//   Trả lại ──Drafter sửa+gửi lại──► Đã gửi duyệt (chạy LẠI từ đầu, KHÔNG jump-back)
//
// Service branch theo schema pin:
// - V2 (Mig 22-24): PE.ApprovalWorkflowId set → iterate ApprovalWorkflowSteps
//   OrderBy Order → mỗi Step iterate Levels group by Order (Cấp 1/2/3, OR-of-N
//   approvers cùng cấp). Match actor.Id == ApprovalWorkflowLevel.ApproverUserId.
//   CurrentApprovalLevelOrder track Cấp đang chờ.
// - V1 legacy (Mig 21): chỉ WorkflowDefinitionId set → iterate
//   PurchaseEvaluationWorkflowSteps + match Dept+PositionLevel/Role/User.
public class PurchaseEvaluationWorkflowService(
    IApplicationDbContext db,
    IDateTime dateTime,
    INotificationService notifications,
    UserManager<User> userManager) : IPurchaseEvaluationWorkflowService
{
    public TimeSpan? GetPhaseSla(PurchaseEvaluationPhase phase) =>
        phase == PurchaseEvaluationPhase.ChoDuyet ? TimeSpan.FromDays(7) : null;

    public async Task TransitionAsync(
        PurchaseEvaluation evaluation,
        PurchaseEvaluationPhase targetPhase,
        Guid? actorUserId,
        IReadOnlyList<string> actorRoles,
        ApprovalDecision decision,
        string? comment,
        WorkflowReturnMode? returnMode = null,
        Guid? returnTargetUserId = null,
        bool skipToFinal = false,
        bool finalizeByCcmDelegation = false,
        decimal? approvedPriceAmount = null,
        string? approvedPriceSource = null,
        decimal? approvedPriceMaxAmount = null,
        bool applyLevelFinalize = true,
        CancellationToken ct = default)
    {
        var fromPhase = evaluation.Phase;
        var isAdmin = actorRoles.Contains(AppRoles.Admin);
        var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;

        // ===== EDGE-5 hard-lock (close-review wf_7df56b89-7bb — owner anh Kiệt oQ2) =====
        // Phiếu ĐÃ ở trạng thái KẾT THÚC (DaDuyet / TuChoi) → user THƯỜNG không được trả
        // lại, từ chối hay chuyển trạng thái. Chặn exploit EDGE-5: drafter non-admin forge
        // Reject trên chính phiếu DaDuyet của mình → lật phiếu đã duyệt về "đang duyệt"
        // (un-terminal). ADMIN + SYSTEM MIỄN (owner oQ2 "Admin đầy đủ nhất" — giữ override
        // :290 + khả năng un-terminal + cron/SLA auto-job decision=AutoApprove). Đặt TRƯỚC
        // MỌI branch → chặn trên CẢ 5 đường (reject / drafter-submit / approve-step /
        // admin-override / fall-through) với non-admin. KHÔNG đụng EnsureCanRejectV2Async
        // (early-return của nó phục vụ system/cron hợp lệ — guard top-method này là fix duy nhất).
        if ((fromPhase is PurchaseEvaluationPhase.DaDuyet or PurchaseEvaluationPhase.TuChoi)
            && !isAdmin && !isSystem)
        {
            throw new ConflictException(
                $"Phiếu đã ở trạng thái kết thúc ({fromPhase}) — không thể trả lại, từ chối hay chuyển trạng thái.");
        }

        // ===== GUARD: targetPhase TraLai/TuChoi BẮT BUỘC decision=Reject =====
        // Defense-in-depth chặn FE inconsistency (gotcha #45 — Session 21 turn 3):
        //   Bug: button "← Trả lại" trong PeWorkflowPanel.tsx gửi decision=Approve
        //   khi target=TraLai do `isReject` local var thiếu nhánh TraLai. BE nhận
        //   payload sẽ skip Reject branch → enter APPROVE STEP → ApproveV2Async
        //   UPSERT opinion = "đã duyệt" + advance Cấp. User UAT thấy: "Trả về
        //   nhưng hệ thống vẫn duyệt".
        // FE fix song song trong fe-admin + fe-user (rule §3.9 mirror 2 app).
        // Guard này KHÔNG xoá khi FE fix — boundary protection cho mọi caller
        // tương lai (API client / mobile app / cron retry).
        if ((targetPhase == PurchaseEvaluationPhase.TraLai
                || targetPhase == PurchaseEvaluationPhase.TuChoi)
            && decision != ApprovalDecision.Reject)
        {
            throw new ConflictException(
                $"Transition tới {targetPhase} BẮT BUỘC decision=Reject (nhận {decision}). " +
                "Báo lỗi caller — payload mismatch giữa target phase và decision " +
                "(xem gotcha #45 + docs/workflow-contract.md).");
        }

        // ===== UAT S60 (anh Kiệt 14:14) — GỠ hành động "Từ chối" =====
        // "Bỏ luôn nút Từ chối — Duyệt hoặc Trả về thôi." Mọi policy đã bỏ
        // TuChoi khỏi NextPhases (FE hết nút); guard này chặn caller direct
        // (API forge / client cũ cache) — đứng TRƯỚC mọi branch nên chặn CẢ
        // Admin manual override (spec = bỏ hẳn hành động, không escape hatch).
        // Phase TuChoi + phiếu TuChoi cũ GIỮ display/filter. Flip lại nếu cần:
        // xóa guard này + restore transitions trong PurchaseEvaluationPolicy.
        if (targetPhase == PurchaseEvaluationPhase.TuChoi)
        {
            throw new ConflictException(
                "Hành động \"Từ chối\" đã được gỡ khỏi quy trình duyệt — chỉ còn Duyệt hoặc Trả lại. " +
                "Phiếu cần dừng: dùng Trả lại để người soạn sửa, hoặc Xóa phiếu khi còn Bản nháp.");
        }

        // ===== REJECT BRANCH (extended Mig 28 — F1 multi-mode Trả lại) =====
        if (decision == ApprovalDecision.Reject)
        {
            // UAT S22+1 — V2 actor scope guard (defense-in-depth).
            // FE PeWorkflowPanel disable cả 3 button (Duyệt + Trả lại + Từ chối)
            // khi actor không match currentLevel.ApproverUserId — BE mirror
            // guard tránh request forge non-approver gọi PATCH direct.
            await EnsureCanRejectV2Async(evaluation, actorUserId, isAdmin, ct);

            // Plan AC S25 Bug 3a — capture pre-call Step/Level để log Approval row
            // chính xác (ApplyReturnModeAsync mutate pointer cho 3 mode OneLevel/
            // OneStep/Assignee). FE ApprovalsTab render `ev.approvals` — trước
            // đây Reject KHÔNG add row → Lịch sử duyệt panel mất event Trả lại.
            var fromStepIdx = evaluation.CurrentWorkflowStepIndex;
            var fromLevelOrder = evaluation.CurrentApprovalLevelOrder;

            if (targetPhase == PurchaseEvaluationPhase.TuChoi)
            {
                // Từ chối hoàn toàn — phiếu khoá vĩnh viễn (lock edit Mig 16).
                evaluation.Phase = PurchaseEvaluationPhase.TuChoi;
                evaluation.SlaDeadline = null;
            }
            else
            {
                // F1 (S21 t4) — 4 mode Trả lại theo workflow.Allow* flag.
                // Default fallback (returnMode=null) = Drafter mode = S17 behavior.
                var effectiveMode = returnMode ?? WorkflowReturnMode.Drafter;
                var returnSummary = await ApplyReturnModeAsync(
                    evaluation, effectiveMode, returnTargetUserId, actorUserId, isAdmin, ct);
                comment = string.IsNullOrWhiteSpace(comment)
                    ? returnSummary
                    : $"{comment} [{returnSummary}]";
            }

            // Plan AC S25 Bug 3a — add Approval row cho Lịch sử duyệt panel
            // (FE ApprovalsTab). Decision=Reject + Comment carry mode + summary.
            // From-position dùng pre-call Step/Level để show actor đã trả lại
            // từ Bước/Cấp nào.
            var fromPos = fromStepIdx.HasValue && fromLevelOrder.HasValue
                ? $"[Bước {fromStepIdx.Value + 1} — Cấp {fromLevelOrder.Value}] "
                : "";
            db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
            {
                PurchaseEvaluationId = evaluation.Id,
                FromPhase = fromPhase,
                ToPhase = evaluation.Phase,
                ApproverUserId = actorUserId,
                Decision = ApprovalDecision.Reject,
                Comment = $"{fromPos}{comment ?? ""}".Trim(),
                ApprovedAt = dateTime.UtcNow,
            });

            await LogTransitionAsync(evaluation, fromPhase, evaluation.Phase, actorUserId, decision, comment, ct);
            await db.SaveChangesAsync(ct);
            return;
        }

        // ===== DRAFTER TRÌNH/GỬI LẠI (Nháp HOẶC Trả lại → ChoDuyet) =====
        // Cả 2 entry point cùng logic: chạy lại từ đầu (Step 0, Cấp 1).
        if ((fromPhase == PurchaseEvaluationPhase.DangSoanThao
                || fromPhase == PurchaseEvaluationPhase.TraLai)
            && (targetPhase == PurchaseEvaluationPhase.ChoDuyet || !isAdmin && !isSystem))
        {
            // Drafter/DeptManager only (or Admin bypass).
            if (!isAdmin && !isSystem
                && !actorRoles.Contains(AppRoles.Drafter)
                && !actorRoles.Contains(AppRoles.DeptManager))
            {
                throw new ForbiddenException(
                    $"Role ({string.Join(",", actorRoles)}) không đủ quyền trình duyệt phiếu.");
            }

            // ===== UAT S60 (anh Kiệt) — Section 3 completeness guard =====
            // "Mục chọn thầu phải có thông tin mới cho gửi duyệt" — đủ CẢ 4
            // mọi trường hợp (anh chốt S60): (1) Đơn vị NCC/TP được chọn
            // (2) Giá chào thầu > 0 của đơn vị đó (3) Ngân sách — link Budget
            // HOẶC nhập tay (4) Bảng so sánh đính kèm.
            // Message gộp MỌI mục thiếu 1 lần (UX — không bắt thử lại từng lỗi).
            // Áp CẢ Admin/system (data-quality ≠ authz — phiếu thiếu thông tin
            // thì không ai được trình, kể cả gửi hộ).
            // Predicate "Bảng so sánh" = attachment chung (SupplierRowId null)
            // — mirror ĐÚNG FE PeDetailTabs banSoSanhAttachments + pre-check
            // missingForApproval (FE KHÔNG check Purpose enum → BE cũng không,
            // tránh mismatch 2 tầng).
            var missing = new List<string>();
            // [multi-NCC A3(i)[2] + D5] Winner = có ÍT NHẤT 1 báo giá ĐƯỢC CHỌN (Quote.IsSelected
            // = nguồn-sự-thật per Detail×Supplier). Tổng giá gói = SUM ThanhTien mọi quote IsSelected
            // toàn phiếu (SUM có-dấu, chỉ dùng ở ngưỡng CEO/giaTri). [S116 anh Kiệt: cho nhập giá ÂM =
            // phát-sinh-giảm/hoàn-tiền = giá THẬT] 0 selected → "chưa chọn"; có selected nhưng TẤT CẢ = 0
            // → "chưa có giá" (âm/dương đều là giá thật → gửi được, kể cả 1 NCC 1 hạng mục âm; Sum()<=0→All(==0)).
            var selectedThanhTien = await (
                from q in db.PurchaseEvaluationQuotes.AsNoTracking()
                join d in db.PurchaseEvaluationDetails.AsNoTracking()
                    on q.PurchaseEvaluationDetailId equals d.Id
                where d.PurchaseEvaluationId == evaluation.Id && q.IsSelected
                select q.ThanhTien).ToListAsync(ct);
            if (selectedThanhTien.Count == 0)
            {
                missing.Add("chưa chọn Đơn vị NCC/TP");
            }
            else if (selectedThanhTien.All(x => x == 0))
            {
                missing.Add("Đơn vị được chọn chưa có giá chào thầu");
            }
            // [S61 Mig 50] Schema ngân sách mới — điều kiện (3) = "Ngân sách - kỳ này"
            // (BudgetPeriodAmount, drafter nhập). FE PeDetailTabs missingForApproval
            // mirror CÙNG predicate (đổi đồng bộ 2 tầng).
            // [S85 anh Kiệt — D3] "Ngân sách kỳ này" tự nhảy từ "Ngân sách Ban hành lần đầu"
            // (PeWorkItemBudget.ProInitialAmount per Dự-án×Hạng-mục). Effective = BudgetPeriodAmount
            // ?? ProInitialAmount → phiếu chưa nhập tay row3 nhưng đã có ban-hành PRO vẫn gửi-duyệt được
            // (FE missingForApproval mirror cùng fallback — đổi đồng bộ 2 tầng).
            // [Mig 58 item 2 CCM] Ngân sách = 0 HỢP LỆ (SSG ngân sách 0 vẫn gửi duyệt được). Chỉ
            // fallback ProInitial khi drafter CHƯA nhập (null) — KHÔNG override số 0 cố ý thành
            // ProInitial rồi vô tình null-block. Block cuối chỉ fire khi null; 0 (cố ý) đi qua.
            decimal? effectiveBudget = evaluation.BudgetPeriodAmount;
            if (effectiveBudget is null && evaluation.WorkItemId is Guid wiId)
            {
                effectiveBudget = await db.PeWorkItemBudgets.AsNoTracking()
                    .Where(b => b.ProjectId == evaluation.ProjectId && b.WorkItemId == wiId)
                    .Select(b => b.ProInitialAmount)
                    .FirstOrDefaultAsync(ct);
            }
            if (effectiveBudget is null)
            {
                missing.Add("chưa nhập Ngân sách kỳ này");
            }
            var hasComparisonFile = await db.PurchaseEvaluationAttachments.AsNoTracking()
                .AnyAsync(a => a.PurchaseEvaluationId == evaluation.Id
                    && a.PurchaseEvaluationSupplierId == null, ct);
            if (!hasComparisonFile)
                missing.Add("chưa đính kèm Bảng so sánh");

            if (missing.Count > 0)
            {
                throw new ConflictException(
                    "Chưa đủ thông tin mục 3 \"Đơn vị NCC/TP được chọn\" để gửi duyệt: "
                    + string.Join(" · ", missing) + ".");
            }

            // [S83 bug fix] Guard require-workflow: KHÔNG cho gửi duyệt phiếu chưa
            // pin quy trình duyệt V2 (ApprovalWorkflowId null → kẹt nhánh V1-legacy:
            // không Bước/Cấp, banner "quy trình cũ", không route duyệt được). FE bắt
            // buộc lúc create (canSubmit) nhưng BE phải enforce defense-in-depth
            // (validate FE-only = lỗ hổng, họ gotcha #44). Đặt SAU Section-3
            // (orthogonal — ưu tiên báo thiếu data) NHƯNG TRƯỚC mutate phase. Data
            // V1 cũ đã wipe S59 → chặn V1-submit an toàn (mọi phiếu mới = V2).
            if (evaluation.ApprovalWorkflowId is null)
                throw new ConflictException(
                    "Phiếu chưa chọn quy trình duyệt — không thể gửi duyệt. "
                    + "Vui lòng mở Sửa, chọn \"Quy trình duyệt\" rồi gửi lại.");

            evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet;

            // Mig 31 (S23 t1 Plan K) — F2 Drafter-skip-from-Nháp semantic deprecated.
            // skipToFinal param 8th repurpose sang Approver scope ChoDuyet (xem
            // ApproveV2Async branch). Drafter SUBMIT chạy normal init pointer Step 0
            // Cấp 1, ignore skipToFinal flag.
            evaluation.CurrentWorkflowStepIndex = 0;
            // Chỉ init levelOrder=1 nếu pin schema V2 (ApprovalWorkflowId set).
            evaluation.CurrentApprovalLevelOrder = evaluation.ApprovalWorkflowId is not null ? 1 : null;
            evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
            await LogTransitionAsync(evaluation, fromPhase, PurchaseEvaluationPhase.ChoDuyet, actorUserId, decision, comment, ct);

            // UAT S60 (anh Kiệt) — bypass khi NGƯỜI SOẠN nằm trong chuỗi duyệt
            // bước đầu (vd Trưởng phòng tự tạo phiếu → không bắt NV duyệt lại
            // + không bắt TP tự bấm duyệt phiếu mình). V2-only. Chạy SAU
            // LogTransition để Changelog đúng thứ tự: "Chuyển phase" → "Bỏ qua
            // Cấp...". TraLai-resubmit đi cùng branch (reset Cấp 1) → tự áp lại.
            if (evaluation.ApprovalWorkflowId is Guid submitAwId)
                await ApplyDrafterBypassOnSubmitAsync(evaluation, submitAwId, ct);

            await db.SaveChangesAsync(ct);
            return;
        }

        // ===== APPROVE STEP (advance pointer trong ChoDuyet) =====
        if (fromPhase == PurchaseEvaluationPhase.ChoDuyet && decision == ApprovalDecision.Approve)
        {
            // Branch: V2 schema mới (ApprovalWorkflowId pin) hay V1 legacy
            // (WorkflowDefinitionId pin Mig 21).
            // Mig 31 (S23 t1 Plan K) — skipToFinal repurpose Approver scope ChoDuyet.
            // V2 path nhận flag, V1 legacy throw nếu non-admin gọi skipToFinal=true.
            if (evaluation.ApprovalWorkflowId is Guid awId)
            {
                await ApproveV2Async(evaluation, awId, actorUserId, actorRoles, isAdmin, isSystem, comment, skipToFinal, finalizeByCcmDelegation, approvedPriceAmount, approvedPriceSource, approvedPriceMaxAmount, applyLevelFinalize, ct);
            }
            else
            {
                if (skipToFinal && !isAdmin && !isSystem)
                    throw new ConflictException(
                        "skipToFinal chỉ hỗ trợ phiếu V2 (ApprovalWorkflowsV2). Phiếu V1 legacy không có per-Approver-slot flag.");
                await ApproveV1LegacyAsync(evaluation, actorUserId, actorRoles, isAdmin, isSystem, comment, ct);
            }
            await db.SaveChangesAsync(ct);
            return;
        }

        // Admin manual override (vd test cứng phase)
        if (isAdmin)
        {
            // [S133 site 5 — reviewer catch] Override thẳng → DaDuyet cũng PHẢI chốt snapshot NS
            // (RULE helper; assignment qua BIẾN targetPhase nên literal-grep 4-site không thấy — #81).
            if (targetPhase == PurchaseEvaluationPhase.DaDuyet)
                await ApplyBudgetSnapshotOnFinalizeAsync(evaluation, ct);
            evaluation.Phase = targetPhase;
            evaluation.SlaDeadline = targetPhase == PurchaseEvaluationPhase.ChoDuyet
                ? dateTime.UtcNow.AddDays(7) : null;
            await LogTransitionAsync(evaluation, fromPhase, targetPhase, actorUserId, decision, comment, ct);
            await db.SaveChangesAsync(ct);
            return;
        }

        throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ.");
    }

    // ===== V2 actor scope guard cho Reject (UAT S22+1) =====
    // Mirror FE PeWorkflowPanel.actorInV2Level — chỉ Approver Cấp hiện tại (V2
    // schema) hoặc Admin được Reject (Trả lại / Từ chối) phiếu V2. Defense-
    // in-depth: UI đã disable cả 3 button nhưng BE chặn request forge non-
    // approver gọi PATCH direct.
    //
    // Skip guard (silent return) khi điều kiện chưa đủ để check:
    //   - isAdmin = true → bypass
    //   - V1 schema (ApprovalWorkflowId null) → legacy behavior unchanged
    //   - Phase != ChoDuyet → reject từ phase khác (vd auto job system)
    //   - actorUserId null → system caller (vd cron)
    //   - Pointer chưa init (CurrentWorkflowStepIndex / CurrentApprovalLevelOrder null)
    //     → workflow chưa start, guard không relevant
    private async Task EnsureCanRejectV2Async(
        PurchaseEvaluation evaluation, Guid? actorUserId, bool isAdmin, CancellationToken ct)
    {
        if (isAdmin) return;
        if (evaluation.ApprovalWorkflowId is not Guid awId) return;
        if (evaluation.Phase != PurchaseEvaluationPhase.ChoDuyet) return;
        if (actorUserId is not Guid actorId) return;
        if (evaluation.CurrentWorkflowStepIndex is not int csi) return;
        if (evaluation.CurrentApprovalLevelOrder is not int curLvl) return;

        var workflow = await db.ApprovalWorkflows.AsNoTracking()
            .Include(w => w.Steps).ThenInclude(s => s.Levels)
            .FirstOrDefaultAsync(w => w.Id == awId, ct);
        if (workflow is null) return;  // schema lỗi — silent skip để LogTransition catch

        var stepsOrdered = workflow.Steps.OrderBy(s => s.Order).ToList();
        if (csi < 0 || csi >= stepsOrdered.Count) return;  // pointer corrupt
        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, mỗi row 1 NV. Filter ngay
        // trong FirstOrDefault thay vì compare sau (tránh chọn nhầm row đầu DB).
        var currentLevel = step.Levels.FirstOrDefault(l => l.Order == curLvl && l.ApproverUserId == actorId);
        if (currentLevel is null)
            throw new ForbiddenException(
                "Không phải lượt bạn — chỉ NV Cấp duyệt hiện tại mới được Trả lại / Từ chối phiếu.");
    }

    // ===== F1 (Mig 28 — S21 t4) — Apply Return Mode =====
    // Switch theo effectiveMode → set Phase + pointer. 3 mode đầu giữ ChoDuyet
    // (peer review chain). Mode Drafter set Phase=TraLai như S17.
    // Validate workflow.Allow* flag match mode → throw nếu disabled.
    // Return summary text để chèn vào comment changelog (audit trail).
    private async Task<string> ApplyReturnModeAsync(
        PurchaseEvaluation evaluation,
        WorkflowReturnMode mode,
        Guid? returnTargetUserId,
        Guid? actorUserId,
        bool isAdmin,
        CancellationToken ct)
    {
        // Mig 29 (S21 t5) refactor: Allow* flag đã move xuống ApprovalWorkflowLevel
        // (per-slot Approver). Cần load workflow Steps+Levels để lấy Level hiện
        // tại (curStepIdx + curLevel).
        if (evaluation.ApprovalWorkflowId is not Guid awId)
        {
            // Phiếu V1 legacy không có Allow* → fallback Drafter (S17 behavior).
            evaluation.Phase = PurchaseEvaluationPhase.TraLai;
            evaluation.CurrentWorkflowStepIndex = null;
            evaluation.CurrentApprovalLevelOrder = null;
            evaluation.SlaDeadline = null;
            return mode == WorkflowReturnMode.Drafter
                ? "Trả về Người soạn thảo"
                : $"Trả về Người soạn thảo (fallback — phiếu V1 không hỗ trợ mode '{mode}')";
        }

        var workflow = await db.ApprovalWorkflows
            .Include(w => w.Steps).ThenInclude(s => s.Levels)
            .FirstOrDefaultAsync(w => w.Id == awId, ct)
            ?? throw new ConflictException("Workflow không tồn tại.");

        var stepsOrdered = workflow.Steps.OrderBy(s => s.Order).ToList();

        // Resolve Level hiện tại (slot Approver đang duyệt) — đọc Allow* từ slot
        // này. Required cho mọi mode (kể cả Drafter — Approver hiện tại quyết
        // định mode Trả lại theo flag riêng của slot).
        //
        // Plan O S23 t5 — Per-NV lookup site MUST discriminate ApproverUserId.
        // Schema Mig 29 OR-of-N: N rows cùng Order. Non-admin actor BẮT BUỘC
        // match slot để đọc Allow flag riêng. Admin bypass validation line 252
        // bất kể currentLevel null (mode logic switch phía dưới không dùng
        // currentLevel object — chỉ dùng curStepIdx + curLevel int values).
        ApprovalWorkflowLevel? currentLevel = null;
        if (evaluation.CurrentWorkflowStepIndex is int csi && csi >= 0 && csi < stepsOrdered.Count)
        {
            var step = stepsOrdered[csi];
            currentLevel = step.Levels.FirstOrDefault(l =>
                l.Order == evaluation.CurrentApprovalLevelOrder
                && l.ApproverUserId == actorUserId);
        }

        // Validate Allow* flag từ Level slot hiện tại (Admin bypass)
        if (!isAdmin && currentLevel is not null)
        {
            var allowed = mode switch
            {
                WorkflowReturnMode.OneLevel => currentLevel.AllowReturnOneLevel,
                WorkflowReturnMode.OneStep => currentLevel.AllowReturnOneStep,
                WorkflowReturnMode.Assignee => currentLevel.AllowReturnToAssignee,
                WorkflowReturnMode.Drafter => currentLevel.AllowReturnToDrafter,
                _ => false,
            };
            if (!allowed)
                throw new ConflictException(
                    $"Cấp Approver hiện tại không bật mode '{mode}'. Liên hệ Admin Designer để config Level slot.");
        }

        var summary = string.Empty;

        // Mode Drafter — Session 17 default (Phase=TraLai clear pointer).
        // Plan AB S25 — KHÔNG return early, fallthrough vào log block dưới cuối để
        // Changelog uniform 4 mode (Bug 2 fix: trước chỉ TransitionAsync log phase
        // transition generic, không log return mode detail → FE History tab miss).
        if (mode == WorkflowReturnMode.Drafter)
        {
            evaluation.Phase = PurchaseEvaluationPhase.TraLai;
            evaluation.CurrentWorkflowStepIndex = null;
            evaluation.CurrentApprovalLevelOrder = null;
            evaluation.SlaDeadline = null;
            summary = "Trả về Người soạn thảo";
        }
        else
        {
            // 3 mode còn lại — yêu cầu pointer hợp lệ
            if (evaluation.CurrentWorkflowStepIndex is not int curStepIdx
                || evaluation.CurrentApprovalLevelOrder is not int curLevel)
                throw new ConflictException(
                    $"Mode '{mode}' yêu cầu phiếu đang ChoDuyet + pointer init. " +
                    $"State hiện tại: Step={evaluation.CurrentWorkflowStepIndex}, Level={evaluation.CurrentApprovalLevelOrder}.");

            switch (mode)
            {
                case WorkflowReturnMode.OneLevel:
                    // Lùi 1 Cấp trong cùng Step. Nếu đang Cấp 1 → lùi sang Bước trước
                    // Cấp cuối. Nếu đang Bước 1 Cấp 1 → reset về (0, 1) giữ ChoDuyet
                    // (Plan M S23 t3 — KHÔNG fallback Drafter, phiếu giữ "đang duyệt").
                    if (curLevel > 1)
                    {
                        evaluation.CurrentApprovalLevelOrder = curLevel - 1;
                        summary = $"Trả về Cấp {curLevel - 1} (cùng Bước {stepsOrdered[curStepIdx].Order})";
                    }
                    else if (curStepIdx > 0)
                    {
                        var prevStep = stepsOrdered[curStepIdx - 1];
                        var prevMaxLevel = prevStep.Levels.OrderBy(l => l.Order).Last().Order;
                        evaluation.CurrentWorkflowStepIndex = curStepIdx - 1;
                        evaluation.CurrentApprovalLevelOrder = prevMaxLevel;
                        summary = $"Trả về Bước {prevStep.Order} Cấp {prevMaxLevel} (Bước trước)";
                    }
                    else
                    {
                        // Bước 1 Cấp 1 → reset về (0, 1) giữ Phase=ChoDuyet (no-op effective
                        // — Approver A hiện tại). Audit log rõ "không lùi được". SLA reset
                        // dưới cuối hàm cho approver giữ nguyên.
                        evaluation.CurrentWorkflowStepIndex = 0;
                        evaluation.CurrentApprovalLevelOrder = 1;
                        summary = "Action 'Trả lại 1 Cấp' không lùi được — phiếu reset về Approver Bước 1 Cấp 1";
                    }
                    break;

                case WorkflowReturnMode.OneStep:
                    // Lùi sang Bước trước, set Level = max của Bước đó.
                    if (curStepIdx > 0)
                    {
                        var prevStep = stepsOrdered[curStepIdx - 1];
                        var prevMaxLevel = prevStep.Levels.OrderBy(l => l.Order).Last().Order;
                        evaluation.CurrentWorkflowStepIndex = curStepIdx - 1;
                        evaluation.CurrentApprovalLevelOrder = prevMaxLevel;
                        summary = $"Trả về Bước {prevStep.Order} Cấp {prevMaxLevel}";
                    }
                    else
                    {
                        // Bước 1 → reset về (0, 1) giữ Phase=ChoDuyet (Plan M S23 t3 —
                        // KHÔNG fallback Drafter, phiếu giữ "đang duyệt").
                        evaluation.CurrentWorkflowStepIndex = 0;
                        evaluation.CurrentApprovalLevelOrder = 1;
                        summary = "Action 'Trả lại 1 Bước' không lùi được — phiếu reset về Approver Bước 1 Cấp 1";
                    }
                    break;

                case WorkflowReturnMode.Assignee:
                    if (returnTargetUserId is not Guid targetUid)
                        throw new ConflictException("returnTargetUserId yêu cầu khi mode=Assignee.");
                    var foundStepIdx = -1;
                    int foundLevel = -1;
                    string? foundStepName = null;
                    for (int si = 0; si < stepsOrdered.Count; si++)
                    {
                        var match = stepsOrdered[si].Levels
                            .FirstOrDefault(l => l.ApproverUserId == targetUid);
                        if (match is not null)
                        {
                            foundStepIdx = si;
                            foundLevel = match.Order;
                            foundStepName = stepsOrdered[si].Name;
                            break;
                        }
                    }
                    if (foundStepIdx < 0)
                        throw new ConflictException(
                            "Không tìm thấy người chỉ định trong workflow. " +
                            "Chỉ pick từ list NV đã duyệt trước đó (PeLevelOpinions).");
                    evaluation.CurrentWorkflowStepIndex = foundStepIdx;
                    evaluation.CurrentApprovalLevelOrder = foundLevel;
                    summary = $"Trả về Người chỉ định — Bước {stepsOrdered[foundStepIdx].Order} ({foundStepName}) Cấp {foundLevel}";
                    break;
            }

            // 3 mode trên đều giữ Phase=ChoDuyet — reset SLA cho approver mới.
            evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
        }

        // Plan AB S25 Bug 2 fix — single Changelog.Add() cover all 4 mode uniform.
        // EntityType=Workflow + Action=Update để FE History filter discriminate qua
        // summary substring "Trả lại" (Workflow entity + summary != phase-transition
        // pattern "Phase X → Y" — distinct từ TransitionAsync caller line 100).
        // KHÔNG SaveChangesAsync call mới — TransitionAsync caller có downstream save.
        var modeName = mode switch
        {
            WorkflowReturnMode.Drafter => "Người soạn thảo",
            WorkflowReturnMode.OneLevel => "1 Cấp",
            WorkflowReturnMode.OneStep => "1 Bước",
            WorkflowReturnMode.Assignee => "Người chỉ định",
            _ => mode.ToString(),
        };
        string? actorName = null;
        if (actorUserId is Guid actorUid)
        {
            var actor = await userManager.FindByIdAsync(actorUid.ToString());
            actorName = actor?.FullName ?? actor?.Email;
        }
        db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
        {
            PurchaseEvaluationId = evaluation.Id,
            EntityType = PurchaseEvaluationEntityType.Workflow,
            Action = ChangelogAction.Update,
            PhaseAtChange = evaluation.Phase,
            UserId = actorUserId,
            UserName = actorName ?? "Hệ thống",
            Summary = $"Trả lại ({modeName}): {summary}",
        });

        return summary;
    }

    // ===== Drafter-in-chain bypass khi gửi duyệt (V2-only) =====
    // Anh Kiệt: "Trưởng phòng tạo thì bypass — không cần nhân viên cấp dưới duyệt lại."
    // A1 (close-review wf_7df56b89-7bb — owner anh Kiệt): chỉ xét BƯỚC ĐẦU (= phòng
    // soạn). Người soạn (DrafterUserId — KHÔNG phải actor submit; Admin gửi hộ vẫn
    // tính theo người soạn) KÝ TAY ô cấp mình; chỉ bypass các cấp NV DƯỚI THUẦN
    // (Order < minOwn, minOwn = cấp THẤP NHẤT người soạn có slot). Pointer DỪNG tại
    // minOwn → người soạn tự duyệt Cấp minOwn qua ApproveV2Async (KHÔNG auto-terminal,
    // KHÔNG auto nhảy Bước — người soạn phải ký thật để "bám đúng quy trình").
    // Non-contiguous (người soạn Cấp 1 + Cấp 3, NV khác ở Cấp 2): minOwn=1 → bypass
    // rỗng, toàn bộ chuỗi ký tay đúng thứ tự (KHÔNG auto qua Cấp 2 của người khác).
    //
    // Audit trail: mỗi cấp NV bị bypass → 1 Approval row (Decision=AutoApprove, "bỏ
    // qua") + 1 dòng Changelog tóm tắt pointer nhảy. KHÔNG tự ký hộ opinion Section 5
    // cho bất kỳ ai (kể cả người soạn — họ ký tay). Idempotent khi TraLai-resubmit
    // (approval row add thêm = lịch sử 2 lần gửi — đúng semantics history).
    // Fail-soft: workflow/step/level lỗi cấu trúc → return im lặng (submit vẫn hợp lệ
    // pointer Cấp 1, ApproveV2Async sẽ báo lỗi cấu trúc khi duyệt).
    private async Task ApplyDrafterBypassOnSubmitAsync(
        PurchaseEvaluation evaluation, Guid awId, CancellationToken ct)
    {
        if (evaluation.DrafterUserId is not Guid drafterId) return;

        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 null) return;

        var steps = aw.Steps.OrderBy(s => s.Order).ToList();
        if (steps.Count == 0) return;

        var firstStep = steps[0];
        var levelGroups = firstStep.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList();
        if (levelGroups.Count == 0) return;

        var drafterSlots = firstStep.Levels.Where(l => l.ApproverUserId == drafterId).ToList();
        if (drafterSlots.Count == 0) return; // drafter ngoài chuỗi bước đầu — flow thường

        // A1 (close-review wf_7df56b89-7bb — owner anh Kiệt): người soạn KÝ TAY ô cấp
        // mình, chỉ bypass các cấp NV DƯỚI THUẦN (Order < minOwn). minOwn = cấp THẤP
        // NHẤT người soạn có slot trong bước đầu.
        var minOwn = drafterSlots.Min(l => l.Order);
        var drafterFullName = await ResolveActorFullNameAsync(drafterId, isSystem: false, ct);
        var bypassedOrders = levelGroups.Select(g => g.Key).Where(o => o < minOwn).OrderBy(o => o).ToList();

        // Mọi order < minOwn là cấp NV cấp dưới THUẦN (người soạn KHÔNG có slot ở đây)
        // → chỉ ghi vết audit "bỏ qua", KHÔNG tự ký hộ opinion (A1 — không tự ký hộ ai;
        // người soạn tự ký tay ô cấp mình qua ApproveV2Async).
        foreach (var order in bypassedOrders)
        {
            db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
            {
                PurchaseEvaluationId = evaluation.Id,
                FromPhase = PurchaseEvaluationPhase.ChoDuyet,
                ToPhase = PurchaseEvaluationPhase.ChoDuyet,
                ApproverUserId = drafterId,
                Decision = ApprovalDecision.AutoApprove,
                Comment = $"[Bước 1 — Cấp {order}] (bỏ qua — phiếu do người duyệt cấp cao hơn cùng phòng soạn)",
                ApprovedAt = dateTime.UtcNow,
            });
        }

        // A1 — pointer DỪNG tại minOwn (cấp thấp nhất của người soạn) → người soạn TỰ KÝ
        // Cấp minOwn qua ApproveV2Async (gate match-approver pass vì họ ∈ ApproverUserId).
        // KHÔNG advance sang Bước sau, KHÔNG terminal DaDuyet khi gửi — người soạn phải ký
        // thật để "bám đúng quy trình".
        //   - minOwn == 1 (người soạn là Cấp 1): bypassedOrders rỗng + block này skip →
        //     pointer giữ (0,1) đã set ở nhánh submit → người soạn ký Cấp 1 bình thường.
        //   - Non-contiguous (người soạn ở Cấp 1 + Cấp 3, NV khác ở Cấp 2): minOwn=1 →
        //     bypass rỗng; người soạn ký Cấp 1 tay → NV Cấp 2 ký bình thường → người soạn
        //     ký Cấp 3 tay. BÁM ĐÚNG quy trình, KHÔNG auto nhảy qua Cấp 2 của người khác.
        if (minOwn > 1)
        {
            evaluation.CurrentApprovalLevelOrder = minOwn;
            await LogTransitionAsync(evaluation,
                PurchaseEvaluationPhase.ChoDuyet, PurchaseEvaluationPhase.ChoDuyet,
                drafterId, ApprovalDecision.AutoApprove,
                $"Bỏ qua Cấp 1..{minOwn - 1} Bước 1 (cấp NV dưới) — chờ người soạn {drafterFullName} ký Cấp {minOwn}",
                ct);
        }
    }

    // ===== V2 schema (Mig 22-24) — iterate ApprovalWorkflowSteps + Levels =====
    // Mig 31 (S23 t1 Plan K) — `skipToFinal` 8th param: F2 Approver scope ChoDuyet.
    // Admin opt-in flag per slot tại matchingLevel.AllowApproverSkipToFinal.
    private async Task ApproveV2Async(
        PurchaseEvaluation evaluation,
        Guid awId,
        Guid? actorUserId,
        IReadOnlyList<string> actorRoles,
        bool isAdmin,
        bool isSystem,
        string? comment,
        bool skipToFinal,
        bool finalizeByCcmDelegation,
        decimal? approvedPriceAmount,
        string? approvedPriceSource,
        decimal? approvedPriceMaxAmount,
        bool applyLevelFinalize,
        CancellationToken ct)
    {
        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)
            ?? throw new ConflictException($"ApprovalWorkflow {awId} không tồn tại.");

        var steps = aw.Steps.OrderBy(s => s.Order).ToList();
        if (steps.Count == 0)
            throw new ConflictException("Quy trình chưa có bước nào.");

        var currentIdx = evaluation.CurrentWorkflowStepIndex ?? 0;
        if (currentIdx < 0 || currentIdx >= steps.Count)
            throw new ConflictException($"CurrentWorkflowStepIndex={currentIdx} không hợp lệ (max={steps.Count - 1}).");

        var currentLevelOrder = evaluation.CurrentApprovalLevelOrder ?? 1;
        var currentStep = steps[currentIdx];

        // Group levels by Order = Cấp. Mỗi Cấp có N approvers (OR-of-N).
        var levelGroups = currentStep.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList();
        var maxLevelOrder = levelGroups.Count == 0 ? 0 : levelGroups.Max(g => g.Key);
        if (currentLevelOrder < 1 || currentLevelOrder > maxLevelOrder)
            throw new ConflictException($"CurrentApprovalLevelOrder={currentLevelOrder} không hợp lệ (max={maxLevelOrder}).");

        var pendingLevelGroup = levelGroups.FirstOrDefault(g => g.Key == currentLevelOrder)
            ?? throw new ConflictException($"Bước {currentIdx + 1} không có cấp {currentLevelOrder}.");

        // Match approver: actor.Id ∈ pendingLevelGroup.ApproverUserId. Admin bypass.
        if (!isAdmin && !isSystem)
        {
            if (actorUserId is null)
                throw new ForbiddenException("Không xác định được approver.");
            var allowedUserIds = pendingLevelGroup.Select(l => l.ApproverUserId).ToHashSet();
            if (!allowedUserIds.Contains(actorUserId.Value))
            {
                // [S155 F-6] Trước đây in THẲNG GUID (`string.Join(", ", allowedUserIds)`)
                // dù biến đã tên là `names` — người dùng cuối đọc được một dãy GUID và
                // không hiểu phải nhờ AI duyệt. Nay resolve sang tên thật; fallback về
                // GUID nếu không tra được để thông điệp không bao giờ rỗng.
                var allowedIdList = allowedUserIds.ToList();
                var resolved = await userManager.Users.AsNoTracking()
                    .Where(u => allowedIdList.Contains(u.Id))
                    .Select(u => u.FullName ?? u.Email)
                    .ToListAsync(ct);
                var names = resolved.Count > 0
                    ? string.Join(", ", resolved)
                    : string.Join(", ", allowedIdList);
                throw new ForbiddenException(
                    $"Bước {currentIdx + 1} ({currentStep.Name}) — Cấp {currentLevelOrder}: bạn không có trong danh sách NV duyệt ({names}).");
            }
        }

        // Log approval. Plan AC S25 Bug 3b — enrich comment với prefix
        // "[Duyệt vượt cấp]" khi skipToFinal=true để FE Lịch sử duyệt panel
        // phân biệt rõ event vượt cấp với approve thường.
        var skipPrefix = skipToFinal ? "[Duyệt vượt cấp tới Cấp cuối] " : "";
        db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
        {
            PurchaseEvaluationId = evaluation.Id,
            FromPhase = evaluation.Phase,
            ToPhase = evaluation.Phase,
            ApproverUserId = actorUserId,
            Decision = ApprovalDecision.Approve,
            Comment = $"{skipPrefix}[Bước {currentIdx + 1} — Cấp {currentLevelOrder}] {comment ?? ""}".Trim(),
            ApprovedAt = dateTime.UtcNow,
        });

        // Mig 26 (Session 19) — UPSERT opinion vào row Level chính chủ. Section 5
        // FE render dynamic theo flow.steps[].levels[]. Q1=1B chốt: comment khi
        // duyệt auto sync sang Section 5 (read-only summary). Empty comment →
        // "(duyệt — không ý kiến)" placeholder Q4 bonus.
        // Multi-NV cùng Cấp (OR-of-N): match level theo ApproverUserId. Admin
        // override → fallback first level group; FE detect SignedByUserId !==
        // Level.ApproverUserId → banner "Admin duyệt thay".
        var matchingLevel = pendingLevelGroup.FirstOrDefault(l => actorUserId.HasValue && l.ApproverUserId == actorUserId.Value)
                            ?? pendingLevelGroup.First();
        var actorFullName = await ResolveActorFullNameAsync(actorUserId, isSystem, ct);
        var existingOpinion = await db.PurchaseEvaluationLevelOpinions
            .FirstOrDefaultAsync(o => o.PurchaseEvaluationId == evaluation.Id
                && o.ApprovalWorkflowLevelId == matchingLevel.Id, ct);
        var normalizedComment = string.IsNullOrWhiteSpace(comment)
            ? "(duyệt — không ý kiến)"
            : comment.Trim();
        if (existingOpinion is null)
        {
            db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
            {
                PurchaseEvaluationId = evaluation.Id,
                ApprovalWorkflowLevelId = matchingLevel.Id,
                Comment = normalizedComment,
                SignedAt = dateTime.UtcNow,
                SignedByUserId = actorUserId ?? Guid.Empty,
                SignedByFullName = actorFullName,
            });
        }
        else
        {
            // CHANGE 4 (close-review wf_7df56b89-7bb) — Opinion history: phiếu bị Trả lại
            // rồi NV cùng Cấp KÝ LẠI → ghi ý kiến CŨ vào Changelog TRƯỚC khi ghi đè (bảo
            // toàn lịch sử; KHÔNG migration / bảng / endpoint mới — edit diễn ra in-flow
            // qua re-sign). Chỉ log khi ý kiến cũ non-empty (lần ký ĐẦU đi nhánh Add ở
            // trên vì existingOpinion null). Interpolation đọc snapshot CŨ ngay tại đây,
            // TRƯỚC 4 dòng ghi đè bên dưới → giá trị đã "đóng băng" vào ContextNote.
            if (!string.IsNullOrWhiteSpace(existingOpinion.Comment))
            {
                db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
                {
                    PurchaseEvaluationId = evaluation.Id,
                    EntityType = PurchaseEvaluationEntityType.Workflow,
                    EntityId = matchingLevel.Id,
                    Action = ChangelogAction.Update,
                    PhaseAtChange = evaluation.Phase,
                    UserId = actorUserId,
                    UserName = actorFullName,
                    Summary = $"Ghi đè ý kiến duyệt (Bước {currentIdx + 1} — Cấp {currentLevelOrder}) — lưu bản cũ vào lịch sử",
                    ContextNote = $"[Ý kiến cũ — {existingOpinion.SignedByFullName} lúc {existingOpinion.SignedAt:dd/MM/yyyy HH:mm}] {existingOpinion.Comment}",
                });
            }

            existingOpinion.Comment = normalizedComment;
            existingOpinion.SignedAt = dateTime.UtcNow;
            existingOpinion.SignedByUserId = actorUserId ?? Guid.Empty;
            existingOpinion.SignedByFullName = actorFullName;
        }

        // Mig 31 (S23 t1 Plan K) — F2 Approver scope ChoDuyet: skip thẳng tới
        // NV cuối (CEO / last approver). Admin opt-in per slot tại
        // matchingLevel.AllowApproverSkipToFinal. Khi Approver tick checkbox
        // "Duyệt thẳng Cấp cuối" + admin enable flag → bỏ qua mọi Bước/Cấp
        // TRUNG GIAN còn lại, advance pointer tới Bước cuối + Cấp cuối (max).
        // Phase GIỮ NGUYÊN ChoDuyet — NV cuối vẫn cần ký thật để tiến DaDuyet
        // (KHÔNG auto-approve terminal). Mirror F3+F4 admin opt-in per-slot
        // pattern (Mig 29 + Mig 30) reinforced 3× cumulative. Non-admin + flag
        // off → ConflictException. Admin bypass flag.
        //
        // S23 t2 spec fix: bro UAT feedback Plan K K2 implement SAI semantic
        // (set Phase=DaDuyet terminal auto-approve), refactor sang advance
        // pointer tới Cấp cuối (CEO duyệt thật).
        if (skipToFinal)
        {
            if (!isAdmin && !isSystem && !matchingLevel.AllowApproverSkipToFinal)
            {
                throw new ConflictException(
                    $"Cấp Approver hiện tại (Bước {currentIdx + 1} Cấp {currentLevelOrder}) " +
                    "chưa được phép duyệt thẳng Cấp cuối. Admin phải tick checkbox " +
                    "'Duyệt thẳng Cấp cuối' trong Workflow Designer cho slot này.");
            }

            // Resolve last Step + last Level (max LevelOrder trong Step cuối)
            var lastStepIdx = steps.Count - 1;
            var lastStep = steps[lastStepIdx];
            var lastLevelGroups = lastStep.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList();
            var lastLevelMaxOrder = lastLevelGroups.Count == 0 ? 1 : lastLevelGroups.Max(g => g.Key);

            // Guard: nếu actor đã ở Cấp cuối Bước cuối thì skipToFinal = no-op
            // → fall through advance logic bên dưới (normal advance → DaDuyet).
            if (currentIdx == lastStepIdx && currentLevelOrder == lastLevelMaxOrder)
            {
                // No-op skip: actor đã ở slot cuối — fall through normal advance
                // (sẽ hit branch `nextIdx >= steps.Count` → DaDuyet đúng).
            }
            else
            {
                // Advance pointer tới Bước cuối + Cấp cuối. Phase giữ ChoDuyet.
                // CEO/NV cuối thấy phiếu đang chờ duyệt + opinion của actor vừa
                // ghi sẵn → duyệt cuối để approve DaDuyet thật.
                evaluation.CurrentWorkflowStepIndex = lastStepIdx;
                evaluation.CurrentApprovalLevelOrder = lastLevelMaxOrder;
                evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
                await LogTransitionAsync(
                    evaluation,
                    PurchaseEvaluationPhase.ChoDuyet,
                    PurchaseEvaluationPhase.ChoDuyet,
                    actorUserId,
                    ApprovalDecision.Approve,
                    $"[Approver skip thẳng tới Bước {lastStepIdx + 1} Cấp {lastLevelMaxOrder} (NV cuối) — bỏ qua các Bước/Cấp trung gian] {comment ?? ""}".Trim(),
                    ct);
                return;
            }
        }

        // [Mig 54 2026-06-18 — anh Kiệt FDC] CCM duyệt-DONE miễn CEO — ĐỔI TỪ AUTO (S69)
        // SANG Ô-TÍCH-TAY (③). Trước S69: gói < ngưỡng + CCM duyệt = tự DaDuyet (im lặng).
        // NAY: CCM phải CHỦ ĐỘNG tích "Duyệt done, miễn CEO" (finalizeByCcmDelegation) thì
        // mới done — an toàn hơn, KHÔNG vô tình bỏ CEO. Điều kiện uỷ-quyền (fail-closed):
        // workflow đặt CeoApprovalThreshold + actor role CostControl + giá gói
        // (winnerQuoteTotal = SUM báo giá NCC được chọn) < ngưỡng. Tích mà KHÔNG đủ điều
        // kiện → Conflict/Forbidden. KHÔNG tích → bỏ block, advance bình thường lên CEO.
        // Opinion + approval row đã ghi ở trên → finalize giữ đủ vết. ① giá chốt: ApplyApprovedPriceOnFinalize.
        // [Mig 58 2026-06-24 — CCM/anh Kiệt FDC item 4] Cấp được cấu hình KẾT THÚC trong Quy
        // trình duyệt (admin bật AllowApproverFinalize cho slot, vd Cấp anh Chương). Duyệt tới
        // cấp này = TỰ kết thúc (DaDuyet), bỏ qua MỌI Bước/Cấp còn lại (kể cả CEO) — quy-trình-
        // driven, KHÔNG cần ô-tích runtime (anh Kiệt chốt 2026-06-24 "cho bật trong quy trình
        // hay hơn"). KHÔNG threshold/role-gate (admin chỉ-định slot = đủ thẩm quyền). ① giá chốt:
        // người duyệt phải chọn (Conflict nếu thiếu, isSystem miễn). Đặt SAU skipToFinal, TRƯỚC
        // ccm-delegation + advance.
        // [S96 2026-07-01 — anh Kiệt FDC] Cấp cấu hình KẾT THÚC (AllowApproverFinalize) NAY là
        // OPT-OUT: người duyệt BỎ tick "Cấp này KẾT THÚC quy trình" (applyLevelFinalize=false) →
        // KHÔNG finalize, rơi xuống advance bình thường → trình tiếp CEO/cấp sau. Đảo S89 (2026-06-24
        // auto-finalize im lặng) theo phản hồi UAT "cái này option thôi". Default true = giữ auto cũ.
        if (matchingLevel.AllowApproverFinalize && applyLevelFinalize)
        {
            // [S97 anh Kiệt FDC] Đánh dấu RUNTIME: phiếu kết thúc sớm tại cấp finalize (không lên CEO)
            // → EndsBeforeCeo display đọc cờ này thay vì suy từ config (phiếu opt-out lên CEO hiện đúng).
            evaluation.EndedByLevelFinalize = true;
            ApplyApprovedPriceOnFinalize(evaluation, isSystem, approvedPriceAmount, approvedPriceSource, approvedPriceMaxAmount);
            await ApplyBudgetSnapshotOnFinalizeAsync(evaluation, ct);   // [S133] chốt NS trước DaDuyet
            evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
            evaluation.CurrentWorkflowStepIndex = null;
            evaluation.CurrentApprovalLevelOrder = null;
            evaluation.SlaDeadline = null;
            await LogTransitionAsync(
                evaluation,
                PurchaseEvaluationPhase.ChoDuyet,
                PurchaseEvaluationPhase.DaDuyet,
                actorUserId,
                ApprovalDecision.Approve,
                $"[Duyệt KẾT THÚC tại Bước {currentIdx + 1} Cấp {currentLevelOrder} — cấu hình quy trình, không trình CEO] {comment ?? ""}".Trim(),
                ct);
            return;
        }

        if (finalizeByCcmDelegation)
        {
            if (aw.CeoApprovalThreshold is not decimal ceoThreshold)
                throw new ConflictException(
                    "Quy trình chưa đặt 'Ngưỡng giá trị gói CEO' — không thể duyệt done miễn CEO.");
            if (!actorRoles.Contains(AppRoles.CostControl))
                throw new ForbiddenException(
                    "Chỉ CCM (Kiểm soát Chi phí) được duyệt done miễn CEO.");

            // [multi-NCC A3(i)[3]] Giá gói = SUM ThanhTien báo giá ĐƯỢC CHỌN (Quote.IsSelected =
            // nguồn-sự-thật) toàn phiếu. CCM duyệt done miễn CEO chỉ khi giá gói < ngưỡng (strict).
            var winnerQuoteTotal = await (
                from q in db.PurchaseEvaluationQuotes.AsNoTracking()
                join d in db.PurchaseEvaluationDetails.AsNoTracking()
                    on q.PurchaseEvaluationDetailId equals d.Id
                where d.PurchaseEvaluationId == evaluation.Id && q.IsSelected
                select (decimal?)q.ThanhTien).SumAsync(ct) ?? 0m;

            if (winnerQuoteTotal >= ceoThreshold)
                throw new ConflictException(
                    $"Giá gói {winnerQuoteTotal:N0}đ ≥ ngưỡng CEO {ceoThreshold:N0}đ — phải trình CEO duyệt, không được duyệt done miễn CEO.");

            ApplyApprovedPriceOnFinalize(evaluation, isSystem, approvedPriceAmount, approvedPriceSource, approvedPriceMaxAmount);
            await ApplyBudgetSnapshotOnFinalizeAsync(evaluation, ct);   // [S133] chốt NS trước DaDuyet
            evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
            evaluation.CurrentWorkflowStepIndex = null;
            evaluation.CurrentApprovalLevelOrder = null;
            evaluation.SlaDeadline = null;
            await LogTransitionAsync(
                evaluation,
                PurchaseEvaluationPhase.ChoDuyet,
                PurchaseEvaluationPhase.DaDuyet,
                actorUserId,
                ApprovalDecision.Approve,
                $"[CCM duyệt done miễn CEO — gói {winnerQuoteTotal:N0}đ < ngưỡng CEO {ceoThreshold:N0}đ] {comment ?? ""}".Trim(),
                ct);
            return;
        }

        // Advance: nếu còn cấp tiếp trong Step → levelOrder++; else → next Step + level 1
        if (currentLevelOrder < maxLevelOrder)
        {
            evaluation.CurrentApprovalLevelOrder = currentLevelOrder + 1;
            evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
            await LogTransitionAsync(evaluation, evaluation.Phase, evaluation.Phase, actorUserId, ApprovalDecision.Approve,
                $"Hoàn tất Cấp {currentLevelOrder}, sang Cấp {currentLevelOrder + 1} cùng Bước {currentIdx + 1}", ct);
            return;
        }

        // Hết cấp trong Step — sang Step kế (Cấp 1)
        var nextIdx = currentIdx + 1;
        if (nextIdx >= steps.Count)
        {
            // All Steps done — terminal DaDuyet. [Mig 54 ①] người duyệt cấp cuối (CEO/NV
            // cuối) chọn giá chốt khi duyệt — bind trước khi sang DaDuyet.
            ApplyApprovedPriceOnFinalize(evaluation, isSystem, approvedPriceAmount, approvedPriceSource, approvedPriceMaxAmount);
            await ApplyBudgetSnapshotOnFinalizeAsync(evaluation, ct);   // [S133] chốt NS trước DaDuyet
            evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
            evaluation.CurrentWorkflowStepIndex = null;
            evaluation.CurrentApprovalLevelOrder = null;
            evaluation.SlaDeadline = null;
            await LogTransitionAsync(evaluation, PurchaseEvaluationPhase.ChoDuyet, PurchaseEvaluationPhase.DaDuyet,
                actorUserId, ApprovalDecision.Approve, comment, ct);
        }
        else
        {
            evaluation.CurrentWorkflowStepIndex = nextIdx;
            evaluation.CurrentApprovalLevelOrder = 1;
            evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
            await LogTransitionAsync(evaluation, evaluation.Phase, evaluation.Phase, actorUserId, ApprovalDecision.Approve,
                $"Hoàn tất Bước {currentIdx + 1}/{steps.Count}, sang Bước {nextIdx + 1} (Cấp 1)", ct);
        }
    }

    // [Mig 54 2026-06-18 — anh Kiệt FDC] Gán giá CHỐT người duyệt chọn (①) tại nhánh
    // DaDuyet. Người duyệt THẬT bắt buộc chọn 1 giá (Conflict nếu thiếu) → đúng ý "CEO
    // phải chọn 1 giá làm giá chốt"; auto-approve hệ thống (SLA, isSystem) MIỄN — không
    // có người để chọn. Source ∈ {Ncc,ProMin,ProMax,Ccm}; amount = snapshot lúc duyệt.
    // [S117 2026-07-14 — anh Kiệt FDC] +"ProMinMax" = duyệt theo KHOẢNG: Amount=Min, maxAmount=Max.
    internal const string ProMinMaxSource = "ProMinMax";
    private static readonly string[] ValidApprovedPriceSources = { "Ncc", "ProMin", "ProMax", "Ccm", ProMinMaxSource };
    private static void ApplyApprovedPriceOnFinalize(
        PurchaseEvaluation evaluation, bool isSystem, decimal? amount, string? source, decimal? maxAmount = null)
    {
        if (amount is null)
        {
            if (!isSystem)
                throw new ConflictException(
                    "Chọn 1 giá chốt (NCC / PRO Min / PRO Max / CCM / cả Min–Max) trước khi duyệt cấp cuối.");
            return; // auto-approve hệ thống (SLA) — không bắt chọn giá
        }
        if (source is null || !ValidApprovedPriceSources.Contains(source))
            throw new ConflictException(
                "Nguồn giá chốt không hợp lệ (phải là NCC / PRO Min / PRO Max / CCM / cả Min–Max).");
        // [S117] "Duyệt theo khoảng" (ProMinMax): bắt buộc có cả Max, Amount=Min, Min ≤ Max.
        if (source == ProMinMaxSource)
        {
            if (maxAmount is null)
                throw new ConflictException("Duyệt theo khoảng (cả Min–Max) phải có cả giá Min và giá Max.");
            if (amount > maxAmount)
                throw new ConflictException("Giá Min không được lớn hơn giá Max khi duyệt theo khoảng.");
        }
        else if (maxAmount is not null)
        {
            // Source đơn mà kèm Max = wire-bug (FE gửi sai) → chặn, giữ bất-biến Max⟺ProMinMax.
            throw new ConflictException("Chỉ nguồn 'cả Min–Max' mới được kèm giá Max.");
        }
        evaluation.ApprovedPriceAmount = amount;
        evaluation.ApprovedPriceSource = source;
        evaluation.ApprovedPriceMaxAmount = source == ProMinMaxSource ? maxAmount : null;
    }

    // [S133 2026-07-17 — anh Kiệt FDC] Chụp (snapshot) bộ số ngân sách gói thầu vào 11 cột
    // ApprovedBudget* trên phiếu TẠI thời điểm chuyển DaDuyet → phiếu ĐÃ DUYỆT bất biến số NS
    // (display-gate `frozen` ở GetPurchaseEvaluationQuery); record PeWorkItemBudgets dùng chung
    // vẫn sửa tự do cho phiếu CHƯA duyệt. LUÔN overwrite + LUÔN set SnapshotAt (= cờ "đã chốt").
    // RULE: MỌI nhánh set Phase=DaDuyet PHẢI gọi helper này ngay TRƯỚC assignment (reviewer grep-check 4 site).
    private async Task ApplyBudgetSnapshotOnFinalizeAsync(PurchaseEvaluation evaluation, CancellationToken ct)
    {
        if (evaluation.WorkItemId is Guid wiKey)
        {
            // 6 cột pair (matrix PRO/CCM) — pair thiếu → null. NAMING: Ccm* = cột CCM
            // (PeWorkItemBudget.InitialAmount/AdjustmentAmount); Pro* = cột PRO.
            var pair = await db.PeWorkItemBudgets.AsNoTracking()
                .FirstOrDefaultAsync(b => b.ProjectId == evaluation.ProjectId && b.WorkItemId == wiKey, ct);
            evaluation.ApprovedBudgetProInitialAmount = pair?.ProInitialAmount;
            evaluation.ApprovedBudgetProAdjustmentAmount = pair?.ProAdjustmentAmount;
            evaluation.ApprovedBudgetProNote = pair?.ProNote;
            evaluation.ApprovedBudgetCcmInitialAmount = pair?.InitialAmount;
            evaluation.ApprovedBudgetCcmAdjustmentAmount = pair?.AdjustmentAmount;
            evaluation.ApprovedBudgetCcmNote = pair?.CcmNote;

            // 4 cột lũy kế qua accumulator (BEHAVIOR y GetPurchaseEvaluationQuery live).
            var supplierRowIds = await db.PurchaseEvaluationSuppliers.AsNoTracking()
                .Where(s => s.PurchaseEvaluationId == evaluation.Id)
                .Select(s => s.Id)
                .ToListAsync(ct);
            var acc = await SolutionErp.Application.PurchaseEvaluations.PeBudgetAccumulator.ComputeAsync(
                db, evaluation.ProjectId, wiKey, evaluation.Id, evaluation.CreatedAt, supplierRowIds, ct);
            evaluation.ApprovedBudgetPrevSubmittedTotal = acc.PrevSubmittedTotal;
            evaluation.ApprovedBudgetPrevSubmittedCount = acc.PrevSubmittedCount;
            evaluation.ApprovedBudgetPrevSelectedTotal = acc.PrevSelectedTotal;
            evaluation.ApprovedBudgetPrevSelectedCount = acc.PrevSelectedCount;

            // [S134 pending-prior] D4 audit-note — nếu finalize phiếu này mà VẪN còn phiếu TRƯỚC
            // cùng gói CHƯA duyệt xong (nháp/đang trình/trả lại) → ghi 1 changelog Action=Update
            // (KHÔNG Transition — caller đã ghi row Transition riêng qua LogTransitionAsync; 2 row
            // Transition = UI hiểu nhầm 2 lần chuyển phase, gotcha #49). KHÔNG SaveChanges (caller lo).
            var pending = await SolutionErp.Application.PurchaseEvaluations.PeBudgetAccumulator.ComputePendingAsync(
                db, evaluation.ProjectId, wiKey, evaluation.Id, evaluation.CreatedAt, ct);
            if (pending.PriorPes.Count > 0)
            {
                static string PendingPhaseLabel(int phase) => phase switch
                {
                    (int)PurchaseEvaluationPhase.DangSoanThao => "Nháp",
                    (int)PurchaseEvaluationPhase.ChoDuyet => "Đã gửi duyệt",
                    (int)PurchaseEvaluationPhase.TraLai => "Trả lại",
                    _ => "Đang duyệt",
                };
                var priorList = string.Join(", ", pending.PriorPes.Select(pr =>
                    $"{pr.MaPhieu ?? "(chưa có mã)"} ({PendingPhaseLabel(pr.Phase)})"));
                db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
                {
                    PurchaseEvaluationId = evaluation.Id,
                    EntityType = PurchaseEvaluationEntityType.Workflow,
                    Action = ChangelogAction.Update,
                    PhaseAtChange = PurchaseEvaluationPhase.DaDuyet,
                    UserId = null,
                    UserName = "Hệ thống",
                    Summary = "Duyệt khi còn phiếu trước cùng gói chưa duyệt",
                    ContextNote = $"[S134 pending-prior] Duyệt khi {pending.PriorPes.Count} phiếu trước cùng gói chưa duyệt: {priorList}",
                });
            }
        }
        else
        {
            // WorkItemId null → không có cặp gói thầu → 6 cột pair + 4 lũy kế = null.
            evaluation.ApprovedBudgetProInitialAmount = null;
            evaluation.ApprovedBudgetProAdjustmentAmount = null;
            evaluation.ApprovedBudgetProNote = null;
            evaluation.ApprovedBudgetCcmInitialAmount = null;
            evaluation.ApprovedBudgetCcmAdjustmentAmount = null;
            evaluation.ApprovedBudgetCcmNote = null;
            evaluation.ApprovedBudgetPrevSubmittedTotal = null;
            evaluation.ApprovedBudgetPrevSubmittedCount = null;
            evaluation.ApprovedBudgetPrevSelectedTotal = null;
            evaluation.ApprovedBudgetPrevSelectedCount = null;
        }
        // LUÔN set = cờ "đã chốt" cho phiếu DaDuyet (UTC — gotcha #72).
        evaluation.ApprovedBudgetSnapshotAt = dateTime.UtcNow;
    }

    // ===== V1 legacy (Mig 21) — iterate PurchaseEvaluationWorkflowSteps =====
    private async Task ApproveV1LegacyAsync(
        PurchaseEvaluation evaluation,
        Guid? actorUserId,
        IReadOnlyList<string> actorRoles,
        bool isAdmin,
        bool isSystem,
        string? comment,
        CancellationToken ct)
    {
        var def = evaluation.WorkflowDefinitionId is Guid wfId
            ? await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking()
                .Include(d => d.Steps.OrderBy(s => s.Order))
                    .ThenInclude(s => s.Approvers)
                .FirstOrDefaultAsync(d => d.Id == wfId, ct)
            : null;

        if (def == null || def.Steps.Count == 0)
            throw new ConflictException("Phiếu chưa pin workflow definition hoặc workflow không có step.");

        var steps = def.Steps.OrderBy(s => s.Order).ToList();
        var currentIdx = evaluation.CurrentWorkflowStepIndex ?? 0;
        if (currentIdx < 0 || currentIdx >= steps.Count)
            throw new ConflictException($"CurrentWorkflowStepIndex={currentIdx} không hợp lệ (max={steps.Count - 1}).");

        var currentStep = steps[currentIdx];

        if (!isAdmin && !isSystem)
        {
            var actor = actorUserId is Guid uid ? await userManager.FindByIdAsync(uid.ToString()) : null;
            if (actor == null)
                throw new ForbiddenException("Không xác định được approver.");

            var matchByDeptLevel = currentStep.DepartmentId != null
                && currentStep.PositionLevel != null
                && actor.DepartmentId == currentStep.DepartmentId
                && actor.PositionLevel != null
                && (int)actor.PositionLevel >= (int)currentStep.PositionLevel;

            var matchByExplicitUser = currentStep.Approvers.Any(a =>
                a.Kind == WorkflowApproverKind.User
                && Guid.TryParse(a.AssignmentValue, out var auid)
                && auid == actor.Id);

            var matchByRole = currentStep.Approvers.Any(a =>
                a.Kind == WorkflowApproverKind.Role
                && actorRoles.Contains(a.AssignmentValue));

            if (!matchByDeptLevel && !matchByExplicitUser && !matchByRole)
                throw new ForbiddenException(
                    $"Step {currentIdx + 1} ({currentStep.Name}) yêu cầu phòng={currentStep.DepartmentId}, cấp={currentStep.PositionLevel}. Bạn không khớp.");
        }

        db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
        {
            PurchaseEvaluationId = evaluation.Id,
            FromPhase = evaluation.Phase,
            ToPhase = evaluation.Phase,
            ApproverUserId = actorUserId,
            Decision = ApprovalDecision.Approve,
            Comment = $"[Step {currentIdx + 1}] {comment ?? ""}",
            ApprovedAt = dateTime.UtcNow,
        });

        var nextIdx = currentIdx + 1;
        if (nextIdx >= steps.Count)
        {
            // [S133] V1 legacy: CHỈ chốt budget-snapshot (KHÔNG ApplyApprovedPriceOnFinalize — V1
            // không có luồng giá-chốt). Ngay TRƯỚC assignment Phase=DaDuyet.
            await ApplyBudgetSnapshotOnFinalizeAsync(evaluation, ct);
            evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
            evaluation.CurrentWorkflowStepIndex = null;
            evaluation.SlaDeadline = null;
            await LogTransitionAsync(evaluation, PurchaseEvaluationPhase.ChoDuyet, PurchaseEvaluationPhase.DaDuyet,
                actorUserId, ApprovalDecision.Approve, comment, ct);
        }
        else
        {
            evaluation.CurrentWorkflowStepIndex = nextIdx;
            evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
            await LogTransitionAsync(evaluation, evaluation.Phase, evaluation.Phase, actorUserId, ApprovalDecision.Approve,
                $"Hoàn tất step {currentIdx + 1}/{steps.Count}, sang step {nextIdx + 1}", ct);
        }
    }

    private async Task LogTransitionAsync(
        PurchaseEvaluation evaluation,
        PurchaseEvaluationPhase fromPhase,
        PurchaseEvaluationPhase toPhase,
        Guid? actorUserId,
        ApprovalDecision decision,
        string? comment,
        CancellationToken ct)
    {
        // Save Approval history (đã làm trong main flow — chỉ log Changelog ở đây)
        string? actorName = null;
        if (actorUserId is Guid uid)
        {
            var user = await userManager.FindByIdAsync(uid.ToString());
            actorName = user?.FullName ?? user?.Email;
        }

        db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
        {
            PurchaseEvaluationId = evaluation.Id,
            EntityType = PurchaseEvaluationEntityType.Workflow,
            Action = ChangelogAction.Transition,
            PhaseAtChange = toPhase,
            UserId = actorUserId,
            UserName = actorName ?? "Hệ thống",
            Summary = $"Chuyển phase {fromPhase} → {toPhase}",
            ContextNote = comment,
        });

        // Notify drafter on terminal states
        if (evaluation.DrafterUserId is Guid drafterId && drafterId != actorUserId)
        {
            var (title, type) = toPhase switch
            {
                PurchaseEvaluationPhase.DaDuyet => ($"Phiếu {evaluation.MaPhieu ?? evaluation.TenGoiThau} đã duyệt",
                                                    NotificationType.ContractPublished),
                PurchaseEvaluationPhase.TuChoi => ($"Phiếu {evaluation.TenGoiThau} bị từ chối",
                                                    NotificationType.ContractRejected),
                PurchaseEvaluationPhase.TraLai when fromPhase == PurchaseEvaluationPhase.ChoDuyet =>
                    ($"Phiếu {evaluation.TenGoiThau} bị trả lại — vui lòng sửa và trình lại",
                     NotificationType.ContractRejected),
                _ => ($"Phiếu {evaluation.TenGoiThau} chuyển phase mới",
                      NotificationType.ContractPhaseTransition),
            };
            await notifications.NotifyAsync(
                drafterId, type, title,
                description: $"{fromPhase} → {toPhase}" + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"),
                href: $"/purchase-evaluations/{evaluation.Id}",
                refId: evaluation.Id,
                ct: ct);
        }

        // [S96 2026-07-01 — anh Kiệt FDC "chuông không bỏ ra được"] Phiếu RỜI một Cấp (finalize/
        // advance/return/reject) → noti "cần bạn duyệt" CŨ (Generic + RefId=phiếu) của những approver
        // chưa kịp duyệt = STALE (phiếu đã xong/chuyển, họ không cần duyệt nữa) → mark-read (ReadAt) để
        // chuông tự sạch. Chạy MỌI transition (kể cả toPhase=DaDuyet/TuChoi/TraLai + duyệt-thay hoàn-tất
        // hộ tại Cấp CEO). Approver Cấp MỚI nhận noti FRESH ở block dưới (tạo SAU mark-read → giữ unread).
        // Best-effort — fail KHÔNG rollback transition. V2-only (có ApprovalWorkflowId).
        if (evaluation.ApprovalWorkflowId is not null)
        {
            try
            {
                await db.Notifications
                    .Where(n => n.RefId == evaluation.Id
                        && n.Type == NotificationType.Generic
                        && n.ReadAt == null)
                    .ExecuteUpdateAsync(s => s.SetProperty(n => n.ReadAt, dateTime.UtcNow), ct);
            }
            catch
            {
                // best-effort — mark-read fail KHÔNG fail transition đã lưu.
            }
        }

        // ===== Notify approver(s) cấp hiện tại khi phiếu vào ChoDuyet =====
        // Tra Sol (Zalo): approver KHÔNG nhận chuông "có hồ sơ cần duyệt" — chỉ
        // drafter được báo (block ↑). Bổ sung: mỗi lần phiếu ENTER hoặc ADVANCE
        // tới 1 Cấp duyệt (toPhase==ChoDuyet) → báo NV Cấp đang chờ.
        // Resolve mirror ĐÚNG canonical (ApplyDrafterBypassOnSubmit / EnsureActor
        // line ~301): load ApprovalWorkflow → Step tại CurrentWorkflowStepIndex →
        // Levels.Where(Order == CurrentApprovalLevelOrder) → distinct ApproverUserId.
        // V2-only (ApprovalWorkflowId + pointer set); phiếu V1/no-workflow skip.
        // Best-effort (try/catch) — phiếu đã SaveChanges, notify fail KHÔNG fail transition.
        if (toPhase == PurchaseEvaluationPhase.ChoDuyet
            && evaluation.ApprovalWorkflowId is Guid notifyAwId
            && evaluation.CurrentWorkflowStepIndex is int notifyCsi
            && evaluation.CurrentApprovalLevelOrder is int notifyLvl)
        {
            try
            {
                var workflow = await db.ApprovalWorkflows.AsNoTracking()
                    .Include(w => w.Steps).ThenInclude(s => s.Levels)
                    .FirstOrDefaultAsync(w => w.Id == notifyAwId, ct);
                var stepsOrdered = workflow?.Steps.OrderBy(s => s.Order).ToList();
                if (stepsOrdered is not null && notifyCsi >= 0 && notifyCsi < stepsOrdered.Count)
                {
                    var approverIds = stepsOrdered[notifyCsi].Levels
                        .Where(l => l.Order == notifyLvl
                            && l.ApproverUserId != Guid.Empty
                            && l.ApproverUserId != actorUserId
                            // UAT S86 (anh Kiệt Zalo 16:05): người TẠO phiếu KHÔNG nhận
                            // chuông "cần bạn duyệt" cho phiếu mình tạo — kể cả khi được
                            // cấu hình làm approver 1 Cấp (họ đã thấy phiếu ở "Phiếu của
                            // tôi"). Chỉ approver-KHÁC-người-tạo nhận. Drafter VẪN nhận
                            // chuông KẾT-QUẢ (đã duyệt / trả lại) ở block trên (:1060).
                            && l.ApproverUserId != evaluation.DrafterUserId)
                        .Select(l => l.ApproverUserId)
                        .Distinct()
                        .ToList();
                    if (approverIds.Count > 0)
                    {
                        await notifications.NotifyManyAsync(
                            approverIds,
                            NotificationType.Generic,
                            $"Phiếu cần bạn duyệt: {evaluation.MaPhieu ?? evaluation.TenGoiThau}",
                            "Có phiếu Duyệt NCC đang chờ bạn duyệt.",
                            $"/purchase-evaluations/{evaluation.Id}",
                            evaluation.Id,
                            ct);
                    }
                }
            }
            catch
            {
                // best-effort — nuốt lỗi notify để KHÔNG rollback transition đã lưu.
            }
        }
    }

    // Mig 26 (Session 19) — helper resolve FullName cho denorm `SignedByFullName`.
    // System auto-approve (actorUserId null + isSystem) → "(System)". User không
    // tồn tại / xóa → fallback UserName / "(unknown)".
    private async Task<string> ResolveActorFullNameAsync(Guid? actorUserId, bool isSystem, CancellationToken ct)
    {
        if (isSystem || actorUserId is null) return "(System)";
        var user = await db.Users.AsNoTracking()
            .Where(u => u.Id == actorUserId.Value)
            .Select(u => new { u.FullName, u.UserName })
            .FirstOrDefaultAsync(ct);
        if (user is null) return "(unknown)";
        return !string.IsNullOrWhiteSpace(user.FullName) ? user.FullName : (user.UserName ?? "(unknown)");
    }
}
