using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Common.Models;
using SolutionErp.Application.ContractSigningPlans.Services;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.ContractSigningPlans;
using SolutionErp.Domain.Contracts;              // ChangelogAction (enum dùng chung 3 module)
using SolutionErp.Domain.Identity;               // AppRoles
using SolutionErp.Domain.PurchaseEvaluations;

namespace SolutionErp.Application.ContractSigningPlans;

// =====================================================================================
// [W2 — S161 2026-07-29] "Kế hoạch ký kết HĐ" (GĐ2) — CQRS mega-file: CHỈ CRUD + DTO.
//
// 🔴 KHÔNG có phần DUYỆT ở đây (Approve/Reject/Return/Submit = W3). Cố ý:
//    khuôn `Office/ProposalFeatures.cs:427-439` flatten `Steps.SelectMany(Levels)` thành
//    MỘT chuỗi tuần tự rồi `ElementAtOrDefault(order-1)` ⇒ biến OR-of-N (nhiều NV cùng
//    Cấp, bất kỳ ai duyệt là xong) thành AND (phải đủ N người, đúng thứ tự). Entity
//    KHKK cố ý mang CON-TRỎ ĐÔI (StepIndex + LevelOrder) mirror PE để tránh đúng bẫy đó;
//    W3 phải mirror `PurchaseEvaluationWorkflowService.ApproveV2Async`, KHÔNG mirror
//    Proposal. Chép nhầm ở W3 = mất OR-of-N.
//
// Khuôn CRUD/DTO thì theo Proposal (list/detail/validator/guard workflow-type).
// Endpoint → `Api/Controllers/ContractSigningPlansController.cs` (authz 2 tầng).
// =====================================================================================

// ===================================== DTOs ==========================================
// Positional record, field append-CUỐI khi cần mở rộng (đừng chèn giữa — vỡ FE mapping).

public record ContractSigningPlanListItemDto(
    Guid Id,
    string? MaKeHoach,
    Guid PurchaseEvaluationId,
    string? PeMaPhieu,
    string? PeTenGoiThau,
    Guid ProjectId,
    string? ProjectName,
    string? ProjectCode,
    Guid? DepartmentId,
    string? DepartmentName,
    Guid DrafterUserId,
    string? DrafterFullName,
    ContractSigningPlanPhase Phase,
    Guid? ApprovalWorkflowId,
    string? WorkflowCode,
    int? CurrentWorkflowStepIndex,
    int? CurrentApprovalLevelOrder,
    int LineCount,
    decimal TotalPeReferenceAmount,
    decimal TotalProposedAmount,
    DateTime? SlaDeadline,
    DateTime CreatedAt,
    DateTime? UpdatedAt,
    // [K2 S164] append-CUỐI. `ApprovalGroup` = nhóm duyệt 1..8 (cây 8 folder + badge).
    // `TotalApprovedAmount` (vá-8): list ĐANG có 2 Σ (PeReference `:711` + Proposed `:713`)
    // nhưng KHÔNG có Σ của số ĐÃ CHỐT ⇒ sau khi phiếu DaDuyet, màn danh sách vẫn khoe số
    // đề xuất trong khi màn chi tiết đã khoá theo `ApprovedAmount` = hai màn lệch số trên
    // cùng một phiếu. Σ này đóng đúng khe đó. Phiếu chưa duyệt → mọi dòng null → 0.
    int ApprovalGroup,
    decimal TotalApprovedAmount,
    // [K7 S167 — vá-4a] ID các HĐ đã sinh ra TỪ CÁC DÒNG của phiếu này (`Line.ContractId`,
    // write-site `CreateContractFromSigningPlanFeatures.cs`). Rỗng = phiếu chưa đưa dòng nào
    // vào HĐ.
    // 🔴 VÌ SAO Ở LIST (không chỉ ở detail — detail đã có `lines[].contractId` từ W2 `:74`):
    //    cây 4-folder (`usePipelineStages.ts:287-291`) dựng GĐ3 từ payload LIST và hôm nay chỉ
    //    có MỘT nguồn là ô đơn `pe.contractId` ⇒ gói có N HĐ thì HĐ thứ 2+ VÔ HÌNH trên cây
    //    (K5 FLAG-1 "chỗ cắm 1 dòng" — lane K5 đã dựng sẵn union, thiếu đúng nguồn thứ hai này).
    //    Trả mảng PHẲNG thay vì `lines[]` lồng: consumer là một `Set<string>`, không cần hạt dòng.
    // ⚠️ KHÔNG `Distinct()` trong projection: N dòng gộp 1 HĐ ra ID trùng — vô hại (consumer là
    //    Set) và tránh một tầng dịch SQL nữa trên projection dùng chung 3 endpoint.
    List<Guid> ContractIds);

public record ContractSigningPlanLineDto(
    Guid Id,
    Guid SupplierId,
    string? SupplierName,
    string? SupplierCode,
    decimal PeReferenceAmount,
    decimal ProposedAmount,
    decimal? ApprovedAmount,
    Guid? ContractId,
    string? Note,
    // [K2 S164] append-CUỐI — hạng mục SP-002 của dòng. `TenHangMuc` là DENORM lúc gán
    // (đừng join ngược lấy tên mới: danh mục đổi tên không được viết lại lịch sử phiếu).
    Guid? CatalogEntryId,
    string? TenHangMuc,
    // [Mig 72 — S177] append-CUỐI. MÃ hạng mục ("A1-01"), DENORM cùng lúc với `TenHangMuc`
    // ngay trên — KHÔNG join ngược `ContractCatalogEntries` lấy mã hiện tại (ghép mã-mới với
    // tên-lúc-ký là sai lệch khó thấy hơn cả để trôi cả hai; xem entity `:29-35`).
    string? MaHangMuc);

public record ContractSigningPlanDossierItemDto(
    Guid Id,
    DossierItemKind Kind,
    Guid? SupplierId,
    string? SupplierName,
    string Name,
    DossierItemStatus Status,
    string? TvgsName,
    DateTime? TvgsResultAt,
    string? Note,
    DateTime CreatedAt);

public record ContractSigningPlanAttachmentDto(
    Guid Id,
    Guid? DossierItemId,
    string FileName,
    string StoragePath,
    long FileSize,
    string ContentType,
    ContractSigningPlanAttachmentPurpose Purpose,
    string? Note,
    DateTime CreatedAt);

public record ContractSigningPlanLevelOpinionDto(
    Guid Id,
    Guid ApprovalWorkflowLevelId,
    int? StepOrder,
    string? StepName,
    int? LevelOrder,
    Guid? ApproverUserId,
    string? Comment,
    DateTime SignedAt,
    Guid SignedByUserId,
    string SignedByFullName);

public record ContractSigningPlanWorkflowLevelDto(
    Guid Id,
    int Order,
    string? Name,
    Guid ApproverUserId,
    string? ApproverFullName,
    // [F-1 S166] Cờ per-Cấp cho ô-tích opt-out "Cấp này KẾT THÚC" ở panel duyệt (khuôn PE S96).
    // FE mirror `ResolveActingLevel` (own-level trước, Admin fallback level đầu nhóm) để quyết
    // hiện ô-tích — thiếu cờ này thì `applyLevelFinalize` của body transition không có UI nào gửi false.
    bool AllowApproverFinalize);

public record ContractSigningPlanWorkflowStepDto(
    Guid Id,
    int Order,
    string Name,
    Guid? DepartmentId,
    List<ContractSigningPlanWorkflowLevelDto> Levels,
    // [S171 BE-4] append-CUỐI (KHÔNG chèn cạnh `DepartmentId` — convention file này là
    // append cuối, JSON bind theo TÊN nên FE không quan tâm vị trí). Tên phòng của Bước,
    // nuôi ĐỒNG THỜI 2 chỗ: chip phòng ở panel 3 (P3-4) và header nhóm Section 5 (P2-5).
    // Join `Departments` 1 lượt trong handler — `Step.DepartmentId` là loose-Guid (không nav).
    string? DepartmentName = null);

// [S171 BE-2] Lịch sử duyệt của phiếu — record MỚI (trước S171 grep = 0 hit; panel 3 mục 6
// "Lịch sử duyệt (N)" của Ảnh 2 không có nguồn nào để đọc).
// 🔴 `StepOrder/StepName/LevelOrder/LevelName` DẪN XUẤT từ `ApprovalWorkflowLevelId`
// (loose-Guid, no-FK — `ContractSigningPlanApproval.cs:14-16`) qua bản-đồ Cấp của workflow đã
// pin. Cấp bị admin xoá ⇒ 4 field null, dòng lịch sử VẪN hiện (không nuốt hàng).
public record ContractSigningPlanApprovalDto(
    Guid Id,
    ContractSigningPlanPhase FromPhase,
    ContractSigningPlanPhase ToPhase,
    ApprovalDecision Decision,
    string? Comment,
    Guid? ApprovalWorkflowLevelId,
    int? StepOrder,
    string? StepName,
    int? LevelOrder,
    string? LevelName,
    Guid? ApprovedByUserId,
    string? ApprovedByFullName,
    DateTime ApprovedAt);

public record ContractSigningPlanDetailDto(
    Guid Id,
    string? MaKeHoach,
    Guid PurchaseEvaluationId,
    string? PeMaPhieu,
    string? PeTenGoiThau,
    /// [S175] Hang muc CONG VIEC cua du an, lay tu phieu Duyet NCC nguon (PE.WorkItemId).
    /// KHONG phai Line.TenHangMuc (do la danh-muc SP-002 phan loai hop dong).
    string? PeWorkItemName,
    Guid ProjectId,
    string? ProjectName,
    string? ProjectCode,
    Guid? DepartmentId,
    string? DepartmentName,
    Guid DrafterUserId,
    string? DrafterFullName,
    ContractSigningPlanPhase Phase,
    Guid? ApprovalWorkflowId,
    string? WorkflowCode,
    string? WorkflowName,
    int? CurrentWorkflowStepIndex,
    int? CurrentApprovalLevelOrder,
    DateTime? SlaDeadline,
    string? HoSoLink,
    string? GhiChu,
    DateTime CreatedAt,
    DateTime? UpdatedAt,
    List<ContractSigningPlanLineDto> Lines,
    List<ContractSigningPlanDossierItemDto> DossierItems,
    List<ContractSigningPlanAttachmentDto> Attachments,
    List<ContractSigningPlanLevelOpinionDto> LevelOpinions,
    List<ContractSigningPlanWorkflowStepDto> WorkflowSteps,
    // [K2 S164] append-CUỐI (sau cả các List — convention của file là append cuối CÙNG,
    // không chèn giữa; JSON bind theo TÊN nên FE không quan tâm vị trí).
    int ApprovalGroup,
    // Display-gate (khuôn tên `budgetFrozen` PE S133): phiếu DaDuyet ⇒ FE đọc `ApprovedAmount`
    // + badge khoá; trước đó đọc `ProposedAmount`. Đây là CỜ HIỂN THỊ dẫn xuất từ Phase,
    // KHÔNG phải cột DB — số đã được đóng băng ở tầng dữ liệu bởi `ApprovedAmount ??=` tại
    // choke-point finalize (`ContractSigningPlanWorkflowService.cs:301-305`).
    bool BudgetFrozen,
    // ===================== [S171 BE-1/BE-1b/BE-2] append-CUỐI =========================
    // Nuôi panel 3 "Quy trình duyệt" (Ảnh 2) — mirror khuôn PE.
    //   • `WorkflowVersion` — dòng meta `{code} v{NN} · {name}` (ApprovalWorkflow.Version:24).
    //   • `EndedByLevelFinalize` — CỜ RUNTIME đọc từ cột phiếu (Mig 71), banner "… không qua CEO".
    //   • `FinalizeStepName`/`FinalizeLevelName` — 🔴 KHÔNG có cột DB nào lưu "Bước/Cấp nào đã
    //     kết thúc" (`ContractSigningPlan.cs:51` chỉ có bool) ⇒ DẪN XUẤT từ cây workflow đã pin,
    //     đúng ngữ nghĩa PE list (`PurchaseEvaluationFeatures.cs:678-685`): Cấp `AllowApproverFinalize`
    //     ĐẦU TIÊN theo (Step.Order, Level.Order). Xem 3-ngả ở handler.
    //   • `Approvals` — lịch sử duyệt (BE-2). ĐÃ LỌC `Decision=Pending`, xem ghi chú handler.
    int? WorkflowVersion,
    bool EndedByLevelFinalize,
    string? FinalizeStepName,
    string? FinalizeLevelName,
    List<ContractSigningPlanApprovalDto> Approvals,
    // [S177] append-CUỐI. MÃ của hạng mục CÔNG VIỆC DỰ ÁN ("DAO_MONG") — cặp mã của
    // `PeWorkItemName` ở `:178`. 🔴 KHÔNG denorm: khác `Line.MaHangMuc`, cái này lấy qua
    // JOIN SỐNG `WorkItems` ngay tại handler (`:929`) y như cái tên đã lấy — chụp lại vào
    // KHKK là chụp một thứ vốn đã là con trỏ loose-Guid qua PE (Mig 49).
    string? PeWorkItemCode);

/// Dòng picker "PE đã duyệt — chờ lập kế hoạch ký kết".
public record ApprovedPeAwaitingPlanDto(
    Guid Id,
    string? MaPhieu,
    string TenGoiThau,
    Guid ProjectId,
    string? ProjectName,
    string? ProjectCode,
    Guid? DepartmentId,
    string? DepartmentName,
    Guid? WorkItemId,
    string? WorkItemName,
    int WinnerCount,
    decimal WinnerQuoteTotal,
    DateTime CreatedAt,
    DateTime? UpdatedAt,
    List<string> WinnerSupplierNames,    // [W2 S161 — F-6] FE render chip tên NCC trúng thầu (append cuối)
    // [K2 S164 — OG-7 UI-hướng-1] Nhóm duyệt của các kế hoạch ĐANG SỐNG trên PE này
    // (`Phase != TuChoi`, xoá mềm đã bị global filter loại). Rỗng = PE chưa có kế hoạch nào.
    // 🔴 Đổi HÌNH DẠNG picker: trước K2, PE có kế hoạch bị LOẠI khỏi danh sách (biến mất);
    // giờ vẫn hiện KÈM danh sách nhóm đã dùng để FE hiện nút "Tạo thêm cho nhóm khác" và
    // disable đúng những nhóm đã có. Ẩn phiếu đi thì user không phân biệt được
    // "PE không đủ điều kiện" với "PE đã dùng hết nhóm" — hai thứ khác nhau.
    List<int> ExistingGroups);

public record CreateContractSigningPlanResult(Guid Id, string MaKeHoach, int LineCount);

// ============================ Helper dùng chung (internal) ===========================

internal static class ContractSigningPlanScope
{
    // [K2 S164] Biên nhóm duyệt N1..N8 (SP-002) + default hướng-1 (OG-1/OG-7 UI-1-1:
    // FE không gửi nhóm ⇒ phiếu vào nhóm 1 — khớp backfill Mig 71 ApprovalGroup=1).
    internal const int MinApprovalGroup = 1;
    internal const int MaxApprovalGroup = 8;
    internal const int DefaultApprovalGroup = 1;

    /// Phase cho phép SỬA nội dung nháp (header + căn cứ b.8-9).
    internal static bool IsDraftEditable(ContractSigningPlanPhase phase)
        => phase is ContractSigningPlanPhase.DangSoanThao or ContractSigningPlanPhase.TraLai;

    /// Guard chung cho mọi đường ghi vào phiếu nháp: người soạn HOẶC Admin, phase mở.
    /// (Khuôn `ProposalFeatures.cs:315-320` — tách ra vì W2 có 4 đường ghi dùng chung.)
    // [W2 S161 — reviewer F-8/F-9] Rào XEM (detail + download/view file) — mirror khuôn PE
    // `PurchaseEvaluationFeatures.cs:877-899`: owner ∥ Admin; phiếu ĐÃ rời nháp thì thêm
    // approver-của-workflow-đã-pin. Nháp = RIÊNG TƯ (S89) — approver KHÔNG xem được nháp.
    // KHKK không có bản đồ role→phase (khác PE eligiblePhases); vị ngữ KHỚP list :619-628
    // (list bịt mà detail mở = rào chỉ còn là UX — chính là F-8).
    internal static async Task EnsureCanViewAsync(
        IApplicationDbContext db, ICurrentUser currentUser, ContractSigningPlan plan, CancellationToken ct)
    {
        if (currentUser.Roles.Contains(AppRoles.Admin)) return;
        if (plan.DrafterUserId == currentUser.UserId) return;
        if (plan.Phase != ContractSigningPlanPhase.DangSoanThao
            && plan.ApprovalWorkflowId is Guid awId && currentUser.UserId is Guid uid
            && await db.ApprovalWorkflowLevels.AsNoTracking()
                .AnyAsync(l => l.Step!.ApprovalWorkflowId == awId && l.ApproverUserId == uid, ct))
            return;
        throw new ForbiddenException("Bạn không có quyền xem kế hoạch này.");
    }

    internal static void EnsureDraftEditable(ContractSigningPlan plan, ICurrentUser currentUser)
    {
        var isOwner = plan.DrafterUserId == currentUser.UserId;
        var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
        if (!isOwner && !isAdmin)
            throw new ForbiddenException("Chỉ người soạn kế hoạch hoặc Admin được sửa phiếu này.");
        if (!IsDraftEditable(plan.Phase))
            throw new ConflictException("Chỉ sửa được khi phiếu ở trạng thái Nháp hoặc Trả lại.");
    }

    /// Guard workflow-type TRỌN 2 nhánh — copy `ProposalFeatures.cs:258-267` (kể cả
    /// nhánh NotFound; bỏ nhánh đó thì workflowId rác đi thẳng vào phiếu).
    internal static async Task EnsureWorkflowTypeAsync(
        IApplicationDbContext db, Guid approvalWorkflowId, CancellationToken ct)
    {
        var wfType = await db.ApprovalWorkflows.AsNoTracking()
            .Where(w => w.Id == approvalWorkflowId)
            .Select(w => (int?)w.ApplicableType)
            .FirstOrDefaultAsync(ct);
        if (wfType is null)
            throw new NotFoundException("ApprovalWorkflow", approvalWorkflowId);
        if (wfType.Value != (int)ApprovalWorkflowApplicableType.ContractSigningPlan)
            throw new ConflictException("Quy trình duyệt không thuộc loại Kế hoạch ký kết HĐ.");
    }

    /// [S182 W1b2 — D6] Quy trình có còn được phép PIN MỚI không: `IsActive` ∧ `IsUserSelectable`.
    /// Mirror vá-3 `CreateContractFromSigningPlanFeatures.cs:141-147`.
    ///
    /// 🔴 CHỈ gọi ở đường ĐẶT PIN MỚI (tạo phiếu / sửa nháp có ĐỔI quy trình). CỐ Ý KHÔNG gọi
    ///    khi giữ nguyên pin cũ: phiếu lỡ pin bản đã archived vẫn phải sửa được GhiChu/HoSoLink,
    ///    nếu không thì admin ngừng một quy trình là khoá cứng mọi phiếu nháp đang trỏ vào nó.
    ///    Cũng KHÔNG gọi ở đường TRÌNH duyệt vì lý do đó (D6 chốt 2 site).
    ///
    /// 2 cờ ném 2 message TÁCH RIÊNG (không gộp `!IsActive || !IsUserSelectable`): "đã ngừng
    /// hoạt động" và "chưa ghim" sửa ở 2 nút khác nhau trong Designer — gộp thì người đọc lỗi
    /// không biết bấm nút nào.
    internal static async Task EnsureWorkflowPinnableAsync(
        IApplicationDbContext db, Guid approvalWorkflowId, CancellationToken ct)
    {
        var wf = await db.ApprovalWorkflows.AsNoTracking()
            .Where(w => w.Id == approvalWorkflowId)
            .Select(w => new { w.Code, w.IsActive, w.IsUserSelectable })
            .FirstOrDefaultAsync(ct);
        if (wf is null)
            throw new NotFoundException("ApprovalWorkflow", approvalWorkflowId);
        if (!wf.IsActive)
            throw new ConflictException(
                $"Quy trình '{wf.Code}' đã ngừng hoạt động — chọn quy trình đang hoạt động.");
        if (!wf.IsUserSelectable)
            throw new ConflictException(
                $"Quy trình '{wf.Code}' chưa được ghim cho người dùng chọn — nhờ Admin ghim, hoặc chọn quy trình khác.");
    }

    /// Precompute ID phiếu mà `userId` là approver của Cấp ĐANG CHỜ — mirror
    /// `PurchaseEvaluationFeatures.cs:817-852 ResolveV2InboxIdsAsync`.
    /// 🔴 In-memory join CÓ CHỦ ĐÍCH: `CurrentWorkflowStepIndex` là INDEX 0-based vào list
    /// Steps đã sort theo Order, không phải Order ⇒ không dịch thẳng sang SQL được.
    /// So Cấp bằng GIÁ TRỊ `Level.Order` và lấy `Any(...)` ⇒ giữ nguyên OR-of-N.
    /// W2 chỉ ĐỌC (chưa có phiếu nào ở ChoDuyet vì transition = W3) — wire sẵn để W3
    /// không phải sửa list/inbox nữa.
    internal static async Task<HashSet<Guid>> ResolveV2InboxIdsAsync(
        IApplicationDbContext db, Guid? userId, CancellationToken ct)
    {
        if (userId is null) return new HashSet<Guid>();

        var candidates = await (
            from p in db.ContractSigningPlans.AsNoTracking()
            where p.Phase == ContractSigningPlanPhase.ChoDuyet
                && p.ApprovalWorkflowId != null
                && p.CurrentWorkflowStepIndex != null
                && p.CurrentApprovalLevelOrder != null
            select new { p.Id, p.ApprovalWorkflowId, p.CurrentWorkflowStepIndex, p.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 match = steps[idx].Levels.Any(l =>
                l.Order == c.CurrentApprovalLevelOrder!.Value && l.ApproverUserId == userId.Value);
            if (match) result.Add(c.Id);
        }
        return result;
    }

    /// Danh sách phase Admin thấy trong Inbox — LIỆT KÊ TƯỜNG MINH (mirror
    /// `PurchaseEvaluationFeatures.cs:731-742`). Nháp KHÔNG vào inbox (S89: inbox =
    /// "việc chờ duyệt", nháp xem ở danh sách).
    internal static readonly ContractSigningPlanPhase[] AdminInboxPhases =
    [
        ContractSigningPlanPhase.ChoDuyet,
    ];

    /// Các workflow mà `userId` có chân duyệt — dùng cho IDOR scope của list.
    internal static async Task<List<Guid>> ResolveUserApprovalWorkflowIdsAsync(
        IApplicationDbContext db, Guid? userId, CancellationToken ct)
        => userId is null
            ? []
            : await db.ApprovalWorkflowLevels.AsNoTracking()
                .Where(l => l.ApproverUserId == userId.Value)
                .Select(l => l.Step!.ApprovalWorkflowId)
                .Distinct()
                .ToListAsync(ct);
}

// ============================ Region 1: CREATE phiếu ================================

/// <param name="PeId">Phiếu Duyệt NCC nguồn (phải `DaDuyet`).</param>
/// <remarks>
/// Tên tham số `PeId` (KHÔNG `PurchaseEvaluationId` như convention repo) là CỐ Ý: spec
/// W2 §②-2 viết đúng chữ đó và lane FE dựng body JSON từ CÙNG bản spec ⇒ đổi tên ở đây
/// là gãy tích hợp giữa 2 lane. DTO trả về vẫn dùng tên đầy đủ.
/// </remarks>
/// <param name="ApprovalGroup">
/// [K2 S164] Nhóm duyệt N1..N8 của kế hoạch. **NULL = nhóm 1** (mặc định) — CỐ Ý nullable
/// thay vì `int` bắt buộc: client hiện tại (`KhkkCreatePage.tsx:73-75` ×2 app) CHƯA gửi
/// trường này, mà giá trị bỏ trống của `int` là 0 → rơi thẳng vào 400 "ngoài 1..8" ⇒ đổi
/// sang bắt buộc là làm gãy màn tạo phiếu đang chạy. `null → 1` khớp đúng ngữ nghĩa
/// backfill của Mig 71 (mọi phiếu cũ = nhóm 1). Client gửi số thì validator 1..8 vẫn có răng.
/// </param>
public record CreateContractSigningPlanCommand(
    Guid PeId,
    Guid ApprovalWorkflowId,
    string? GhiChu = null,
    int? ApprovalGroup = null) : IRequest<CreateContractSigningPlanResult>;

public class CreateContractSigningPlanCommandValidator : AbstractValidator<CreateContractSigningPlanCommand>
{
    public CreateContractSigningPlanCommandValidator()
    {
        RuleFor(x => x.PeId).NotEmpty();
        RuleFor(x => x.ApprovalWorkflowId).NotEmpty();
        // MaxLength MATCH EF config `ContractSigningPlanConfiguration.cs:22` (GhiChu = 2000).
        // EF là source of truth — KHÔNG lấy số từ spec (bài S35).
        RuleFor(x => x.GhiChu).MaximumLength(2000);
        // Chỉ soi khi client CÓ gửi (`.When`): null là đường hợp lệ (= nhóm 1), 0 hoặc 9 thì không.
        RuleFor(x => x.ApprovalGroup)
            .InclusiveBetween(ContractSigningPlanScope.MinApprovalGroup,
                              ContractSigningPlanScope.MaxApprovalGroup)
            .When(x => x.ApprovalGroup.HasValue)
            .WithMessage("Nhóm duyệt phải trong khoảng N1..N8.");
    }
}

public class CreateContractSigningPlanCommandHandler(
    IApplicationDbContext db,
    ICurrentUser currentUser,
    IContractSigningPlanCodeGenerator codeGen)
    : IRequestHandler<CreateContractSigningPlanCommand, CreateContractSigningPlanResult>
{
    public async Task<CreateContractSigningPlanResult> Handle(
        CreateContractSigningPlanCommand request, CancellationToken ct)
    {
        if (!currentUser.IsAuthenticated || currentUser.UserId is null)
            throw new UnauthorizedException();
        var actorId = currentUser.UserId.Value;

        var pe = await db.PurchaseEvaluations
            .Include(p => p.Suppliers)
            .Include(p => p.Details).ThenInclude(d => d.Quotes)
            .FirstOrDefaultAsync(p => p.Id == request.PeId, ct)
            ?? throw new NotFoundException("PurchaseEvaluation", request.PeId);

        // ---- Rào (i) PHASE — mirror `CreateContractFromEvaluationFeatures.cs:53-54`.
        if (pe.Phase != PurchaseEvaluationPhase.DaDuyet)
            throw new ConflictException("Chỉ lập kế hoạch ký kết từ phiếu Duyệt NCC đã duyệt xong (Đã duyệt).");

        // ---- Rào (ii) WINNER — mirror `:56-58`. Không winner = không có dòng giá nào để lập.
        var winners = pe.Suppliers.Where(s => s.IsWinner).ToList();
        if (winners.Count == 0)
            throw new ConflictException("Phiếu chưa chọn NCC/TP trúng thầu — chưa có dòng giá để lập kế hoạch.");

        // ---- Rào (iii) [K2 S164 — OG-7] 1 PE = 1 kế hoạch SỐNG **MỖI NHÓM DUYỆT** (trước K2 là
        // 1 kế hoạch/PE tuyệt đối). Một gói thầu có thể phải ký NHIỀU loại hợp đồng thuộc các
        // nhóm duyệt khác nhau ⇒ khoá theo PE là khoá nhầm chiều. `TuChoi` KHÔNG tính là sống ⇒
        // phiếu bị từ chối vẫn lập lại được. Phiếu xoá mềm cũng không tính (global filter tự
        // loại) ⇒ xoá xong tạo lại được (acceptance §③-B "DELETE → tạo lại → 201").
        // 🔴 CỐ Ý KHÔNG dựng UNIQUE (PeId, ApprovalGroup) dưới DB — lý do THẬT (gate-K2 F-7 sửa;
        // bản cũ nói "unique không diễn tả được Phase != TuChoi" là SAI kỹ thuật: filtered index
        // `WHERE [Phase] <> 3 AND [IsDeleted] = 0` diễn tả được). Đợt này chấp nhận rào handler
        // (check-then-insert, còn cửa race 2-request-đồng-thời nhỏ) vì Phase là cột mutable +
        // UI-hướng-1; cần chống race tuyệt đối thì dựng filtered unique ở wave sau.
        var approvalGroup = request.ApprovalGroup ?? ContractSigningPlanScope.DefaultApprovalGroup;

        var hasLivePlan = await db.ContractSigningPlans
            .AnyAsync(p => p.PurchaseEvaluationId == pe.Id
                && p.ApprovalGroup == approvalGroup
                && p.Phase != ContractSigningPlanPhase.TuChoi, ct);
        if (hasLivePlan)
            throw new ConflictException(
                $"Phiếu Duyệt NCC này đã có kế hoạch ký kết cho nhóm duyệt N{approvalGroup}. " +
                "Chọn nhóm duyệt khác, hoặc xoá/để bị từ chối kế hoạch cũ của nhóm này trước khi lập lại.");

        // ---- Rào (iv) WORKFLOW TYPE — copy trọn 2 nhánh Proposal `:258-267`.
        await ContractSigningPlanScope.EnsureWorkflowTypeAsync(db, request.ApprovalWorkflowId, ct);

        // ---- Rào (iv-bis) [S182 W1b2 — D6] Quy trình phải CÒN DÙNG ĐƯỢC lúc pin.
        // Mirror vá-3 `CreateContractFromSigningPlanFeatures.cs:141-147` (cùng lỗ, đã bịt cho
        // type-3): FE chỉ hiện quy trình active+ghim, nhưng POST thẳng thì pin được bản đã
        // archived ⇒ phiếu trình xong treo ở quy trình không ai bảo trì.
        await ContractSigningPlanScope.EnsureWorkflowPinnableAsync(db, request.ApprovalWorkflowId, ct);

        // ---- Rào (v) [K3 S165 — QĐ-3 "K3 chỉ ĐỌC ApprovalGroup"] Workflow họ nhóm
        // `KHKK-N{n}` ⇒ n PHẢI khớp nhóm duyệt của phiếu (phiếu nhóm 3 pin quy trình nhóm 5
        // = 409). Code NGOÀI họ (type-10 legacy/chung) → cho qua — ràng buộc chỉ áp cho
        // 8 workflow nhóm seed @K3.
        // [S182 W1b] Thân rào DỜI sang `ContractSigningPlanWorkflowGuards` — cùng luật này
        // nay chặn ở CẢ 3 cửa (tạo / sửa nháp re-pin / trình duyệt), trước chỉ có cửa này.
        await ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync(
            db, request.ApprovalWorkflowId, approvalGroup, ct);

        // 🔴 GEN MÃ TRƯỚC KHI `Add` — `ContractSigningPlanCodeGenerator` TỰ COMMIT
        // (`ContractSigningPlanCodeGenerator.cs:23-43`: transaction SERIALIZABLE +
        // `SaveChangesAsync` nội bộ). Nếu phiếu đã nằm trong change-tracker lúc gọi, lượt
        // SaveChanges của codegen sẽ flush phiếu DỞ (chưa có mã) trong transaction của nó.
        // Bài S88 "multi-entity create + self-committing codegen": gen hết mã ở LOCAL
        // trước, Add + SaveChanges đúng 1 lần ở cuối = atomic, không đẻ phiếu nửa vời.
        var maKeHoach = await codeGen.GenerateAsync(ct);

        var plan = new ContractSigningPlan
        {
            MaKeHoach = maKeHoach,
            PurchaseEvaluationId = pe.Id,
            ProjectId = pe.ProjectId,          // denorm từ PE (loose-Guid, để lọc list)
            DepartmentId = pe.DepartmentId,    // denorm từ PE
            // 🔴 DrafterUserId = NGƯỜI ĐANG TẠO kế hoạch, KHÔNG phải `pe.DrafterUserId`:
            //   (a) entity khai "BCH soạn kế hoạch" (`ContractSigningPlan.cs:24`) — người
            //       soạn PE (QS) và người lập kế hoạch (BCH) là 2 vai khác nhau;
            //   (b) khuôn Proposal `:276` / PE `:147` đều lấy `currentUser`;
            //   (c) `pe.DrafterUserId` là `Guid?` còn cột này non-null ⇒ copy sang sẽ ra
            //       `Guid.Empty` cho phiếu PE cũ thiếu drafter = hỏng cả quyền sửa lẫn scope list.
            DrafterUserId = actorId,
            Phase = ContractSigningPlanPhase.DangSoanThao,
            ApprovalGroup = approvalGroup,     // [K2 S164] nhóm duyệt của phiếu — chốt lúc TẠO
            ApprovalWorkflowId = request.ApprovalWorkflowId,
            GhiChu = string.IsNullOrWhiteSpace(request.GhiChu) ? null : request.GhiChu.Trim(),
        };

        // ---- Auto-sinh Lines từ winner — SNAPSHOT 1 LẦN, không đọc live.
        // Phép tính tiền copy `CreateContractFromEvaluationFeatures.cs:88-90`: tổng
        // `Quote.ThanhTien` của các báo giá ĐƯỢC CHỌN thuộc ĐÚNG supplier-row đó
        // (`q.PurchaseEvaluationSupplierId == w.Id` — so với Id của ROW phiếu, KHÔNG phải
        // `SupplierId` master; hai cái khác nhau, nhầm là cộng nhầm NCC).
        foreach (var w in winners.OrderBy(s => s.Order).ThenBy(s => s.Id))
        {
            var peReference = pe.Details.SelectMany(d => d.Quotes)
                .Where(q => q.PurchaseEvaluationSupplierId == w.Id && q.IsSelected)
                .Sum(q => q.ThanhTien);

            plan.Lines.Add(new ContractSigningPlanLine
            {
                ContractSigningPlanId = plan.Id,
                SupplierId = w.SupplierId,
                // [K2 S164] Mồi hạng mục = NULL CÓ CHỦ ĐÍCH. Lúc tạo, hệ thống biết NCC nào
                // trúng thầu nhưng KHÔNG biết dòng đó sẽ ký theo hạng mục SP-002 nào — đoán bừa
                // một hạng mục là bịa dữ liệu pháp lý. Người soạn gán sau qua
                // `UpdateContractSigningPlanLineCommand`; cửa ra là SUBMIT-GUARD
                // (`ContractSigningPlanWorkflowService.SubmitAsync`) chặn trình khi còn dòng NULL.
                CatalogEntryId = null,
                TenHangMuc = null,
                MaHangMuc = null,   // [Mig 72] cặp mã+tên đi liền — mồi thì cùng NULL
                PeReferenceAmount = peReference,
                // Mồi ProposedAmount = số PE. W2 CHƯA có đường sửa dòng giá (spec §②-2 không
                // liệt kê command nào cho Line) ⇒ để 0 thì cả phiếu là 0 và không ai sửa được.
                // Mồi bằng số tham chiếu = trường hợp thường gặp (đề xuất đúng giá đã duyệt);
                // sửa tay mở ở W3 cùng màn duyệt.
                ProposedAmount = peReference,
            });
        }

        db.ContractSigningPlans.Add(plan);

        db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
        {
            ContractSigningPlanId = plan.Id,
            EntityType = ContractSigningPlanEntityType.Header,
            Action = ChangelogAction.Insert,
            PhaseAtChange = plan.Phase,
            UserId = actorId,
            UserName = currentUser.FullName ?? currentUser.Email,
            Summary = $"Lập kế hoạch ký kết {maKeHoach} — {plan.Lines.Count} dòng NCC trúng thầu",
            ContextNote = $"Nguồn: phiếu Duyệt NCC {pe.MaPhieu ?? pe.TenGoiThau} ({pe.Id})",
        });

        await db.SaveChangesAsync(ct);
        return new CreateContractSigningPlanResult(plan.Id, maKeHoach, plan.Lines.Count);
    }
}

// ========================= Region 2: UPDATE phiếu nháp ==============================

/// Sửa header phiếu nháp. MỌI field null-safe: `null` = client không gửi ⇒ GIỮ giá trị cũ
/// (bug-class #73/S42 — absolute-set làm form "Sửa" rút gọn null-hoá field nó không hiển thị).
/// Chuỗi rỗng `""` = client CHỦ ĐỘNG xoá ⇒ set null (khuôn `HoSoLink`
/// `PurchaseEvaluationFeatures.cs:285-286`).
public record UpdateContractSigningPlanDraftCommand(
    Guid Id,
    string? GhiChu = null,
    string? HoSoLink = null,
    Guid? ApprovalWorkflowId = null) : IRequest;

public class UpdateContractSigningPlanDraftCommandValidator
    : AbstractValidator<UpdateContractSigningPlanDraftCommand>
{
    public UpdateContractSigningPlanDraftCommandValidator()
    {
        RuleFor(x => x.Id).NotEmpty();
        // MaxLength MATCH EF: GhiChu 2000 (`ContractSigningPlanConfiguration.cs:22`),
        // HoSoLink 1000 (`:21`).
        RuleFor(x => x.GhiChu).MaximumLength(2000);
        RuleFor(x => x.HoSoLink).MaximumLength(1000);
    }
}

public class UpdateContractSigningPlanDraftCommandHandler(
    IApplicationDbContext db,
    ICurrentUser currentUser) : IRequestHandler<UpdateContractSigningPlanDraftCommand>
{
    public async Task Handle(UpdateContractSigningPlanDraftCommand request, CancellationToken ct)
    {
        var plan = await db.ContractSigningPlans.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
            ?? throw new NotFoundException("ContractSigningPlan", request.Id);

        ContractSigningPlanScope.EnsureDraftEditable(plan, currentUser);

        // Re-pin quy trình: guard type LẠI (workflow có thể bị admin đổi loại giữa chừng).
        //
        // [S182 W1b/W1b2] Cửa này trước KHÔNG có rào nhóm ⇒ tạo phiếu đúng nhóm rồi sửa nháp
        // pin sang quy trình nhóm khác là lọt. Nay đủ 3 rào như đường TẠO.
        // 🔴 Cả 3 nằm TRONG nhánh `awId != plan.ApprovalWorkflowId` (đường ĐỔI pin) — CỐ Ý:
        //    client gửi lại đúng workflow cũ (form "Sửa" echo nguyên field) không được biến
        //    thành 409, nếu không thì sửa GhiChu của phiếu pin bản archived sẽ hỏng (D6).
        if (request.ApprovalWorkflowId is Guid awId && awId != plan.ApprovalWorkflowId)
        {
            await ContractSigningPlanScope.EnsureWorkflowTypeAsync(db, awId, ct);
            await ContractSigningPlanScope.EnsureWorkflowPinnableAsync(db, awId, ct);
            await ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync(
                db, awId, plan.ApprovalGroup, ct);
            plan.ApprovalWorkflowId = awId;
        }

        if (request.GhiChu is not null)
            plan.GhiChu = string.IsNullOrWhiteSpace(request.GhiChu) ? null : request.GhiChu.Trim();
        if (request.HoSoLink is not null)
            plan.HoSoLink = string.IsNullOrWhiteSpace(request.HoSoLink) ? null : request.HoSoLink.Trim();

        db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
        {
            ContractSigningPlanId = plan.Id,
            EntityType = ContractSigningPlanEntityType.Header,
            Action = ChangelogAction.Update,
            PhaseAtChange = plan.Phase,
            UserId = currentUser.UserId,
            UserName = currentUser.FullName ?? currentUser.Email,
            Summary = "Cập nhật thông tin kế hoạch ký kết",
        });

        await db.SaveChangesAsync(ct);
    }
}

// [S168 K8-blocker] Gán hạng mục SP-002 cho DÒNG kế hoạch — mảnh "Lines-editor" mà comment
// auto-gen (`:481-484`) HẸN nhưng chưa từng land (build-order K4b đổi việc sang `?group=`):
// submit-guard vá-5 đã CHẶN line CatalogEntryId=null ⇒ thiếu command này thì MỌI phiếu tạo
// sau K2 kẹt Nháp vĩnh viễn. Dry-run K8 bắt lỗ này trước giờ chạy.
/// [S175 — anh chốt] "Chỉ thêm hạng mục → nhiều hạng mục → giá và ghi chú, vậy thôi."
/// ⇒ 3 field, MỌI field null-safe theo khuôn `UpdateContractSigningPlanDraftCommand:559-563`:
/// `null` = client KHÔNG gửi ⇒ giữ giá trị cũ (bug-class #73/S42 — form rút gọn null-hoá field
/// nó không hiển thị). `CatalogEntryId` đổi từ bắt-buộc sang nullable ⇒ call-site cũ (FE gửi
/// đúng 1 field `catalogEntryId`) KHÔNG phải sửa dòng nào.
public record UpdateContractSigningPlanLineCommand(
    Guid PlanId,
    Guid LineId,
    Guid? CatalogEntryId = null,
    decimal? ProposedAmount = null,
    string? Note = null) : IRequest;

public class UpdateContractSigningPlanLineCommandValidator
    : AbstractValidator<UpdateContractSigningPlanLineCommand>
{
    public UpdateContractSigningPlanLineCommandValidator()
    {
        RuleFor(x => x.PlanId).NotEmpty();
        RuleFor(x => x.LineId).NotEmpty();
        // 🔴 KHÔNG cho giá ÂM. Khác `AdjustmentAmount` của ngân sách (cái đó cho âm vì là
        // "hiệu chỉnh tăng GIẢM"); đây là GIÁ ĐỀ XUẤT của một hạng mục — âm là vô nghĩa.
        RuleFor(x => x.ProposedAmount).GreaterThanOrEqualTo(0).When(x => x.ProposedAmount.HasValue);
        RuleFor(x => x.Note).MaximumLength(1000);
    }
}

public class UpdateContractSigningPlanLineCommandHandler(
    IApplicationDbContext db,
    ICurrentUser currentUser) : IRequestHandler<UpdateContractSigningPlanLineCommand>
{
    public async Task Handle(UpdateContractSigningPlanLineCommand request, CancellationToken ct)
    {
        var plan = await db.ContractSigningPlans
            .Include(x => x.Lines)
            .FirstOrDefaultAsync(x => x.Id == request.PlanId, ct)
            ?? throw new NotFoundException("ContractSigningPlan", request.PlanId);

        ContractSigningPlanScope.EnsureDraftEditable(plan, currentUser);

        var line = plan.Lines.FirstOrDefault(l => l.Id == request.LineId)
            ?? throw new NotFoundException("ContractSigningPlanLine", request.LineId);

        // [gate-lineeditor F-6] Rào BE thật (FE khoá chỉ là hiển thị): dòng đã bắc cầu HĐ
        // thì BẤT BIẾN — đổi sau khi có HĐ = đổi lịch sử pháp lý của HĐ đã sinh.
        // 🔴 [S175] Rào này nay che CẢ TIỀN, không chỉ hạng mục: `ProposedAmount` là số đi vào
        // HĐ, sửa sau khi HĐ sinh cũng là viết lại lịch sử.
        if (line.ContractId != null)
            throw new ConflictException("Dòng đã được đưa vào hợp đồng — không sửa được nữa.");

        var changes = new List<string>();

        if (request.CatalogEntryId is Guid catId)
        {
            // Fail-fast tầng-gán (submit-guard vẫn là chốt CUỐI chống data cũ/lách API):
            // hạng mục phải CÒN SỐNG + đang hoạt động + ĐÚNG nhóm duyệt của phiếu.
            var entry = await db.ContractCatalogEntries.AsNoTracking()
                .FirstOrDefaultAsync(c => c.Id == catId, ct)
                ?? throw new NotFoundException("ContractCatalogEntry", catId);
            if (!entry.IsActive || entry.ApprovalGroup != plan.ApprovalGroup)
                throw new ConflictException(
                    $"Hạng mục không hợp lệ — phải đang hoạt động và thuộc nhóm duyệt N{plan.ApprovalGroup} của phiếu.");

            // 🔴 [S175] Đổi hạng mục có thể ĐỤNG UNIQUE (PlanId, SupplierId, CatalogEntryId):
            // NCC nay được N dòng ⇒ đổi dòng này trùng hạng mục dòng khác CÙNG NCC là có thật.
            // Bắt TRƯỚC khi SaveChanges để trả 409 đọc được, thay vì 500 unique-violation.
            if (plan.Lines.Any(l => l.Id != line.Id && l.SupplierId == line.SupplierId && l.CatalogEntryId == catId))
                throw new ConflictException(
                    $"NCC này đã có dòng cho hạng mục '{entry.TenVi}' — mỗi cặp NCC × hạng mục chỉ 1 dòng.");

            line.CatalogEntryId = entry.Id;
            // Denorm tên TẠI THỜI ĐIỂM GÁN (khuôn `SignedByFullName` Mig 26 — entity `:25-27`):
            // danh mục đổi tên sau KHÔNG viết lại lịch sử phiếu.
            line.TenHangMuc = entry.TenVi;
            line.MaHangMuc = entry.Code;   // [Mig 72] chụp CÙNG LÚC với tên — xem entity `:29-35`
            changes.Add($"hạng mục → '{entry.Code} — {entry.TenVi}'");
        }

        // [S175] Ghi changelog theo cặp CŨ→MỚI: đây là TIỀN, người duyệt phải truy được ai
        // đổi từ bao nhiêu sang bao nhiêu, không chỉ biết "có sửa".
        if (request.ProposedAmount is decimal amt && amt != line.ProposedAmount)
        {
            changes.Add($"giá đề xuất {line.ProposedAmount:N0} → {amt:N0} đ");
            line.ProposedAmount = amt;
        }

        if (request.Note != null)
        {
            // Chuỗi rỗng = client CHỦ ĐỘNG xoá ⇒ null (khuôn `HoSoLink`).
            var note = string.IsNullOrWhiteSpace(request.Note) ? null : request.Note.Trim();
            if (note != line.Note) { changes.Add("ghi chú"); line.Note = note; }
        }

        if (changes.Count == 0) return;   // không có gì đổi ⇒ đừng đẻ changelog rỗng

        // [gate-lineeditor F-1] Phiếu liên danh N dòng ⇒ nhật ký PHẢI trỏ được DÒNG NÀO:
        // EntityId = line.Id (quy ước mọi changelog thực-thể-con của file) + tên NCC vào Summary.
        var supplierName = await db.Suppliers.AsNoTracking()
            .Where(x => x.Id == line.SupplierId)
            .Select(x => x.Name)
            .FirstOrDefaultAsync(ct);

        db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
        {
            ContractSigningPlanId = plan.Id,
            EntityType = ContractSigningPlanEntityType.Line,
            EntityId = line.Id,
            Action = ChangelogAction.Update,
            PhaseAtChange = plan.Phase,
            UserId = currentUser.UserId,
            UserName = currentUser.FullName ?? currentUser.Email,
            Summary = $"Sửa dòng NCC {supplierName ?? "(không rõ tên)"}: {string.Join(" · ", changes)}",
        });

        await db.SaveChangesAsync(ct);
    }
}

// ===================== [S175] Thêm / xoá DÒNG hạng mục =============================
// Anh chốt: "Chỉ thêm hạng mục → nhiều hạng mục → giá và ghi chú, vậy thôi."
// 🔴 0 MIGRATION: unique `(PlanId, SupplierId, CatalogEntryId)` filter `IsDeleted=0` đã có
// từ Mig 71 ⇒ 1 NCC × N hạng mục **máy đã cho phép từ lâu**, chỉ UI khoá 1-1. Đây đúng khuôn
// "máy rộng, UI hẹp" anh chốt S164 — nay bung UI, KHÔNG đụng máy.

public record AddContractSigningPlanLineCommand(
    Guid PlanId,
    Guid SupplierId,
    Guid CatalogEntryId,
    decimal ProposedAmount,
    string? Note = null) : IRequest<Guid>;

public class AddContractSigningPlanLineCommandValidator
    : AbstractValidator<AddContractSigningPlanLineCommand>
{
    public AddContractSigningPlanLineCommandValidator()
    {
        RuleFor(x => x.PlanId).NotEmpty();
        RuleFor(x => x.SupplierId).NotEmpty();
        // 🔴 BẮT BUỘC, khác đường auto-sinh (dòng gốc để null có chủ đích): unique index CỐ Ý
        // KHÔNG lọc `CatalogEntryId IS NOT NULL` (`ContractSigningPlanLineConfiguration.cs:35-38`)
        // ⇒ thêm dòng NULL sẽ đụng ngay dòng gốc của chính NCC đó.
        RuleFor(x => x.CatalogEntryId).NotEmpty();
        RuleFor(x => x.ProposedAmount).GreaterThanOrEqualTo(0);
        RuleFor(x => x.Note).MaximumLength(1000);
    }
}

public class AddContractSigningPlanLineCommandHandler(
    IApplicationDbContext db,
    ICurrentUser currentUser) : IRequestHandler<AddContractSigningPlanLineCommand, Guid>
{
    public async Task<Guid> Handle(AddContractSigningPlanLineCommand request, CancellationToken ct)
    {
        var plan = await db.ContractSigningPlans
            .Include(x => x.Lines)
            .FirstOrDefaultAsync(x => x.Id == request.PlanId, ct)
            ?? throw new NotFoundException("ContractSigningPlan", request.PlanId);

        ContractSigningPlanScope.EnsureDraftEditable(plan, currentUser);

        // 🔴 NCC phải ĐÃ CÓ trên phiếu. NCC đến từ winner của phiếu Duyệt NCC
        // (`ContractSigningPlanFeatures.cs:508-531`); cho đẻ NCC mới ở đây = lách cả vòng
        // duyệt NCC, ký hợp đồng với nhà thầu chưa ai chọn.
        if (!plan.Lines.Any(l => l.SupplierId == request.SupplierId))
            throw new ConflictException(
                "NCC không thuộc kế hoạch này — chỉ thêm hạng mục cho NCC đã trúng thầu trên phiếu Duyệt NCC.");

        var entry = await db.ContractCatalogEntries.AsNoTracking()
            .FirstOrDefaultAsync(c => c.Id == request.CatalogEntryId, ct)
            ?? throw new NotFoundException("ContractCatalogEntry", request.CatalogEntryId);
        if (!entry.IsActive || entry.ApprovalGroup != plan.ApprovalGroup)
            throw new ConflictException(
                $"Hạng mục không hợp lệ — phải đang hoạt động và thuộc nhóm duyệt N{plan.ApprovalGroup} của phiếu.");

        // Bắt trùng TRƯỚC SaveChanges ⇒ 409 đọc được thay vì 500 unique-violation.
        if (plan.Lines.Any(l => l.SupplierId == request.SupplierId && l.CatalogEntryId == entry.Id))
            throw new ConflictException(
                $"NCC này đã có dòng cho hạng mục '{entry.TenVi}' — mỗi cặp NCC × hạng mục chỉ 1 dòng.");

        var line = new ContractSigningPlanLine
        {
            ContractSigningPlanId = plan.Id,
            SupplierId = request.SupplierId,
            CatalogEntryId = entry.Id,
            TenHangMuc = entry.TenVi,
            MaHangMuc = entry.Code,   // [Mig 72] chụp CÙNG LÚC với tên — xem entity `:29-35`
            // 🔴 = 0, KHÔNG copy số tham chiếu của NCC. `PeReferenceAmount` là Σ báo giá ĐƯỢC
            // CHỌN của CẢ NCC đó trên phiếu PE, đã nằm trọn ở dòng gốc (`:512-515`). Lặp lại ở
            // dòng thêm tay = ĐẾM ĐÔI tiền tham chiếu của dự án. Nó cũng là DẤU phân biệt
            // dòng-gốc ⟂ dòng-thêm-tay mà `Delete` dựa vào để chặn xoá nhầm.
            PeReferenceAmount = 0m,
            ProposedAmount = request.ProposedAmount,
            Note = string.IsNullOrWhiteSpace(request.Note) ? null : request.Note.Trim(),
        };
        db.ContractSigningPlanLines.Add(line);

        var supplierName = await db.Suppliers.AsNoTracking()
            .Where(x => x.Id == request.SupplierId)
            .Select(x => x.Name)
            .FirstOrDefaultAsync(ct);

        db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
        {
            ContractSigningPlanId = plan.Id,
            EntityType = ContractSigningPlanEntityType.Line,
            EntityId = line.Id,
            Action = ChangelogAction.Insert,
            PhaseAtChange = plan.Phase,
            UserId = currentUser.UserId,
            UserName = currentUser.FullName ?? currentUser.Email,
            Summary = $"Thêm hạng mục '{entry.Code} — {entry.TenVi}' cho NCC {supplierName ?? "(không rõ tên)"} — "
                      + $"giá đề xuất {request.ProposedAmount:N0} đ",
        });

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

public record DeleteContractSigningPlanLineCommand(Guid PlanId, Guid LineId) : IRequest;

public class DeleteContractSigningPlanLineCommandHandler(
    IApplicationDbContext db,
    ICurrentUser currentUser) : IRequestHandler<DeleteContractSigningPlanLineCommand>
{
    public async Task Handle(DeleteContractSigningPlanLineCommand request, CancellationToken ct)
    {
        var plan = await db.ContractSigningPlans
            .Include(x => x.Lines)
            .FirstOrDefaultAsync(x => x.Id == request.PlanId, ct)
            ?? throw new NotFoundException("ContractSigningPlan", request.PlanId);

        ContractSigningPlanScope.EnsureDraftEditable(plan, currentUser);

        var line = plan.Lines.FirstOrDefault(l => l.Id == request.LineId)
            ?? throw new NotFoundException("ContractSigningPlanLine", request.LineId);

        if (line.ContractId != null)
            throw new ConflictException("Dòng đã được đưa vào hợp đồng — không xoá được.");

        // 🔴 CHỈ xoá được dòng THÊM TAY. Dòng gốc (`PeReferenceAmount != 0`) sinh từ winner của
        // phiếu Duyệt NCC và mang SNAPSHOT tham chiếu — xoá là mất số gốc, và mất luôn dấu vết
        // NCC đó từng trúng thầu. Muốn bỏ NCC thì sửa ở phiếu PE, không phải xoá lén ở đây.
        if (line.PeReferenceAmount != 0m)
            throw new ConflictException(
                "Đây là dòng gốc sinh từ phiếu Duyệt NCC — không xoá được. Chỉ xoá được hạng mục thêm tay.");

        var supplierName = await db.Suppliers.AsNoTracking()
            .Where(x => x.Id == line.SupplierId)
            .Select(x => x.Name)
            .FirstOrDefaultAsync(ct);
        var tenHangMuc = line.TenHangMuc ?? "(chưa gán hạng mục)";

        db.ContractSigningPlanLines.Remove(line);

        db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
        {
            ContractSigningPlanId = plan.Id,
            EntityType = ContractSigningPlanEntityType.Line,
            EntityId = line.Id,
            Action = ChangelogAction.Delete,
            PhaseAtChange = plan.Phase,
            UserId = currentUser.UserId,
            UserName = currentUser.FullName ?? currentUser.Email,
            Summary = $"Xoá hạng mục '{tenHangMuc}' của NCC {supplierName ?? "(không rõ tên)"}",
        });

        await db.SaveChangesAsync(ct);
    }
}

// ============================ Region 3: GET detail ==================================

public record GetContractSigningPlanQuery(Guid Id) : IRequest<ContractSigningPlanDetailDto>;

public class GetContractSigningPlanQueryHandler(IApplicationDbContext db, ICurrentUser currentUser)
    : IRequestHandler<GetContractSigningPlanQuery, ContractSigningPlanDetailDto>
{
    public async Task<ContractSigningPlanDetailDto> Handle(
        GetContractSigningPlanQuery request, CancellationToken ct)
    {
        // Include tree khuôn `ProposalFeatures.cs:159-162`. Không cần `.Where(!IsDeleted)`
        // thủ công: 4 bảng này CÓ global filter (`ContractSigningPlanConfiguration.cs:42`,
        // `...LineConfiguration.cs:33`, `...DossierItemConfiguration.cs:30`,
        // `...AttachmentConfiguration.cs:38`). LevelOpinions cố ý KHÔNG có filter
        // (`ContractSigningPlanLevelOpinion.cs:18-20`) — chấp nhận, W2 chưa ghi bảng đó.
        var plan = await db.ContractSigningPlans.AsNoTracking()
            .Include(x => x.Lines)
            .Include(x => x.DossierItems)
            .Include(x => x.Attachments)
            .Include(x => x.LevelOpinions)
            .FirstOrDefaultAsync(x => x.Id == request.Id, ct)
            ?? throw new NotFoundException("ContractSigningPlan", request.Id);

        // [W2 S161 — F-8] rào xem mirror PE (list bịt thì detail phải bịt cùng vị ngữ).
        await ContractSigningPlanScope.EnsureCanViewAsync(db, currentUser, plan, ct);

        // [S175] +`WorkItemId` để lấy HẠNG MỤC CÔNG VIỆC CỦA DỰ ÁN — anh: "hạng mục của Dự án,
        // Duyệt NCC ấy". 🔴 KHÁC hẳn `TenHangMuc` của Line: cái đó là hạng mục danh-mục SP-002
        // (86 dòng, dùng để phân loại HỢP ĐỒNG); cái này là gói việc của dự án trên phiếu PE.
        // Trùng tên tiếng Việt nhưng hai trục khác nhau — trộn là chỉ sai gói thầu.
        var pe = await db.PurchaseEvaluations.AsNoTracking()
            .Where(p => p.Id == plan.PurchaseEvaluationId)
            .Select(p => new { p.MaPhieu, p.TenGoiThau, p.WorkItemId })
            .FirstOrDefaultAsync(ct);
        // `WorkItemId` là loose-Guid (Mig 49 — KHÔNG FK vật lý) ⇒ join tay, null-safe.
        // [S177] Lấy LUÔN `Code` trong cùng 1 lượt đọc thay vì thêm query thứ hai — anh muốn
        // hiện "mã hạng mục (Dự án) - mã hạng mục chi tiết" trên từng dòng bảng hạng mục.
        var peWorkItem = pe != null && pe.WorkItemId != null
            ? await db.WorkItems.AsNoTracking()
                .Where(w => w.Id == pe.WorkItemId)
                .Select(w => new { w.Name, w.Code })
                .FirstOrDefaultAsync(ct)
            : null;
        var peWorkItemName = peWorkItem?.Name;
        var peWorkItemCode = peWorkItem?.Code;
        var project = await db.Projects.AsNoTracking()
            .Where(p => p.Id == plan.ProjectId)
            .Select(p => new { p.Name, p.Code })
            .FirstOrDefaultAsync(ct);
        var deptName = await db.Departments.AsNoTracking()
            .Where(d => d.Id == plan.DepartmentId).Select(d => d.Name).FirstOrDefaultAsync(ct);
        var drafterName = await db.Users.AsNoTracking()
            .Where(u => u.Id == plan.DrafterUserId).Select(u => u.FullName).FirstOrDefaultAsync(ct);

        // ---- Tên NCC cho Lines + DossierItems (loose-Guid, không nav) — 1 lượt truy.
        var supplierIds = plan.Lines.Select(l => l.SupplierId)
            .Concat(plan.DossierItems.Where(d => d.SupplierId.HasValue).Select(d => d.SupplierId!.Value))
            .Distinct().ToList();
        var suppliers = await db.Suppliers.AsNoTracking()
            .Where(s => supplierIds.Contains(s.Id))
            .Select(s => new { s.Id, s.Name, s.Code })
            .ToDictionaryAsync(s => s.Id, ct);

        // ---- Workflow tree + metadata Cấp cho LevelOpinions.
        string? wfCode = null, wfName = null;
        int? wfVersion = null;                          // [S171 BE-1] meta `{code} v{NN}`
        string? finalizeStepName = null, finalizeLevelName = null;   // [S171 BE-1b] DẪN XUẤT
        var endedByLevelFinalize = false;               // [S171 BE-1b] 3-ngả, xem dưới
        var stepDtos = new List<ContractSigningPlanWorkflowStepDto>();
        var levelMeta = new Dictionary<Guid,
            (int StepOrder, string StepName, int LevelOrder, string? LevelName, Guid ApproverUserId)>();

        if (plan.ApprovalWorkflowId is Guid wfId)
        {
            var wf = await db.ApprovalWorkflows.AsNoTracking()
                .Include(w => w.Steps.OrderBy(s => s.Order))
                    .ThenInclude(s => s.Levels.OrderBy(l => l.Order))
                .FirstOrDefaultAsync(w => w.Id == wfId, ct);
            if (wf is not null)
            {
                wfCode = wf.Code;
                wfName = wf.Name;
                wfVersion = wf.Version;
                var approverIds = wf.Steps.SelectMany(s => s.Levels).Select(l => l.ApproverUserId)
                    .Distinct().ToList();
                var approverNames = await db.Users.AsNoTracking()
                    .Where(u => approverIds.Contains(u.Id))
                    .Select(u => new { u.Id, u.FullName })
                    .ToDictionaryAsync(u => u.Id, u => u.FullName, ct);

                // [S171 BE-4] Tên phòng của Bước — `Step.DepartmentId` loose-Guid (không nav) ⇒
                // 1 lượt truy gom, KHÔNG N+1 trong vòng lặp.
                var stepDeptIds = wf.Steps.Where(s => s.DepartmentId.HasValue)
                    .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))
                        .Select(d => new { d.Id, d.Name })
                        .ToDictionaryAsync(d => d.Id, d => d.Name, ct);

                var sortedSteps = wf.Steps.OrderBy(s => s.Order).ToList();
                for (var i = 0; i < sortedSteps.Count; i++)
                {
                    var s = sortedSteps[i];
                    stepDtos.Add(new ContractSigningPlanWorkflowStepDto(
                        s.Id, s.Order, s.Name, s.DepartmentId,
                        s.Levels.OrderBy(l => l.Order).Select(l => new ContractSigningPlanWorkflowLevelDto(
                            l.Id, l.Order, l.Name, l.ApproverUserId,
                            approverNames.TryGetValue(l.ApproverUserId, out var fn) ? fn : null,
                            l.AllowApproverFinalize)).ToList(),
                        s.DepartmentId is Guid dId && stepDeptNames.TryGetValue(dId, out var dn) ? dn : null));

                    foreach (var l in s.Levels)
                        levelMeta[l.Id] = (s.Order, s.Name, l.Order, l.Name, l.ApproverUserId);
                }

                // ---- [S171 BE-1b] "Kết thúc tại cấp" — KHÔNG có cột DB nào lưu Bước/Cấp nào đã
                // kết thúc (`ContractSigningPlan.cs:51` chỉ có bool) ⇒ DẪN XUẤT tại chỗ. Ngữ nghĩa
                // mirror PE list `PurchaseEvaluationFeatures.cs:670-685`, làm IN-MEMORY (cây đã nạp
                // đủ ở trên) thay vì port 3 subquery — cùng kết quả, bớt 3 lượt truy.
                // 🔴 KHÁC PE MỘT CHỖ, CỐ Ý: PE so `Step.Order - 1` với con-trỏ; KHKK
                // `CurrentWorkflowStepIndex` là INDEX 0-based vào list ĐÃ SORT
                // (`ContractSigningPlanWorkflowService.cs:26,422-435`), KHÔNG phải `Order - 1`.
                // Chép nguyên `Order - 1` sẽ sai ngay khi admin để Order không tuần-tự 1..N.
                var finalizeLevels = sortedSteps
                    .SelectMany((s, idx) => s.Levels.OrderBy(l => l.Order)
                        .Where(l => l.AllowApproverFinalize)
                        .Select(l => new { StepIdx = idx, Step = s, Level = l }))
                    .ToList();

                // Tên Bước/Cấp = cấp finalize ĐẦU TIÊN của quy trình (KHÔNG lọc theo con-trỏ —
                // đúng như PE `:678-685`): đây là nhãn CẤU HÌNH "quy trình này kết thúc ở đâu".
                var firstFinalize = finalizeLevels.FirstOrDefault();
                finalizeStepName = firstFinalize?.Step.Name;
                finalizeLevelName = firstFinalize?.Level.Name;

                // 3-ngả: phiếu ĐÃ DUYỆT đọc CỜ RUNTIME (finalize sớm THẬT đã xảy ra hay chưa —
                // bài S97 gotcha #78/#79: suy từ config là sai khi người duyệt bỏ tick opt-out);
                // phiếu chưa xong thì POINTER-AWARE (chỉ tính cấp finalize ở-hoặc-SAU con-trỏ, để
                // không dán banner "kết thúc sớm" khi phiếu đã QUA cấp đó và đang lên CEO);
                // không pin workflow ⇒ danh sách rỗng ⇒ false.
                var curIdx = plan.CurrentWorkflowStepIndex ?? 0;
                var curLevel = plan.CurrentApprovalLevelOrder ?? 1;
                endedByLevelFinalize = plan.Phase == ContractSigningPlanPhase.DaDuyet
                    ? plan.EndedByLevelFinalize
                    : finalizeLevels.Any(x => x.StepIdx > curIdx
                        || (x.StepIdx == curIdx && x.Level.Order >= curLevel));
            }
        }

        var lines = plan.Lines
            .OrderBy(l => l.CreatedAt).ThenBy(l => l.Id)
            .Select(l => new ContractSigningPlanLineDto(
                l.Id, l.SupplierId,
                suppliers.TryGetValue(l.SupplierId, out var sup) ? sup.Name : null,
                sup?.Code,
                l.PeReferenceAmount, l.ProposedAmount, l.ApprovedAmount, l.ContractId, l.Note,
                // [K2 S164] Tên hạng mục lấy từ CỘT DENORM của chính dòng, KHÔNG join ngược sang
                // `ContractCatalogEntries`: danh mục sửa tên/xoá mềm sau ngày ký không được phép
                // viết lại nội dung phiếu đã lập (khuôn `SignedByFullName` Mig 26).
                l.CatalogEntryId, l.TenHangMuc, l.MaHangMuc))
            .ToList();

        var dossierItems = plan.DossierItems
            .OrderBy(d => d.Kind).ThenBy(d => d.CreatedAt)
            .Select(d => new ContractSigningPlanDossierItemDto(
                d.Id, d.Kind, d.SupplierId,
                d.SupplierId.HasValue && suppliers.TryGetValue(d.SupplierId.Value, out var ds) ? ds.Name : null,
                d.Name, d.Status, d.TvgsName, d.TvgsResultAt, d.Note, d.CreatedAt))
            .ToList();

        var attachments = plan.Attachments
            .OrderByDescending(a => a.CreatedAt)
            .Select(a => new ContractSigningPlanAttachmentDto(
                a.Id, a.ContractSigningPlanDossierItemId, a.FileName, a.StoragePath,
                a.FileSize, a.ContentType, a.Purpose, a.Note, a.CreatedAt))
            .ToList();

        var opinions = plan.LevelOpinions
            .Select(o =>
            {
                levelMeta.TryGetValue(o.ApprovalWorkflowLevelId, out var meta);
                var known = levelMeta.ContainsKey(o.ApprovalWorkflowLevelId);
                return new ContractSigningPlanLevelOpinionDto(
                    o.Id, o.ApprovalWorkflowLevelId,
                    known ? meta.StepOrder : null,
                    known ? meta.StepName : null,
                    known ? meta.LevelOrder : null,
                    known ? meta.ApproverUserId : null,
                    o.Comment, o.SignedAt, o.SignedByUserId, o.SignedByFullName);
            })
            .OrderBy(o => o.StepOrder).ThenBy(o => o.LevelOrder)
            .ToList();

        // ---- [S171 BE-2/BE-3/BE-6] Lịch sử duyệt (panel 3 mục 6 của Ảnh 2).
        // 🔴 LỌC `Decision = Pending`: hàng Pending là hành-vi GỬI duyệt (`...WorkflowService.cs:204`),
        //    KHÔNG phải một lần ai đó bấm Duyệt/Trả lại/Từ chối. Để nguyên thì số `(N)` của KHKK
        //    đếm khác nghĩa với `(N)` của PE (PE dedupe ở FE `PeDetailTabs.tsx:3152-3192`) — trong
        //    khi cả đợt này là "cho đồng nhất". KHÔNG mất thông tin: mọi transition kể cả Gửi duyệt
        //    đều có 1 hàng ở `ContractSigningPlanChangelogs` (cùng hàm ghi `:546`) → khối
        //    "Lịch sử thay đổi" vẫn hiện đủ.
        var approvalRows = await db.ContractSigningPlanApprovals.AsNoTracking()
            .Where(a => a.ContractSigningPlanId == plan.Id && a.Decision != ApprovalDecision.Pending)
            .OrderByDescending(a => a.ApprovedAt)
            .ToListAsync(ct);

        // [BE-3] Tên người duyệt. `ContractSigningPlanApproval` KHÔNG denorm tên (khác hàng
        // changelog anh-em `:546-557` CÓ `UserName`).
        // 🔴 Chọn JOIN `Users`, KHÔNG lấy tên từ changelog cùng transition: 2 hàng đó ghi cùng
        //    unit-of-work nhưng KHÔNG có khoá nối chính xác — `Approval.ApprovedAt` là `now` của
        //    domain còn `Changelog.CreatedAt` do AuditingInterceptor đóng lúc SaveChanges ⇒ lệch ms,
        //    ghép lại phải dùng "bucket thời gian" (đúng cái hack 5s PE đang phải chịu ở FE).
        //    Phương án denorm thêm cột tên vào Approval thì cần MIGRATION — ngoài phạm vi đợt UI này.
        var approverActorIds = approvalRows
            .Where(a => a.ApprovedByUserId.HasValue).Select(a => a.ApprovedByUserId!.Value)
            .Distinct().ToList();
        var approverActorNames = approverActorIds.Count == 0
            ? new Dictionary<Guid, string>()
            : await db.Users.AsNoTracking()
                .Where(u => approverActorIds.Contains(u.Id))
                .Select(u => new { u.Id, u.FullName })
                .ToDictionaryAsync(u => u.Id, u => u.FullName, ct);

        var approvals = approvalRows
            .Select(a =>
            {
                var known = a.ApprovalWorkflowLevelId is Guid lid && levelMeta.ContainsKey(lid);
                var meta = known ? levelMeta[a.ApprovalWorkflowLevelId!.Value] : default;
                return new ContractSigningPlanApprovalDto(
                    a.Id, a.FromPhase, a.ToPhase, a.Decision, a.Comment,
                    a.ApprovalWorkflowLevelId,
                    known ? meta.StepOrder : null,
                    known ? meta.StepName : null,
                    known ? meta.LevelOrder : null,
                    known ? meta.LevelName : null,
                    a.ApprovedByUserId,
                    a.ApprovedByUserId is Guid uid && approverActorNames.TryGetValue(uid, out var an)
                        ? an : null,
                    a.ApprovedAt);
            })
            .ToList();

        return new ContractSigningPlanDetailDto(
            plan.Id, plan.MaKeHoach, plan.PurchaseEvaluationId, pe?.MaPhieu, pe?.TenGoiThau,
            peWorkItemName,
            plan.ProjectId, project?.Name, project?.Code,
            plan.DepartmentId, deptName, plan.DrafterUserId, drafterName,
            plan.Phase, plan.ApprovalWorkflowId, wfCode, wfName,
            plan.CurrentWorkflowStepIndex, plan.CurrentApprovalLevelOrder,
            plan.SlaDeadline, plan.HoSoLink, plan.GhiChu,
            plan.CreatedAt, plan.UpdatedAt,
            lines, dossierItems, attachments, opinions, stepDtos,
            plan.ApprovalGroup,
            // [K2 S164] `BudgetFrozen` = CỜ HIỂN THỊ dẫn xuất từ Phase, KHÔNG phải cột DB. Số đã
            // được đóng băng ở tầng dữ liệu bởi `ApprovedAmount ??=` tại choke-point finalize
            // (`ContractSigningPlanWorkflowService.ApplyApprovedValuesOnFinalize`); cờ này chỉ
            // bảo FE "đọc `ApprovedAmount` + khoá ô" thay vì đọc `ProposedAmount`.
            plan.Phase == ContractSigningPlanPhase.DaDuyet,
            // [S171 BE-1/BE-1b/BE-2] 5 field nuôi panel 3.
            wfVersion, endedByLevelFinalize, finalizeStepName, finalizeLevelName, approvals,
            // [S177] mã hạng mục CÔNG VIỆC DỰ ÁN — cùng lượt đọc `WorkItems` với `peWorkItemName`.
            peWorkItemCode);
    }
}

// ======================= Region 4: LIST / INBOX / ĐÃ XOÁ ============================

/// <param name="ApprovalGroup">
/// [K2 S164] Lọc theo nhóm duyệt N1..N8. K2 là CHỦ tham số này; cây 8 folder (K5) và các
/// màn con (K4b) chỉ TIÊU THỤ — thêm chỗ lọc thứ hai ở tầng trên là đẻ hai định nghĩa
/// "thuộc nhóm N" rồi lệch nhau. `null` = mọi nhóm (giữ nguyên hành vi trước K2).
/// </param>
public record ListContractSigningPlansQuery(
    ContractSigningPlanPhase? Phase = null,
    Guid? ProjectId = null,
    Guid? PurchaseEvaluationId = null,
    bool PendingMe = false,
    int? ApprovalGroup = null) : PagedRequest, IRequest<PagedResult<ContractSigningPlanListItemDto>>;

public class ListContractSigningPlansQueryHandler(IApplicationDbContext db, ICurrentUser currentUser)
    : IRequestHandler<ListContractSigningPlansQuery, PagedResult<ContractSigningPlanListItemDto>>
{
    public async Task<PagedResult<ContractSigningPlanListItemDto>> Handle(
        ListContractSigningPlansQuery request, CancellationToken ct)
    {
        var q = db.ContractSigningPlans.AsNoTracking().AsQueryable();

        // 🔴 IDOR scope (bài 8b S155: "handler MỚI mặc định KHÔNG có rào nào"). Mirror
        // `PeSoftDeleteFeatures.cs:213-229` bỏ vế eligible-phase-theo-role — module này
        // KHÔNG có bản đồ role→phase (workflow V2 thuần, phase do quy trình quyết).
        // Non-admin thấy: phiếu MÌNH soạn ∪ phiếu đã rời nháp thuộc quy trình mình có chân duyệt.
        // (Muốn nới cho cả phòng thì thêm vế `DepartmentId` ở ĐÂY — 1 chỗ, cả list lẫn màn xoá.)
        if (!currentUser.Roles.Contains(AppRoles.Admin))
        {
            var userId = currentUser.UserId;
            var userWfIds = await ContractSigningPlanScope
                .ResolveUserApprovalWorkflowIdsAsync(db, userId, ct);
            q = q.Where(p => p.DrafterUserId == userId
                || (p.Phase != ContractSigningPlanPhase.DangSoanThao
                    && p.ApprovalWorkflowId != null
                    && userWfIds.Contains(p.ApprovalWorkflowId.Value)));
        }

        if (request.Phase is not null) q = q.Where(p => p.Phase == request.Phase);
        if (request.ProjectId is not null) q = q.Where(p => p.ProjectId == request.ProjectId);
        if (request.PurchaseEvaluationId is not null)
            q = q.Where(p => p.PurchaseEvaluationId == request.PurchaseEvaluationId);
        // [K2 S164] Lọc nhóm duyệt — đặt CÙNG chỗ với các vế lọc khác, SAU vế IDOR ở trên:
        // lọc không bao giờ được là thứ NỚI phạm vi thấy được, chỉ được thu hẹp.
        if (request.ApprovalGroup is not null)
            q = q.Where(p => p.ApprovalGroup == request.ApprovalGroup);

        if (request.PendingMe)
        {
            var inboxIds = currentUser.Roles.Contains(AppRoles.Admin)
                ? null
                : await ContractSigningPlanScope.ResolveV2InboxIdsAsync(db, currentUser.UserId, ct);
            q = inboxIds is null
                ? q.Where(p => ContractSigningPlanScope.AdminInboxPhases.Contains(p.Phase))
                : q.Where(p => inboxIds.Contains(p.Id));
        }

        if (!string.IsNullOrWhiteSpace(request.Search))
        {
            var kw = request.Search.Trim();
            q = q.Where(p => (p.MaKeHoach != null && p.MaKeHoach.Contains(kw))
                || (p.GhiChu != null && p.GhiChu.Contains(kw)));
        }

        q = request.SortDesc
            ? q.OrderByDescending(p => p.UpdatedAt ?? p.CreatedAt)
            : q.OrderBy(p => p.UpdatedAt ?? p.CreatedAt);

        return await ProjectPagedAsync(db, q, request.Page, request.PageSize, ct);
    }

    /// Projection dùng chung list sống + list đã xoá (2 handler, 1 hình dạng DTO).
    internal static async Task<PagedResult<ContractSigningPlanListItemDto>> ProjectPagedAsync(
        IApplicationDbContext db,
        IQueryable<ContractSigningPlan> q,
        int page, int pageSize, CancellationToken ct)
    {
        var total = await q.CountAsync(ct);
        var items = await q
            .Skip((page - 1) * pageSize).Take(pageSize)
            .Select(p => new ContractSigningPlanListItemDto(
                p.Id, p.MaKeHoach, p.PurchaseEvaluationId,
                db.PurchaseEvaluations.Where(e => e.Id == p.PurchaseEvaluationId)
                    .Select(e => e.MaPhieu).FirstOrDefault(),
                db.PurchaseEvaluations.Where(e => e.Id == p.PurchaseEvaluationId)
                    .Select(e => e.TenGoiThau).FirstOrDefault(),
                p.ProjectId,
                db.Projects.Where(pr => pr.Id == p.ProjectId).Select(pr => pr.Name).FirstOrDefault(),
                db.Projects.Where(pr => pr.Id == p.ProjectId).Select(pr => pr.Code).FirstOrDefault(),
                p.DepartmentId,
                db.Departments.Where(d => d.Id == p.DepartmentId).Select(d => d.Name).FirstOrDefault(),
                p.DrafterUserId,
                db.Users.Where(u => u.Id == p.DrafterUserId).Select(u => u.FullName).FirstOrDefault(),
                p.Phase,
                p.ApprovalWorkflowId,
                db.ApprovalWorkflows.Where(w => w.Id == p.ApprovalWorkflowId)
                    .Select(w => w.Code).FirstOrDefault(),
                p.CurrentWorkflowStepIndex,
                p.CurrentApprovalLevelOrder,
                db.ContractSigningPlanLines.Count(l => l.ContractSigningPlanId == p.Id),
                db.ContractSigningPlanLines.Where(l => l.ContractSigningPlanId == p.Id)
                    .Sum(l => (decimal?)l.PeReferenceAmount) ?? 0m,
                db.ContractSigningPlanLines.Where(l => l.ContractSigningPlanId == p.Id)
                    .Sum(l => (decimal?)l.ProposedAmount) ?? 0m,
                p.SlaDeadline, p.CreatedAt, p.UpdatedAt,
                p.ApprovalGroup,
                // [K2 S164 — vá-8] Σ số ĐÃ CHỐT. Thiếu Σ này thì phiếu `DaDuyet` khoe số ĐỀ XUẤT
                // ở màn danh sách trong khi màn chi tiết đã khoá theo `ApprovedAmount` ⇒ hai màn
                // nói hai con số trên CÙNG một phiếu. `ApprovedAmount` vốn nullable ⇒ phiếu chưa
                // duyệt cho ra `null` → `0`, đúng nghĩa "chưa chốt đồng nào".
                db.ContractSigningPlanLines.Where(l => l.ContractSigningPlanId == p.Id)
                    .Sum(l => l.ApprovedAmount) ?? 0m,
                // [K7 S167 — vá-4a] Nguồn THẬT của đường GĐ2 → GĐ3 cho cây 4-folder (xem chú
                // thích tại DTO). Cùng khuôn collection-subquery với `WinnerSupplierNames`
                // (`CreateContractFromEvaluationFeatures.cs:185-189`) — đã chạy thật ở list PE.
                db.ContractSigningPlanLines
                    .Where(l => l.ContractSigningPlanId == p.Id && l.ContractId != null)
                    .OrderBy(l => l.CreatedAt)
                    .Select(l => l.ContractId!.Value)
                    .ToList()))
            .ToListAsync(ct);

        return new PagedResult<ContractSigningPlanListItemDto>(items, total, page, pageSize);
    }
}

/// Inbox "chờ tôi duyệt" — wire SẴN cho W3 (W2 chưa có phiếu nào ở `ChoDuyet` vì
/// transition thuộc W3; query vẫn đúng và trả rỗng).
public record GetMyContractSigningPlanInboxQuery : IRequest<List<ContractSigningPlanListItemDto>>;

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

        var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
        var q = db.ContractSigningPlans.AsNoTracking().AsQueryable();

        if (isAdmin)
        {
            // Admin: phase-list TƯỜNG MINH (mirror PE `:731-742`) — nháp KHÔNG vào inbox.
            q = q.Where(p => ContractSigningPlanScope.AdminInboxPhases.Contains(p.Phase));
        }
        else
        {
            var inboxIds = await ContractSigningPlanScope.ResolveV2InboxIdsAsync(db, currentUser.UserId, ct);
            if (inboxIds.Count == 0) return [];
            q = q.Where(p => inboxIds.Contains(p.Id));
        }

        q = q.OrderByDescending(p => p.UpdatedAt ?? p.CreatedAt);
        var paged = await ListContractSigningPlansQueryHandler.ProjectPagedAsync(db, q, 1, 100, ct);
        return paged.Items.ToList();
    }
}

/// Màn "Đã xoá" — CHỈ XEM (mirror `ListDeletedPurchaseEvaluationsQuery`).
// [K4b S166 — gate-K4b FLAG-1] +ApprovalGroup mirror list SỐNG (`:756-757`): 8 leaf "Đã xóa"
// per-nhóm trỏ `?group=n` mà endpoint này không đọc ⇒ 8 URL trả CÙNG tập mọi-nhóm; lọc client-side
// thì total/số-trang nói dối (server đếm trên tập chưa lọc) ⇒ phải lọc server-side như list sống.
public record ListDeletedContractSigningPlansQuery(int? ApprovalGroup = null)
    : PagedRequest, IRequest<PagedResult<ContractSigningPlanListItemDto>>;

public class ListDeletedContractSigningPlansQueryHandler(IApplicationDbContext db, ICurrentUser currentUser)
    : IRequestHandler<ListDeletedContractSigningPlansQuery, PagedResult<ContractSigningPlanListItemDto>>
{
    public async Task<PagedResult<ContractSigningPlanListItemDto>> Handle(
        ListDeletedContractSigningPlansQuery request, CancellationToken ct)
    {
        // 🔴 LUẬT (`ContractFeatures.cs:356-360`): `IgnoreQueryFilters()` **GỠ** filter chứ
        // KHÔNG ĐẢO nó ⇒ thiếu `.Where(x => x.IsDeleted)` là màn "Đã xoá" liệt kê TOÀN BỘ
        // phiếu sống lẫn chết. Tự kiểm: grep -rn "IgnoreQueryFilters" src/Backend --include=*.cs
        var q = db.ContractSigningPlans.AsNoTracking().IgnoreQueryFilters()
            .Where(x => x.IsDeleted);

        // IDOR scope MIRROR list sống (cùng vị ngữ) — phase ở đây là phase TẠI LÚC xoá.
        if (!currentUser.Roles.Contains(AppRoles.Admin))
        {
            var userId = currentUser.UserId;
            var userWfIds = await ContractSigningPlanScope
                .ResolveUserApprovalWorkflowIdsAsync(db, userId, ct);
            q = q.Where(p => p.DrafterUserId == userId
                || (p.Phase != ContractSigningPlanPhase.DangSoanThao
                    && p.ApprovalWorkflowId != null
                    && userWfIds.Contains(p.ApprovalWorkflowId.Value)));
        }

        // [K4b S166] Lọc nhóm — mirror list sống `:756-757`, SAU vế IDOR (lọc chỉ thu hẹp).
        if (request.ApprovalGroup is not null)
            q = q.Where(p => p.ApprovalGroup == request.ApprovalGroup);

        if (!string.IsNullOrWhiteSpace(request.Search))
        {
            var kw = request.Search.Trim();
            q = q.Where(p => p.MaKeHoach != null && p.MaKeHoach.Contains(kw));
        }

        // Mới xoá lên đầu: `AuditingInterceptor` set `UpdatedAt` ngay lượt xoá mềm.
        q = request.SortDesc
            ? q.OrderByDescending(p => p.UpdatedAt ?? p.CreatedAt)
            : q.OrderBy(p => p.UpdatedAt ?? p.CreatedAt);

        return await ListContractSigningPlansQueryHandler
            .ProjectPagedAsync(db, q, request.Page, request.PageSize, ct);
    }
}

// ==================== Region 5: DOSSIER ITEMS (căn cứ b.8-9) ========================
// CRUD chỉ mở ở {DangSoanThao, TraLai} — căn cứ là INPUT của con số đang duyệt, sửa
// giữa lúc duyệt = đổi đề bài sau lưng người duyệt.

public record UpsertContractSigningPlanDossierItemCommand(
    Guid ContractSigningPlanId,
    Guid? Id,
    DossierItemKind Kind,
    Guid? SupplierId,
    string Name,
    DossierItemStatus Status,
    string? TvgsName = null,
    DateTime? TvgsResultAt = null,
    string? Note = null) : IRequest<Guid>;

public class UpsertContractSigningPlanDossierItemCommandValidator
    : AbstractValidator<UpsertContractSigningPlanDossierItemCommand>
{
    public UpsertContractSigningPlanDossierItemCommandValidator()
    {
        RuleFor(x => x.ContractSigningPlanId).NotEmpty();
        RuleFor(x => x.Kind).IsInEnum();
        RuleFor(x => x.Status).IsInEnum();
        // MaxLength MATCH EF `ContractSigningPlanDossierItemConfiguration.cs:18-20`.
        RuleFor(x => x.Name).NotEmpty().MaximumLength(500);
        RuleFor(x => x.TvgsName).MaximumLength(200);
        RuleFor(x => x.Note).MaximumLength(1000);
    }
}

public class UpsertContractSigningPlanDossierItemCommandHandler(
    IApplicationDbContext db,
    ICurrentUser currentUser) : IRequestHandler<UpsertContractSigningPlanDossierItemCommand, Guid>
{
    public async Task<Guid> Handle(
        UpsertContractSigningPlanDossierItemCommand request, CancellationToken ct)
    {
        var plan = await db.ContractSigningPlans
            .FirstOrDefaultAsync(p => p.Id == request.ContractSigningPlanId, ct)
            ?? throw new NotFoundException("ContractSigningPlan", request.ContractSigningPlanId);
        ContractSigningPlanScope.EnsureDraftEditable(plan, currentUser);

        ContractSigningPlanDossierItem item;
        ChangelogAction action;
        if (request.Id is Guid itemId)
        {
            item = await db.ContractSigningPlanDossierItems
                .FirstOrDefaultAsync(d => d.Id == itemId && d.ContractSigningPlanId == plan.Id, ct)
                ?? throw new NotFoundException("ContractSigningPlanDossierItem", itemId);
            action = ChangelogAction.Update;
        }
        else
        {
            item = new ContractSigningPlanDossierItem { ContractSigningPlanId = plan.Id };
            db.ContractSigningPlanDossierItems.Add(item);
            action = ChangelogAction.Insert;
        }

        item.Kind = request.Kind;
        item.SupplierId = request.SupplierId;
        item.Name = request.Name.Trim();
        item.Status = request.Status;
        item.TvgsName = string.IsNullOrWhiteSpace(request.TvgsName) ? null : request.TvgsName.Trim();
        item.TvgsResultAt = request.TvgsResultAt;
        item.Note = string.IsNullOrWhiteSpace(request.Note) ? null : request.Note.Trim();

        db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
        {
            ContractSigningPlanId = plan.Id,
            EntityType = ContractSigningPlanEntityType.DossierItem,
            EntityId = item.Id,
            Action = action,
            PhaseAtChange = plan.Phase,
            UserId = currentUser.UserId,
            UserName = currentUser.FullName ?? currentUser.Email,
            Summary = $"{(action == ChangelogAction.Insert ? "Thêm" : "Cập nhật")} căn cứ: {item.Name}",
        });

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

public record DeleteContractSigningPlanDossierItemCommand(Guid ContractSigningPlanId, Guid Id) : IRequest;

public class DeleteContractSigningPlanDossierItemCommandHandler(
    IApplicationDbContext db,
    ICurrentUser currentUser) : IRequestHandler<DeleteContractSigningPlanDossierItemCommand>
{
    public async Task Handle(DeleteContractSigningPlanDossierItemCommand request, CancellationToken ct)
    {
        var plan = await db.ContractSigningPlans
            .FirstOrDefaultAsync(p => p.Id == request.ContractSigningPlanId, ct)
            ?? throw new NotFoundException("ContractSigningPlan", request.ContractSigningPlanId);
        ContractSigningPlanScope.EnsureDraftEditable(plan, currentUser);

        var item = await db.ContractSigningPlanDossierItems
            .FirstOrDefaultAsync(d => d.Id == request.Id && d.ContractSigningPlanId == plan.Id, ct)
            ?? throw new NotFoundException("ContractSigningPlanDossierItem", request.Id);

        // `Remove()` = XOÁ MỀM: `AuditingInterceptor.cs:54-63` đổi state Deleted→Modified rồi
        // set IsDeleted/DeletedAt/DeletedBy. Row ở lại ⇒ FK changelog vẫn hợp lệ.
        db.ContractSigningPlanDossierItems.Remove(item);

        db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
        {
            ContractSigningPlanId = plan.Id,
            EntityType = ContractSigningPlanEntityType.DossierItem,
            EntityId = item.Id,
            Action = ChangelogAction.Delete,
            PhaseAtChange = plan.Phase,
            UserId = currentUser.UserId,
            UserName = currentUser.FullName ?? currentUser.Email,
            Summary = $"Xoá căn cứ: {item.Name}",
        });

        await db.SaveChangesAsync(ct);
    }
}

// ========================== Region 6: ATTACHMENTS ==================================
// 🔴 MỞ Ở MỌI PHASE (triết lý PE S147): file là BẰNG CHỨNG, người duyệt phải bổ sung
// được scan kết quả TVGS ngay lúc đang duyệt. Chỉ CĂN CỨ (region 5) mới khoá theo phase.
// Mirror `PurchaseEvaluationAttachmentFeatures.cs` (IFileStorage + sanitize + best-effort delete).

public record UploadContractSigningPlanAttachmentCommand(
    Guid ContractSigningPlanId,
    Guid? DossierItemId,
    string FileName,
    string ContentType,
    long FileSize,
    Stream Content,
    ContractSigningPlanAttachmentPurpose Purpose,
    string? Note) : IRequest<ContractSigningPlanAttachmentDto>;

public class UploadContractSigningPlanAttachmentCommandValidator
    : AbstractValidator<UploadContractSigningPlanAttachmentCommand>
{
    private const long MaxBytes = 20L * 1024 * 1024;

    private static readonly HashSet<string> AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase)
    {
        "application/pdf",
        "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
        "application/msword",
        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        "application/vnd.ms-excel",
        "image/png",
        "image/jpeg",
        "image/webp",
    };

    public UploadContractSigningPlanAttachmentCommandValidator()
    {
        RuleFor(x => x.ContractSigningPlanId).NotEmpty();
        // MaxLength MATCH EF `ContractSigningPlanAttachmentConfiguration.cs:24,26,28`
        // (FileName 500 / ContentType 200 / Note 1000) — RỘNG hơn PE (255/100/500) vì
        // config KHKK theo khuôn Proposal, KHÔNG copy số của PE.
        RuleFor(x => x.FileName).NotEmpty().MaximumLength(500);
        RuleFor(x => x.ContentType).NotEmpty().MaximumLength(200);
        RuleFor(x => x.FileSize).GreaterThan(0).LessThanOrEqualTo(MaxBytes)
            .WithMessage("File vượt quá 20 MB.");
        RuleFor(x => x.ContentType).Must(c => AllowedContentTypes.Contains(c))
            .WithMessage("Định dạng file không được hỗ trợ. Cho phép: pdf, doc(x), xls(x), png, jpg, webp.");
        RuleFor(x => x.Purpose).IsInEnum();
        RuleFor(x => x.Note).MaximumLength(1000);
    }
}

public class UploadContractSigningPlanAttachmentCommandHandler(
    IApplicationDbContext db,
    IFileStorage storage,
    ICurrentUser currentUser)
    : IRequestHandler<UploadContractSigningPlanAttachmentCommand, ContractSigningPlanAttachmentDto>
{
    public async Task<ContractSigningPlanAttachmentDto> Handle(
        UploadContractSigningPlanAttachmentCommand request, CancellationToken ct)
    {
        var plan = await db.ContractSigningPlans
            .FirstOrDefaultAsync(p => p.Id == request.ContractSigningPlanId, ct)
            ?? throw new NotFoundException("ContractSigningPlan", request.ContractSigningPlanId);

        // [YC-016 @S183 — "(c) THEO TAY CẦM PHIẾU"] CHỈ rẽ nhánh file-khi-duyệt; đường
        // căn-cứ dossier (purpose khác, người soạn upload lúc soạn) GIỮ NGUYÊN hành vi cũ.
        if (request.Purpose == ContractSigningPlanAttachmentPurpose.ApprovalAttachment)
            await ContractSigningPlanWorkflowGuards.EnsureApprovalAttachmentUploaderAsync(
                db, plan, currentUser.UserId, ct);

        // Căn cứ gắn kèm (nếu có) phải thuộc ĐÚNG phiếu này — `DossierItemId` chỉ có index,
        // KHÔNG FK vật lý (`ContractSigningPlanAttachment.cs:14-19` — tránh multiple cascade
        // path) ⇒ DB không kiểm hộ, handler phải tự kiểm.
        if (request.DossierItemId is Guid did)
        {
            var ok = await db.ContractSigningPlanDossierItems
                .AnyAsync(d => d.Id == did && d.ContractSigningPlanId == plan.Id, ct);
            if (!ok) throw new NotFoundException("ContractSigningPlanDossierItem", did);
        }

        var attId = Guid.NewGuid();
        var safeName = SanitizeFileName(request.FileName);
        var relativePath = $"contract-signing-plans/{plan.Id}/{attId}_{safeName}";

        await storage.SaveAsync(relativePath, request.Content, ct);

        var entity = new ContractSigningPlanAttachment
        {
            Id = attId,
            ContractSigningPlanId = plan.Id,
            ContractSigningPlanDossierItemId = request.DossierItemId,
            FileName = request.FileName,
            StoragePath = relativePath,
            FileSize = request.FileSize,
            ContentType = request.ContentType,
            Purpose = request.Purpose,
            Note = string.IsNullOrWhiteSpace(request.Note) ? null : request.Note.Trim(),
        };
        db.ContractSigningPlanAttachments.Add(entity);

        db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
        {
            ContractSigningPlanId = plan.Id,
            EntityType = ContractSigningPlanEntityType.Attachment,
            EntityId = attId,
            Action = ChangelogAction.Insert,
            PhaseAtChange = plan.Phase,
            UserId = currentUser.UserId,
            UserName = currentUser.FullName ?? currentUser.Email,
            Summary = $"Tải lên file: {entity.FileName}",
        });

        await db.SaveChangesAsync(ct);

        return new ContractSigningPlanAttachmentDto(
            entity.Id, entity.ContractSigningPlanDossierItemId, entity.FileName, entity.StoragePath,
            entity.FileSize, entity.ContentType, entity.Purpose, entity.Note, entity.CreatedAt);
    }

    private static string SanitizeFileName(string name)
    {
        var baseName = Path.GetFileName(name);
        foreach (var c in Path.GetInvalidFileNameChars())
            baseName = baseName.Replace(c, '_');
        baseName = baseName.TrimStart('.');
        if (string.IsNullOrWhiteSpace(baseName)) baseName = "file";
        return baseName.Length > 200 ? baseName[^200..] : baseName;
    }
}

public record ContractSigningPlanAttachmentFile(Stream Content, string FileName, string ContentType);

public record DownloadContractSigningPlanAttachmentQuery(Guid ContractSigningPlanId, Guid AttachmentId)
    : IRequest<ContractSigningPlanAttachmentFile>;

public class DownloadContractSigningPlanAttachmentQueryHandler(
    IApplicationDbContext db,
    IFileStorage storage,
    ICurrentUser currentUser)
    : IRequestHandler<DownloadContractSigningPlanAttachmentQuery, ContractSigningPlanAttachmentFile>
{
    public async Task<ContractSigningPlanAttachmentFile> Handle(
        DownloadContractSigningPlanAttachmentQuery request, CancellationToken ct)
    {
        // [W2 S161 — F-9] cùng rào xem với detail (2 site — vá F-8 mà quên đường file thì vẫn hở).
        // Handler này phục vụ CẢ /download lẫn /view (controller dùng chung query).
        var plan = await db.ContractSigningPlans.AsNoTracking()
            .FirstOrDefaultAsync(x => x.Id == request.ContractSigningPlanId, ct)
            ?? throw new NotFoundException("ContractSigningPlan", request.ContractSigningPlanId);
        await ContractSigningPlanScope.EnsureCanViewAsync(db, currentUser, plan, ct);

        var att = await db.ContractSigningPlanAttachments.AsNoTracking()
            .FirstOrDefaultAsync(a => a.Id == request.AttachmentId
                && a.ContractSigningPlanId == request.ContractSigningPlanId, ct)
            ?? throw new NotFoundException("ContractSigningPlanAttachment", request.AttachmentId);

        var stream = await storage.OpenReadAsync(att.StoragePath, ct);
        return new ContractSigningPlanAttachmentFile(stream, att.FileName, att.ContentType);
    }
}

public record DeleteContractSigningPlanAttachmentCommand(Guid ContractSigningPlanId, Guid AttachmentId) : IRequest;

public class DeleteContractSigningPlanAttachmentCommandHandler(
    IApplicationDbContext db,
    IFileStorage storage,
    ICurrentUser currentUser) : IRequestHandler<DeleteContractSigningPlanAttachmentCommand>
{
    public async Task Handle(DeleteContractSigningPlanAttachmentCommand request, CancellationToken ct)
    {
        var att = await db.ContractSigningPlanAttachments
            .FirstOrDefaultAsync(a => a.Id == request.AttachmentId
                && a.ContractSigningPlanId == request.ContractSigningPlanId, ct)
            ?? throw new NotFoundException("ContractSigningPlanAttachment", request.AttachmentId);
        var plan = await db.ContractSigningPlans.AsNoTracking()
            .FirstOrDefaultAsync(p => p.Id == request.ContractSigningPlanId, ct);

        var storagePath = att.StoragePath;
        db.ContractSigningPlanAttachments.Remove(att);   // xoá MỀM (AuditingInterceptor)

        db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
        {
            ContractSigningPlanId = request.ContractSigningPlanId,
            EntityType = ContractSigningPlanEntityType.Attachment,
            EntityId = att.Id,
            Action = ChangelogAction.Delete,
            PhaseAtChange = plan?.Phase ?? ContractSigningPlanPhase.DangSoanThao,
            UserId = currentUser.UserId,
            UserName = currentUser.FullName ?? currentUser.Email,
            Summary = $"Xoá file: {att.FileName}",
        });

        await db.SaveChangesAsync(ct);

        // ⚠️ Row là xoá MỀM nhưng file vật lý xoá THẬT — mirror PE
        // (`PurchaseEvaluationAttachmentFeatures.cs:181-182`), best-effort.
        try { await storage.DeleteAsync(storagePath, ct); }
        catch { /* best-effort */ }
    }
}

// ========================= Region 7: DELETE phiếu (mềm) ============================

/// <param name="Reason">Lý do xoá — `DeletedBy`/`DeletedAt` chỉ trả lời "ai + lúc nào",
/// LÝ DO nằm ở changelog (khuôn `PeSoftDeleteFeatures.cs:34-36`).</param>
public record DeleteContractSigningPlanCommand(Guid Id, string? Reason = null) : IRequest;

public class DeleteContractSigningPlanCommandValidator : AbstractValidator<DeleteContractSigningPlanCommand>
{
    public DeleteContractSigningPlanCommandValidator()
    {
        RuleFor(x => x.Id).NotEmpty();
        // MaxLength MATCH EF: `ContractSigningPlanChangelog.ContextNote`
        // (`ContractSigningPlanChangelogConfiguration.cs` — 2000, cùng khuôn PE).
        RuleFor(x => x.Reason).MaximumLength(2000);
    }
}

public class DeleteContractSigningPlanCommandHandler(
    IApplicationDbContext db,
    ICurrentUser currentUser) : IRequestHandler<DeleteContractSigningPlanCommand>
{
    public async Task Handle(DeleteContractSigningPlanCommand request, CancellationToken ct)
    {
        var plan = await db.ContractSigningPlans.FirstOrDefaultAsync(p => p.Id == request.Id, ct)
            ?? throw new NotFoundException("ContractSigningPlan", request.Id);

        var isOwner = plan.DrafterUserId == currentUser.UserId;
        var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
        if (!isOwner && !isAdmin)
            throw new ForbiddenException("Chỉ người soạn kế hoạch hoặc Admin được xoá phiếu này.");

        // Allow-list {DangSoanThao, TuChoi, TraLai} — M6 spec cũ: THIẾU đường này thì lỡ tạo
        // nhầm 1 kế hoạch là phiếu PE bị khoá 409 vĩnh viễn (rào "1 PE = 1 kế hoạch sống" ở
        // Create không có cửa thoát). Xoá phiếu ĐANG duyệt vẫn cấm (cần rào đúng-lượt như PE).
        // [S177 — anh: "Trạng thái trả lại cho xóa đc luôn nhé"] +`TraLai`.
        // 🔴 ĐÂY LÀ VÁ LỆCH 2 TẦNG, không phải nới quyền mới: FE ĐÃ hiện nút Xoá cho phiếu
        // TraLai từ trước (`KhkkCreatePage` tính `canEdit` gồm cả `TraLai` ⇒ `readOnly=false`
        // ⇒ thanh nút đáy hiện) trong khi BE trả 409 — bấm được mà không xoá được. Rào hiển-thị
        // và rào API là HAI tầng độc lập; sửa một tầng là để lại đúng cái bẫy này (gotcha #82).
        // 🔸 TraLai an toàn về nghiệp-vụ: phiếu đã bị trả về tay người soạn, KHÔNG đang chiếm
        //    lượt của ai — cùng vị thế với DangSoanThao, khác hẳn phiếu đang chờ duyệt.
        if (plan.Phase is not (ContractSigningPlanPhase.DangSoanThao
                or ContractSigningPlanPhase.TuChoi
                or ContractSigningPlanPhase.TraLai))
            throw new ConflictException(
                "Chỉ xoá được kế hoạch ở trạng thái Nháp, Trả lại hoặc Từ chối. " +
                "Kế hoạch đang duyệt phải Trả lại/Từ chối trước.");

        var reason = string.IsNullOrWhiteSpace(request.Reason) ? null : request.Reason.Trim();

        // 🔴 THỨ TỰ: `Remove()` TRƯỚC, `Add(changelog)` SAU (bug T26 S155). EF chạy cascade
        // client-side NGAY tại `Remove()` (`CascadeDeleteTiming.Immediate`); changelog đã ở
        // state `Added` trước đó sẽ bị DETACH ⇒ SaveChanges ghi 0 row, im lặng, không lỗi.
        // Thêm SAU thì lượt cascade đã chạy xong lúc chưa có con nào để gỡ ⇒ vết sống.
        db.ContractSigningPlans.Remove(plan);

        db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
        {
            ContractSigningPlanId = plan.Id,
            EntityType = ContractSigningPlanEntityType.Header,
            Action = ChangelogAction.Delete,
            PhaseAtChange = plan.Phase,
            UserId = currentUser.UserId,
            UserName = currentUser.FullName ?? currentUser.Email,
            Summary = $"Xoá kế hoạch ký kết {plan.MaKeHoach ?? "(chưa có mã)"}",
            ContextNote = reason,
        });

        await db.SaveChangesAsync(ct);
    }
}

// ============ Region 8: PICKER "PE đã duyệt — chờ lập kế hoạch ký kết" =============

public record ListApprovedPeAwaitingPlanQuery : IRequest<List<ApprovedPeAwaitingPlanDto>>;

public class ListApprovedPeAwaitingPlanQueryHandler(IApplicationDbContext db)
    : IRequestHandler<ListApprovedPeAwaitingPlanQuery, List<ApprovedPeAwaitingPlanDto>>
{
    public async Task<List<ApprovedPeAwaitingPlanDto>> Handle(
        ListApprovedPeAwaitingPlanQuery request, CancellationToken ct)
    {
        // Mirror `CreateContractFromEvaluationFeatures.cs:162-174` (JOIN LEFT Users/Depts/WorkItems),
        // ĐỔI vị ngữ: bỏ `e.ContractId == null` (không liên quan GĐ2) + thêm "chưa có kế hoạch SỐNG"
        // + "có ≥1 NCC trúng thầu" (khớp đúng 2 rào của Create ⇒ picker không mời user bấm vào
        // thứ chắc chắn 409/Conflict).
        return await (
            from e in db.PurchaseEvaluations.AsNoTracking()
            // [W2 S161 — F-12] LEFT join (mirror Departments/WorkItems): Projects có global filter
            // !IsDeleted ⇒ INNER join làm PE của dự án xoá mềm BIẾN MẤT khỏi picker trong khi
            // Create vẫn nhận — lớp "vắng-mặt trông giống ổn" (bài S147 silent-vanish).
            join p0 in db.Projects.AsNoTracking() on e.ProjectId equals p0.Id into pj
            from p in pj.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 e.Phase == PurchaseEvaluationPhase.DaDuyet
                && db.PurchaseEvaluationSuppliers.Any(sw => sw.PurchaseEvaluationId == e.Id && sw.IsWinner)
            // 🔴 [K2 S164 — OG-7] ĐÃ GỠ vế `&& !db.ContractSigningPlans.Any(...)`: rào không còn
            // là "1 PE = 1 kế hoạch" mà là "1 PE = 1 kế hoạch MỖI NHÓM" ⇒ loại PE-đã-có-phiếu
            // khỏi picker sẽ chặn luôn đường lập cho nhóm khác (chính là năng lực K2 mở ra).
            // Vế đó chuyển thành DỮ LIỆU trả về (`ExistingGroups` dưới) để FE khoá đúng nhóm
            // đã dùng — rào cứng 409 vẫn nằm ở Create, picker chỉ có nhiệm vụ không mời nhầm.
            orderby e.UpdatedAt ?? e.CreatedAt descending
            select new ApprovedPeAwaitingPlanDto(
                e.Id, e.MaPhieu, e.TenGoiThau,
                e.ProjectId, p != null ? p.Name : null, p != null ? p.Code : null,
                e.DepartmentId, d != null ? d.Name : null,
                e.WorkItemId, wi != null ? wi.Name : null,
                db.PurchaseEvaluationSuppliers.Count(sw => sw.PurchaseEvaluationId == e.Id && sw.IsWinner),
                // Tổng báo giá ĐƯỢC CHỌN của các NCC trúng thầu — hình dạng subquery-có-join
                // copy y khuôn đã chạy được ở `CreateContractFromEvaluationFeatures.cs:185-189`
                // (KHÔNG dùng `Any(...)` lồng trong `Where` của subquery: sâu 2 tầng, dễ vỡ
                // dịch SQL mà chỉ nổ lúc chạy, không nổ lúc build).
                (from sw in db.PurchaseEvaluationSuppliers
                 where sw.PurchaseEvaluationId == e.Id && sw.IsWinner
                 join qt in db.PurchaseEvaluationQuotes on sw.Id equals qt.PurchaseEvaluationSupplierId
                 where qt.IsSelected
                 select (decimal?)qt.ThanhTien).Sum() ?? 0m,
                e.CreatedAt, e.UpdatedAt,
                // [F-6] tên NCC trúng thầu — mirror khuôn winner-names PE list (subquery ToList
                // trong projection, EF dịch được — tiền lệ PurchaseEvaluationFeatures list).
                (from sw in db.PurchaseEvaluationSuppliers
                 where sw.PurchaseEvaluationId == e.Id && sw.IsWinner
                 join sup in db.Suppliers on sw.SupplierId equals sup.Id
                 orderby sup.Name
                 select sup.Name).ToList(),
                // [K2 S164] Nhóm đã dùng — CÙNG HÌNH DẠNG subquery-ToList với vế trên (khuôn đã
                // chạy thật), KHÔNG `Distinct()`: rào Create bảo đảm mỗi (PE, nhóm) tối đa 1 phiếu
                // sống ⇒ danh sách vốn đã không trùng; thêm Distinct chỉ để SQL sinh phức tạp hơn.
                (from pl in db.ContractSigningPlans
                 where pl.PurchaseEvaluationId == e.Id
                     && pl.Phase != ContractSigningPlanPhase.TuChoi
                 orderby pl.ApprovalGroup
                 select pl.ApprovalGroup).ToList())).ToListAsync(ct);
    }
}

// ==================== Region 8: CHANGELOG list (S171 BE-5) ==========================
// Panel 3 mục 7 "Lịch sử thay đổi" — nguồn `ContractSigningPlanChangelogs` (ghi từ ngày 1,
// 10 write-site kể cả cầu KHKK→HĐ `CreateContractFromSigningPlanFeatures.cs:226`).
// Khuôn `ListPurchaseEvaluationChangelogsQueryHandler` (`PurchaseEvaluationFeatures.cs:1419`)
// — 🔴 NHƯNG có THÊM rào xem, xem ghi chú ở handler.

public record ContractSigningPlanChangelogDto(
    Guid Id,
    ContractSigningPlanEntityType EntityType,
    Guid? EntityId,
    ChangelogAction Action,
    ContractSigningPlanPhase? PhaseAtChange,
    Guid? UserId,
    string? UserName,
    string? Summary,
    string? FieldChangesJson,
    string? ContextNote,
    DateTime CreatedAt);

public record ListContractSigningPlanChangelogsQuery(Guid ContractSigningPlanId, int Take = 200)
    : IRequest<List<ContractSigningPlanChangelogDto>>;

public class ListContractSigningPlanChangelogsQueryHandler(
    IApplicationDbContext db, ICurrentUser currentUser)
    : IRequestHandler<ListContractSigningPlanChangelogsQuery, List<ContractSigningPlanChangelogDto>>
{
    public async Task<List<ContractSigningPlanChangelogDto>> Handle(
        ListContractSigningPlanChangelogsQuery request, CancellationToken ct)
    {
        // 🔴 AUTHZ 2 TẦNG — tầng policy `KeHoachKyKet.Read` ở controller CHƯA ĐỦ (gotcha #82):
        // policy chỉ nói "được vào module", không nói "được đọc PHIẾU NÀY". Detail đã bịt bằng
        // `EnsureCanViewAsync` (`:678-679`) ⇒ lịch sử của cùng phiếu phải bịt CÙNG VỊ NGỮ, nếu
        // không thì bịt cửa chính còn mở cửa sau (nội dung ý kiến/ghi chú duyệt nằm ở đây).
        // ⚠️ CỐ Ý KHÔNG chép PE: `PurchaseEvaluationsController.cs:281` KHÔNG có policy nào và
        //    handler PE cũng không có rào hàng — đó là lỗ #82 thứ hai đang treo, KHÔNG phải mẫu.
        var plan = await db.ContractSigningPlans.AsNoTracking()
            .FirstOrDefaultAsync(p => p.Id == request.ContractSigningPlanId, ct)
            ?? throw new NotFoundException("ContractSigningPlan", request.ContractSigningPlanId);
        await ContractSigningPlanScope.EnsureCanViewAsync(db, currentUser, plan, ct);

        return await db.ContractSigningPlanChangelogs.AsNoTracking()
            .Where(c => c.ContractSigningPlanId == request.ContractSigningPlanId)
            .OrderByDescending(c => c.CreatedAt)
            .Take(request.Take)
            .Select(c => new ContractSigningPlanChangelogDto(
                c.Id, c.EntityType, c.EntityId, c.Action, c.PhaseAtChange,
                c.UserId, c.UserName, c.Summary, c.FieldChangesJson, c.ContextNote,
                c.CreatedAt))
            .ToListAsync(ct);
    }
}
