From f0bca7737c739732db4df88385dcbd350cb9df61 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Thu, 25 Jun 2026 14:58:54 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20PurchaseEvaluation=20=C2=B7=20FE-Use?= =?UTF-8?q?r=20=C2=B7=20FE-Admin=20=C2=B7=20Tests:=20PE=20note=20"Duy?= =?UTF-8?q?=E1=BB=87t=20l=C3=A0=20K=E1=BA=BET=20TH=C3=9AC=20quy=20tr=C3=AC?= =?UTF-8?q?nh=20(kh=C3=B4ng=20tr=C3=ACnh=20CEO)"=20=E2=80=94=203=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phiếu mà quy trình duyệt có cấp bật AllowApproverFinalize (Mig 58) sẽ kết thúc tại cấp đó, KHÔNG trình CEO. Hiển thị note cho MỌI NGƯỜI NẮM ở 3 surface (anh Kiệt FDC). KHÔNG migration (cột AllowApproverFinalize đã có Mig 58, prod ở Mig 59). BE (3 file, computed DTO): - PurchaseEvaluationDtos.cs: +bool EndsBeforeCeo +string? FinalizeStepName +string? FinalizeLevelName trên DetailBundle + ListItem; flow-level +bool AllowApproverFinalize. - PurchaseEvaluationFeatures.cs: detail compute first-reachable (Step.Order↑→Level.Order↑, dept-name in-memory); list + inbox projection qua 3 subquery EF-safe (lv.Step!.Name). V1 (workflowId==null) → false. - CreateContractFromEvaluationFeatures.cs: ListApproved projection cùng DTO → +3 subquery y hệt. FE 2-app (fe-user + fe-admin SHA-mirror component): - PeFinalizeChip.tsx (MỚI, chip tím) ở 3 danh sách: PeListPanel + PurchaseEvaluationsListPage (cây) + InboxPage. - PeWorkflowPanel.tsx: note callout trong phiếu, branch DaDuyet (past-tense) / đang-chạy; copy boundary-aware "...là KẾT THÚC, không trình CEO. Các cấp trước vẫn duyệt như thường." (tránh cấp giữa hiểu nhầm). - UserDashboardPage.tsx (chỉ fe-user): dải "Phiếu Duyệt NCC gần đây" + chip (admin dashboard để nguyên). - types: PeListItem/PeDetailBundle/PeApprovalFlowLevel +field. Tests: +PeFinalizeProjectionTests (EF-translate smoke 3 subquery + first-reachable + V1-false). Full suite 421→422. em-main self-gate sau workflow (impl-frontend return rỗng #53 → mirror fe-admin + verify 5 site recover-disk). Co-Authored-By: Claude Opus 4.8 --- fe-admin/src/components/pe/PeFinalizeChip.tsx | 23 ++++ fe-admin/src/components/pe/PeListPanel.tsx | 2 + .../src/components/pe/PeWorkflowPanel.tsx | 23 ++++ .../pages/pe/PurchaseEvaluationsListPage.tsx | 2 + fe-admin/src/types/purchaseEvaluation.ts | 14 +++ fe-user/src/components/pe/PeFinalizeChip.tsx | 23 ++++ fe-user/src/components/pe/PeListPanel.tsx | 2 + fe-user/src/components/pe/PeWorkflowPanel.tsx | 23 ++++ fe-user/src/pages/InboxPage.tsx | 2 + fe-user/src/pages/UserDashboardPage.tsx | 84 +++++++++++++ .../pages/pe/PurchaseEvaluationsListPage.tsx | 2 + fe-user/src/types/purchaseEvaluation.ts | 14 +++ .../CreateContractFromEvaluationFeatures.cs | 15 ++- .../Dtos/PurchaseEvaluationDtos.cs | 20 ++- .../PurchaseEvaluationFeatures.cs | 60 ++++++++- .../Application/PeFinalizeProjectionTests.cs | 114 ++++++++++++++++++ 16 files changed, 417 insertions(+), 6 deletions(-) create mode 100644 fe-admin/src/components/pe/PeFinalizeChip.tsx create mode 100644 fe-user/src/components/pe/PeFinalizeChip.tsx create mode 100644 tests/SolutionErp.Infrastructure.Tests/Application/PeFinalizeProjectionTests.cs diff --git a/fe-admin/src/components/pe/PeFinalizeChip.tsx b/fe-admin/src/components/pe/PeFinalizeChip.tsx new file mode 100644 index 0000000..9b4db25 --- /dev/null +++ b/fe-admin/src/components/pe/PeFinalizeChip.tsx @@ -0,0 +1,23 @@ +// PE — chip "Quy trình kết thúc tại cấp này" (không trình CEO). Hiện khi quy +// trình của phiếu có cấp bật AllowApproverFinalize (Mig 58, anh Kiệt FDC S89). +// SINGLE SOURCE OF TRUTH cho pill này dùng ở MỌI danh sách phiếu (cây danh sách +// + picker workspace/chờ-duyệt + inbox) để mọi người NẮM ngay "phiếu này duyệt +// đến đây là xong, không lên CEO" mà KHÔNG cần mở phiếu. Shape khớp PeUrgentChips. + +export function PeFinalizeChip({ + endsBeforeCeo, + finalizeStepName, +}: { + endsBeforeCeo: boolean + finalizeStepName: string | null +}) { + if (!endsBeforeCeo) return null + return ( + + {finalizeStepName ? `⚑ Kết thúc · ${finalizeStepName}` : '⚑ Không lên CEO'} + + ) +} diff --git a/fe-admin/src/components/pe/PeListPanel.tsx b/fe-admin/src/components/pe/PeListPanel.tsx index c63972d..39f3ce2 100644 --- a/fe-admin/src/components/pe/PeListPanel.tsx +++ b/fe-admin/src/components/pe/PeListPanel.tsx @@ -26,6 +26,7 @@ import { type PeListItem, } from '@/types/purchaseEvaluation' import { PeUrgentChips } from '@/components/pe/PeUrgentChips' +import { PeFinalizeChip } from '@/components/pe/PeFinalizeChip' export function PeListPanel({ typeFilter, @@ -158,6 +159,7 @@ export function PeListPanel({
+ {p.tenGoiThau}
diff --git a/fe-admin/src/components/pe/PeWorkflowPanel.tsx b/fe-admin/src/components/pe/PeWorkflowPanel.tsx index 5c91e8d..763e56e 100644 --- a/fe-admin/src/components/pe/PeWorkflowPanel.tsx +++ b/fe-admin/src/components/pe/PeWorkflowPanel.tsx @@ -251,6 +251,29 @@ export function PeWorkflowPanel({ )}
+ {/* [Mig 58 AllowApproverFinalize — anh Kiệt FDC, S89] NOTE cho MỌI người NẮM: + quy trình của phiếu này có cấp được cấu hình "Duyệt là KẾT THÚC" (không + trình CEO). Branch theo phase: đang chạy = callout xanh-lá báo trước / + Đã duyệt = xác nhận đã kết thúc tại đâu. */} + {evaluation.endsBeforeCeo && ( +
+ {evaluation.phase === PurchaseEvaluationPhase.DaDuyet ? ( + + ✅ Phiếu đã kết thúc tại{' '} + {evaluation.finalizeStepName ?? 'cấp duyệt'} + {evaluation.finalizeLevelName ? <> · {evaluation.finalizeLevelName} : null} — không qua CEO. + + ) : ( + + ⚑ Quy trình rút gọn: duyệt đến{' '} + {evaluation.finalizeStepName ?? 'cấp này'} + {evaluation.finalizeLevelName ? <> · {evaluation.finalizeLevelName} : null} là KẾT THÚC, + không trình CEO. Các cấp trước vẫn duyệt như thường. + + )} +
+ )} + {/* Mig 24 V2 — Flow render Bước → Cấp → NV thay phase cards. Status: Done (✓ emerald) / Current (● brand) / Pending (○ slate) */} {flow && flow.steps.length > 0 && ( diff --git a/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx b/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx index 7fecabd..39167b0 100644 --- a/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx +++ b/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx @@ -27,6 +27,7 @@ import { } from '@/types/purchaseEvaluation' import { PeDetailTabs } from '@/components/pe/PeDetailTabs' import { PeUrgentChips } from '@/components/pe/PeUrgentChips' +import { PeFinalizeChip } from '@/components/pe/PeFinalizeChip' import { PeWorkflowPanel } from '@/components/pe/PeWorkflowPanel' export function PurchaseEvaluationsListPage() { @@ -487,6 +488,7 @@ export function PurchaseEvaluationsListPage() {
{/* S77 — cờ gấp PILL đồng bộ mọi danh sách: 🔴 GẤP (PRO) / 🟢 GẤP (CCM). */} + {p.tenGoiThau}
+ {finalizeStepName ? `⚑ Kết thúc · ${finalizeStepName}` : '⚑ Không lên CEO'} + + ) +} diff --git a/fe-user/src/components/pe/PeListPanel.tsx b/fe-user/src/components/pe/PeListPanel.tsx index c63972d..39f3ce2 100644 --- a/fe-user/src/components/pe/PeListPanel.tsx +++ b/fe-user/src/components/pe/PeListPanel.tsx @@ -26,6 +26,7 @@ import { type PeListItem, } from '@/types/purchaseEvaluation' import { PeUrgentChips } from '@/components/pe/PeUrgentChips' +import { PeFinalizeChip } from '@/components/pe/PeFinalizeChip' export function PeListPanel({ typeFilter, @@ -158,6 +159,7 @@ export function PeListPanel({
+ {p.tenGoiThau}
diff --git a/fe-user/src/components/pe/PeWorkflowPanel.tsx b/fe-user/src/components/pe/PeWorkflowPanel.tsx index 5c91e8d..763e56e 100644 --- a/fe-user/src/components/pe/PeWorkflowPanel.tsx +++ b/fe-user/src/components/pe/PeWorkflowPanel.tsx @@ -251,6 +251,29 @@ export function PeWorkflowPanel({ )}
+ {/* [Mig 58 AllowApproverFinalize — anh Kiệt FDC, S89] NOTE cho MỌI người NẮM: + quy trình của phiếu này có cấp được cấu hình "Duyệt là KẾT THÚC" (không + trình CEO). Branch theo phase: đang chạy = callout xanh-lá báo trước / + Đã duyệt = xác nhận đã kết thúc tại đâu. */} + {evaluation.endsBeforeCeo && ( +
+ {evaluation.phase === PurchaseEvaluationPhase.DaDuyet ? ( + + ✅ Phiếu đã kết thúc tại{' '} + {evaluation.finalizeStepName ?? 'cấp duyệt'} + {evaluation.finalizeLevelName ? <> · {evaluation.finalizeLevelName} : null} — không qua CEO. + + ) : ( + + ⚑ Quy trình rút gọn: duyệt đến{' '} + {evaluation.finalizeStepName ?? 'cấp này'} + {evaluation.finalizeLevelName ? <> · {evaluation.finalizeLevelName} : null} là KẾT THÚC, + không trình CEO. Các cấp trước vẫn duyệt như thường. + + )} +
+ )} + {/* Mig 24 V2 — Flow render Bước → Cấp → NV thay phase cards. Status: Done (✓ emerald) / Current (● brand) / Pending (○ slate) */} {flow && flow.steps.length > 0 && ( diff --git a/fe-user/src/pages/InboxPage.tsx b/fe-user/src/pages/InboxPage.tsx index ebaf876..3337a07 100644 --- a/fe-user/src/pages/InboxPage.tsx +++ b/fe-user/src/pages/InboxPage.tsx @@ -26,6 +26,7 @@ import { type PeListItem, } from '@/types/purchaseEvaluation' import { PeUrgentChips } from '@/components/pe/PeUrgentChips' +import { PeFinalizeChip } from '@/components/pe/PeFinalizeChip' const fmtMoney = (v: number) => v.toLocaleString('vi-VN') @@ -288,6 +289,7 @@ export function InboxPage() {
+ {p.tenGoiThau}
diff --git a/fe-user/src/pages/UserDashboardPage.tsx b/fe-user/src/pages/UserDashboardPage.tsx index d01943f..9cb3c0b 100644 --- a/fe-user/src/pages/UserDashboardPage.tsx +++ b/fe-user/src/pages/UserDashboardPage.tsx @@ -10,9 +10,17 @@ import { EmptyState } from '@/components/EmptyState' import { Button } from '@/components/ui/Button' import { useAuth } from '@/contexts/AuthContext' import { api } from '@/lib/api' +import { cn } from '@/lib/cn' import type { Paged } from '@/types/master' import type { ContractListItem } from '@/types/contracts' import { ContractTypeLabel } from '@/types/forms' +import { PeFinalizeChip } from '@/components/pe/PeFinalizeChip' +import { + getPeDisplayStatus, + PeDisplayStatusColor, + PeDisplayStatusLabel, + type PeListItem, +} from '@/types/purchaseEvaluation' type MyDashboard = { draftsInProgress: number @@ -97,6 +105,14 @@ export function UserDashboardPage() { (await api.get>('/contracts', { params: { page: 1, pageSize: 5 } })).data, }) + // [S89 — anh Kiệt FDC] 5 Phiếu Duyệt NCC gần đây, kèm chip "Quy trình kết thúc" + // (PeFinalizeChip) để user nhìn nhanh phiếu nào duyệt-đến-đây-là-xong không lên CEO. + const recentPe = useQuery({ + queryKey: ['my-pe-recent'], + queryFn: async () => + (await api.get>('/purchase-evaluations', { params: { page: 1, pageSize: 5 } })).data, + }) + const s = stats.data return ( @@ -220,6 +236,74 @@ export function UserDashboardPage() { ))} + + {/* [S89 — anh Kiệt FDC] Phiếu Duyệt NCC gần đây — mỗi row có PhaseBadge (PE + display-status) + PeFinalizeChip ("quy trình kết thúc, không lên CEO") + + tên gói + mã phiếu. PE phase ≠ Contract phase nên dùng PE display-status pill. */} +
+
+

+ + + + Phiếu Duyệt NCC gần đây +

+ +
+ + {recentPe.isLoading && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ )} + + {!recentPe.isLoading && (recentPe.data?.items?.length ?? 0) === 0 && ( +
+ +
+ )} + +
    + {recentPe.data?.items?.map(p => ( +
  • + +
  • + ))} +
+
) } diff --git a/fe-user/src/pages/pe/PurchaseEvaluationsListPage.tsx b/fe-user/src/pages/pe/PurchaseEvaluationsListPage.tsx index 7fecabd..39167b0 100644 --- a/fe-user/src/pages/pe/PurchaseEvaluationsListPage.tsx +++ b/fe-user/src/pages/pe/PurchaseEvaluationsListPage.tsx @@ -27,6 +27,7 @@ import { } from '@/types/purchaseEvaluation' import { PeDetailTabs } from '@/components/pe/PeDetailTabs' import { PeUrgentChips } from '@/components/pe/PeUrgentChips' +import { PeFinalizeChip } from '@/components/pe/PeFinalizeChip' import { PeWorkflowPanel } from '@/components/pe/PeWorkflowPanel' export function PurchaseEvaluationsListPage() { @@ -487,6 +488,7 @@ export function PurchaseEvaluationsListPage() {
{/* S77 — cờ gấp PILL đồng bộ mọi danh sách: 🔴 GẤP (PRO) / 🟢 GẤP (CCM). */} + {p.tenGoiThau}
WinnerSupplierNames); + List WinnerSupplierNames, + // [S89 Mig 58 anh Kiệt FDC] Quy trình pin có Cấp "Duyệt là KẾT THÚC (không trình + // CEO)" → FE render chip/note trên card list + sidebar. "first-reachable finalize" + // theo Step.Order tăng → Level.Order tăng. V1 (ApprovalWorkflowId==null) → false/null. + bool EndsBeforeCeo, + string? FinalizeStepName, + string? FinalizeLevelName); public record PurchaseEvaluationSupplierDto( Guid Id, @@ -155,7 +161,10 @@ public record PurchaseEvaluationApprovalFlowLevelDto( int Order, // 1/2/3 trong Step string? Name, List Approvers, - string Status); // "Done" | "Current" | "Pending" + string Status, // "Done" | "Current" | "Pending" + // [S89 Mig 58 anh Kiệt FDC] Cấp này (OR-of-N rows cùng Order) có NV nào bật cờ + // "Duyệt là KẾT THÚC quy trình" → FE render NOTE/chip trong flow Bước/Cấp. + bool AllowApproverFinalize); public record PurchaseEvaluationApprovalFlowStepDto( int Order, // 1-based @@ -297,6 +306,13 @@ public record PurchaseEvaluationDetailBundleDto( // Mig 26 (Session 19) — Section 5 V2 dynamic. Empty list cho phiếu V1 // legacy hoặc phiếu V2 chưa có cấp nào duyệt → FE fallback message. List LevelOpinions, + // [S89 Mig 58 anh Kiệt FDC] Quy trình pin có Cấp bật "Duyệt là KẾT THÚC (không + // trình CEO)" → FE hiển thị NOTE cho MỌI người ở detail. "first-reachable + // finalize" = Step.Order tăng → Level.Order tăng (đúng nơi flow THỰC kết thúc). + // V1 (ApprovalWorkflowId==null) → EndsBeforeCeo=false, names null. + bool EndsBeforeCeo, + string? FinalizeStepName, + string? FinalizeLevelName, PurchaseEvaluationWorkflowSummaryDto Workflow); // [S61 Mig 50] Bảng "TỔNG HỢP NGÂN SÁCH TRÌNH KÝ" theo Excel anh Kiệt — BE compute diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs index 1774bfa..87a909f 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs @@ -633,7 +633,22 @@ public class ListPurchaseEvaluationsQueryHandler( where sw.PurchaseEvaluationId == x.e.Id && sw.IsWinner join sup in db.Suppliers on sw.SupplierId equals sup.Id orderby sup.Name - select sup.Name).ToList())) + select sup.Name).ToList(), + // [S89 Mig 58] "Duyệt là KẾT THÚC (không trình CEO)" qua 3 subquery riêng + // (mirror winner-names — EF-safe; record-ctor projection KHÔNG cho biến trung + // gian nên không gộp tuple). lv.Step!.Name = simple navigation chắc translate. + // ApprovalWorkflowId==null → subquery rỗng → false/null (V1 tự đúng). + (from lv in db.ApprovalWorkflowLevels + where lv.AllowApproverFinalize && lv.Step!.ApprovalWorkflowId == x.e.ApprovalWorkflowId + select 1).Any(), + (from lv in db.ApprovalWorkflowLevels + where lv.AllowApproverFinalize && lv.Step!.ApprovalWorkflowId == x.e.ApprovalWorkflowId + orderby lv.Step!.Order, lv.Order + select lv.Step!.Name).FirstOrDefault(), + (from lv in db.ApprovalWorkflowLevels + where lv.AllowApproverFinalize && lv.Step!.ApprovalWorkflowId == x.e.ApprovalWorkflowId + orderby lv.Step!.Order, lv.Order + select lv.Name).FirstOrDefault())) .ToListAsync(ct); return new PagedResult(items, total, request.Page, request.PageSize); @@ -735,7 +750,21 @@ public class GetMyPurchaseEvaluationInboxQueryHandler( where sw.PurchaseEvaluationId == x.e.Id && sw.IsWinner join sup in db.Suppliers on sw.SupplierId equals sup.Id orderby sup.Name - select sup.Name).ToList())) + select sup.Name).ToList(), + // [S89 Mig 58] "Duyệt là KẾT THÚC (không trình CEO)" — 3 subquery riêng mirror + // winner-names (EF-safe). lv.Step!.Name simple navigation. ApprovalWorkflowId + // ==null → subquery rỗng → false/null (V1 tự đúng). + (from lv in db.ApprovalWorkflowLevels + where lv.AllowApproverFinalize && lv.Step!.ApprovalWorkflowId == x.e.ApprovalWorkflowId + select 1).Any(), + (from lv in db.ApprovalWorkflowLevels + where lv.AllowApproverFinalize && lv.Step!.ApprovalWorkflowId == x.e.ApprovalWorkflowId + orderby lv.Step!.Order, lv.Order + select lv.Step!.Name).FirstOrDefault(), + (from lv in db.ApprovalWorkflowLevels + where lv.AllowApproverFinalize && lv.Step!.ApprovalWorkflowId == x.e.ApprovalWorkflowId + orderby lv.Step!.Order, lv.Order + select lv.Name).FirstOrDefault())) .Take(100) .ToListAsync(ct); } @@ -951,6 +980,13 @@ public class GetPurchaseEvaluationQueryHandler( ApprovalWorkflowOptionsDto? currentLevelOptions = null; PurchaseEvaluationCurrentApprovalDto? currentApproval = null; PurchaseEvaluationApprovalFlowDto? approvalFlow = null; + // [S89 Mig 58 anh Kiệt FDC] "Duyệt là KẾT THÚC quy trình (không trình CEO)" — + // first-reachable Cấp bật AllowApproverFinalize theo Step.Order tăng → Level.Order + // tăng. V1 (aw==null) giữ default false/null. Names dùng dept-name thân-thiện + // (mirror step-label build flow ~:1084), fallback Step.Name. + bool endsBeforeCeo = false; + string? finalizeStepName = null; + string? finalizeLevelName = null; if (e.ApprovalWorkflowId is Guid awId) { var aw = await db.ApprovalWorkflows.AsNoTracking() @@ -1075,7 +1111,10 @@ public class GetPurchaseEvaluationQueryHandler( var levelName = g.FirstOrDefault()?.Name; return new PurchaseEvaluationApprovalFlowLevelDto( g.Key, levelName, approvers, - ComputeLevelStatus(i, g.Key)); + ComputeLevelStatus(i, g.Key), + // [S89 Mig 58] OR-of-N rows cùng Order: bất kỳ NV trong cấp bật cờ + // → cấp này là điểm KẾT THÚC (FE render note trong flow). + g.Any(l => l.AllowApproverFinalize)); }).ToList(); flowSteps.Add(new PurchaseEvaluationApprovalFlowStepDto( @@ -1088,6 +1127,20 @@ public class GetPurchaseEvaluationQueryHandler( approvalFlow = new PurchaseEvaluationApprovalFlowDto(currentIdx, currentLevel, flowSteps); + // [S89 Mig 58] First-reachable finalize theo Step.Order tăng → Level.Order + // tăng (steps đã OrderBy Order). Tên Bước ưu tiên dept-name thân-thiện + // (mirror flow step-label ~:1084), fallback Step.Name. + var finalizeStep = steps.FirstOrDefault(s => s.Levels.Any(l => l.AllowApproverFinalize)); + if (finalizeStep is not null) + { + var finalizeLevel = finalizeStep.Levels + .Where(l => l.AllowApproverFinalize).OrderBy(l => l.Order).FirstOrDefault(); + endsBeforeCeo = true; + finalizeStepName = finalizeStep.DepartmentId is Guid fsd + && stepDeptNames.TryGetValue(fsd, out var fsdn) ? fsdn : finalizeStep.Name; + finalizeLevelName = finalizeLevel?.Name; + } + // CurrentApproval (legacy banner) — chỉ populate nếu Phase=ChoDuyet if (phase == PurchaseEvaluationPhase.ChoDuyet && currentIdx is int idxCur && currentLevel is int lvlCur @@ -1185,6 +1238,7 @@ public class GetPurchaseEvaluationQueryHandler( o.Opinion, o.SignedAt, o.UserId, o.UserName)) .ToList(), await BuildLevelOpinionsAsync(e, ct), + endsBeforeCeo, finalizeStepName, finalizeLevelName, // [S89 Mig 58] Duyệt = KẾT THÚC (không trình CEO) new PurchaseEvaluationWorkflowSummaryDto( policy.Name, policy.Description, policy.ActivePhases.ToList(), diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/PeFinalizeProjectionTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/PeFinalizeProjectionTests.cs new file mode 100644 index 0000000..650b7ed --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/PeFinalizeProjectionTests.cs @@ -0,0 +1,114 @@ +using Microsoft.Extensions.DependencyInjection; +using SolutionErp.Application.PurchaseEvaluations; +using SolutionErp.Domain.ApprovalWorkflowsV2; +using SolutionErp.Domain.Identity; +using SolutionErp.Domain.Master; +using SolutionErp.Domain.PurchaseEvaluations; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Application; + +// [S89 anh Kiệt FDC] PE finalize-note (Mig 58 AllowApproverFinalize) — TRANSLATION-SMOKE +// cho 3 subquery mới (EndsBeforeCeo / FinalizeStepName / FinalizeLevelName) trong list +// projection. Subquery: (from lv in db.ApprovalWorkflowLevels where lv.AllowApproverFinalize +// && lv.Step!.ApprovalWorkflowId == e.ApprovalWorkflowId orderby lv.Step!.Order, lv.Order +// select lv.Step!.Name / lv.Name).FirstOrDefault() — PHẢI translate trên EF SQLite (rủi ro +// reviewer cảnh báo). Target = ListApprovedPurchaseEvaluationsQuery (sạch nhất: chỉ +// Phase==DaDuyet, KHÔNG role-paging) — cùng subquery xuất hiện ở List + Inbox projection. +// Verify: translate (không ném) + first-reachable (Step.Order↑→Level.Order↑) + V1 null→false. +// IdentityFixture: ApproverUserId là Guid non-null (FK) → cần 1 User row thật. +public class PeFinalizeProjectionTests +{ + private static ApprovalWorkflow BuildWorkflow( + string code, Guid approverUserId, + params (string stepName, (string levelName, bool finalize)[] levels)[] steps) + { + var wf = new ApprovalWorkflow + { + Code = code, + Version = 1, + ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc, + Name = code, + IsActive = true, + IsUserSelectable = true, + }; + for (var s = 0; s < steps.Length; s++) + { + var step = new ApprovalWorkflowStep + { + ApprovalWorkflowId = wf.Id, + Order = s + 1, + Name = steps[s].stepName, + }; + for (var l = 0; l < steps[s].levels.Length; l++) + { + var (lvName, fin) = steps[s].levels[l]; + step.Levels.Add(new ApprovalWorkflowLevel + { + ApprovalWorkflowStepId = step.Id, + Order = l + 1, + Name = lvName, + ApproverUserId = approverUserId, + AllowApproverFinalize = fin, + }); + } + wf.Steps.Add(step); + } + return wf; + } + + private static Guid SeedApprovedPe(TestApplicationDbContext db, Guid projectId, Guid? wfId, string code) + { + var id = Guid.NewGuid(); + db.PurchaseEvaluations.Add(new PurchaseEvaluation + { + Id = id, + MaPhieu = code, + Type = PurchaseEvaluationType.DuyetNcc, + Phase = PurchaseEvaluationPhase.DaDuyet, // lọt ListApproved filter + TenGoiThau = $"Gói {code}", + ProjectId = projectId, + ContractId = null, // chưa gen HĐ → lọt picker + DrafterUserId = null, + ApprovalWorkflowId = wfId, + }); + return id; + } + + [Fact] + public async Task ListApproved_FinalizeFields_Translate_FirstReachable_V1False() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var approver = await fix.CreateUserAsync("fin-approver@test.local", "Approver", null, new[] { AppRoles.CostControl }); + + var project = new Project { Id = Guid.NewGuid(), Code = "FINP", Name = "Dự án Finalize" }; + db.Projects.Add(project); + + // Bước 1 (Cấp 1, no-flag) → Bước 2 (Cấp 1, FINALIZE). first-reachable = Bước 2 · Cấp 1. + var wfFin = BuildWorkflow("QT-FIN", approver.Id, + ("Bước 1", new[] { ("Cấp 1", false) }), + ("Bước 2", new[] { ("Cấp 1", true) })); + db.ApprovalWorkflows.Add(wfFin); + // Không cấp nào finalize → endsBeforeCeo=false. + var wfPlain = BuildWorkflow("QT-PLAIN", approver.Id, ("Bước 1", new[] { ("Cấp 1", false) })); + db.ApprovalWorkflows.Add(wfPlain); + + var peFin = SeedApprovedPe(db, project.Id, wfFin.Id, "PE-FIN"); + var pePlain = SeedApprovedPe(db, project.Id, wfPlain.Id, "PE-PLAIN"); + var peV1 = SeedApprovedPe(db, project.Id, null, "PE-V1"); // V1 legacy: no workflow + await db.SaveChangesAsync(CancellationToken.None); + + var handler = new ListApprovedPurchaseEvaluationsQueryHandler(db); + // Nếu 3 subquery KHÔNG translate → ném InvalidOperationException = FAIL (blocker). + var res = await handler.Handle(new ListApprovedPurchaseEvaluationsQuery(), CancellationToken.None); + + var fin = res.Single(x => x.Id == peFin); + fin.EndsBeforeCeo.Should().BeTrue(); + fin.FinalizeStepName.Should().Be("Bước 2"); // first-reachable bỏ qua Bước 1 no-flag + fin.FinalizeLevelName.Should().Be("Cấp 1"); + + res.Single(x => x.Id == pePlain).EndsBeforeCeo.Should().BeFalse(); + res.Single(x => x.Id == peV1).EndsBeforeCeo.Should().BeFalse(); // V1 null-workflow tự đúng + } +}