[CLAUDE] PurchaseEvaluation · FE-User · FE-Admin · Tests: PE note "Duyệt là KẾT THÚC quy trình (không trình CEO)" — 3 surface
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m58s

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 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-25 14:58:54 +07:00
parent a71753bde8
commit f0bca7737c
16 changed files with 417 additions and 6 deletions

View File

@ -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 (
<span
className="inline-flex shrink-0 items-center rounded bg-violet-100 px-1.5 py-0.5 text-[11px] font-semibold text-violet-800"
title={`Quy trình kết thúc tại ${finalizeStepName ?? 'cấp này'} — không trình CEO`}
>
{finalizeStepName ? `⚑ Kết thúc · ${finalizeStepName}` : '⚑ Không lên CEO'}
</span>
)
}

View File

@ -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({
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1 text-[13px] font-medium text-slate-900">
<PeUrgentChips isUrgentByPro={p.isUrgentByPro} isUrgentByCcm={p.isUrgentByCcm} />
<PeFinalizeChip endsBeforeCeo={p.endsBeforeCeo} finalizeStepName={p.finalizeStepName} />
<span className="truncate">{p.tenGoiThau}</span>
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">

View File

@ -251,6 +251,29 @@ export function PeWorkflowPanel({
)}
</div>
{/* [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 && (
<div className="rounded-md border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
{evaluation.phase === PurchaseEvaluationPhase.DaDuyet ? (
<span>
Phiếu đã kết thúc tại{' '}
<span className="font-semibold">{evaluation.finalizeStepName ?? 'cấp duyệt'}</span>
{evaluation.finalizeLevelName ? <> · {evaluation.finalizeLevelName}</> : null} không qua CEO.
</span>
) : (
<span>
Quy trình rút gọn: duyệt đến{' '}
<span className="font-semibold">{evaluation.finalizeStepName ?? 'cấp này'}</span>
{evaluation.finalizeLevelName ? <> · {evaluation.finalizeLevelName}</> : null} KẾT THÚC,
không trình CEO. Các cấp trước vẫn duyệt như thường.
</span>
)}
</div>
)}
{/* 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 && (

View File

@ -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() {
<div className="flex min-w-0 flex-1 items-center gap-1 truncate text-[13px] font-medium text-slate-900">
{/* S77 — cờ gấp PILL đồng bộ mọi danh sách: 🔴 GẤP (PRO) / 🟢 GẤP (CCM). */}
<PeUrgentChips isUrgentByPro={p.isUrgentByPro} isUrgentByCcm={p.isUrgentByCcm} />
<PeFinalizeChip endsBeforeCeo={p.endsBeforeCeo} finalizeStepName={p.finalizeStepName} />
<span className="truncate">{p.tenGoiThau}</span>
</div>
<span

View File

@ -141,6 +141,12 @@ export type PeListItem = {
// null selectedSupplierName khi joint ≥2 winner → list card ưu tiên field này,
// fallback selectedSupplierName cho phiếu 1-winner cũ.
winnerSupplierNames: string[]
// [Mig 58 AllowApproverFinalize — anh Kiệt FDC, S89] Quy trình có cấp bật "Duyệt là
// KẾT THÚC" (không trình CEO). BE first-reachable finalize theo Step.Order↑ → Level.Order↑.
// V1 (approvalWorkflowId==null) → endsBeforeCeo=false + names null. FE render PeFinalizeChip.
endsBeforeCeo: boolean
finalizeStepName: string | null
finalizeLevelName: string | null
}
export type PeSupplier = {
@ -263,6 +269,8 @@ export type PeApprovalFlowLevel = {
name: string | null
approvers: PeCurrentApprovalLevelApprover[]
status: 'Done' | 'Current' | 'Pending'
// [Mig 58 — anh Kiệt FDC, S89] Cấp này bật "Duyệt là KẾT THÚC quy trình" (không trình CEO).
allowApproverFinalize: boolean
}
export type PeApprovalFlowStep = {
@ -491,6 +499,12 @@ export type PeDetailBundle = {
currentApproval: PeCurrentApproval | null
// Mig 24 — full flow snapshot (Bước → Cấp → NV) với Status per level
approvalFlow: PeApprovalFlow | null
// [Mig 58 AllowApproverFinalize — anh Kiệt FDC, S89] Quy trình có cấp bật "Duyệt là KẾT
// THÚC" (không trình CEO). BE first-reachable finalize theo Step.Order↑ → Level.Order↑
// (dùng nơi flow THỰC kết thúc). V1 (approvalWorkflowId==null) → endsBeforeCeo=false, names null.
endsBeforeCeo: boolean
finalizeStepName: string | null
finalizeLevelName: string | null
suppliers: PeSupplier[]
details: PeDetailRow[]
approvals: PeApproval[]

View File

@ -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 (
<span
className="inline-flex shrink-0 items-center rounded bg-violet-100 px-1.5 py-0.5 text-[11px] font-semibold text-violet-800"
title={`Quy trình kết thúc tại ${finalizeStepName ?? 'cấp này'} — không trình CEO`}
>
{finalizeStepName ? `⚑ Kết thúc · ${finalizeStepName}` : '⚑ Không lên CEO'}
</span>
)
}

View File

@ -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({
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1 text-[13px] font-medium text-slate-900">
<PeUrgentChips isUrgentByPro={p.isUrgentByPro} isUrgentByCcm={p.isUrgentByCcm} />
<PeFinalizeChip endsBeforeCeo={p.endsBeforeCeo} finalizeStepName={p.finalizeStepName} />
<span className="truncate">{p.tenGoiThau}</span>
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">

View File

@ -251,6 +251,29 @@ export function PeWorkflowPanel({
)}
</div>
{/* [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 && (
<div className="rounded-md border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
{evaluation.phase === PurchaseEvaluationPhase.DaDuyet ? (
<span>
Phiếu đã kết thúc tại{' '}
<span className="font-semibold">{evaluation.finalizeStepName ?? 'cấp duyệt'}</span>
{evaluation.finalizeLevelName ? <> · {evaluation.finalizeLevelName}</> : null} không qua CEO.
</span>
) : (
<span>
Quy trình rút gọn: duyệt đến{' '}
<span className="font-semibold">{evaluation.finalizeStepName ?? 'cấp này'}</span>
{evaluation.finalizeLevelName ? <> · {evaluation.finalizeLevelName}</> : null} KẾT THÚC,
không trình CEO. Các cấp trước vẫn duyệt như thường.
</span>
)}
</div>
)}
{/* 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 && (

View File

@ -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() {
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1 text-[13px] font-medium text-slate-900">
<PeUrgentChips isUrgentByPro={p.isUrgentByPro} isUrgentByCcm={p.isUrgentByCcm} />
<PeFinalizeChip endsBeforeCeo={p.endsBeforeCeo} finalizeStepName={p.finalizeStepName} />
<span className="truncate">{p.tenGoiThau}</span>
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">

View File

@ -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<Paged<ContractListItem>>('/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<Paged<PeListItem>>('/purchase-evaluations', { params: { page: 1, pageSize: 5 } })).data,
})
const s = stats.data
return (
@ -220,6 +236,74 @@ export function UserDashboardPage() {
))}
</ul>
</section>
{/* [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. */}
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<header className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
<h2 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-violet-50 text-violet-600">
<Inbox className="h-4 w-4" />
</span>
Phiếu Duyệt NCC gần đây
</h2>
<Button variant="outline" onClick={() => navigate('/purchase-evaluations')}>
Xem tất cả
</Button>
</header>
{recentPe.isLoading && (
<div className="space-y-2 p-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-14 animate-pulse rounded-md bg-slate-100" />
))}
</div>
)}
{!recentPe.isLoading && (recentPe.data?.items?.length ?? 0) === 0 && (
<div className="p-6">
<EmptyState
icon={Inbox}
title="Chưa có phiếu nào"
description="Phiếu Duyệt NCC gần đây sẽ hiển thị tại đây."
/>
</div>
)}
<ul className="divide-y divide-slate-100">
{recentPe.data?.items?.map(p => (
<li key={p.id}>
<button
onClick={() => navigate(`/purchase-evaluations/${p.id}`)}
className="flex w-full items-center justify-between px-4 py-3 text-left transition hover:bg-slate-50"
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium text-slate-900">
{p.tenGoiThau}
</span>
<PeFinalizeChip endsBeforeCeo={p.endsBeforeCeo} finalizeStepName={p.finalizeStepName} />
<span
className={cn(
'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1 ring-inset ring-current/15',
PeDisplayStatusColor[getPeDisplayStatus(p.phase)],
)}
>
{PeDisplayStatusLabel[getPeDisplayStatus(p.phase)]}
</span>
</div>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-slate-500">
<span className="font-mono">{p.maPhieu ?? '—'}</span>
<span>·</span>
<span className="truncate">{p.projectName}</span>
</div>
</div>
</button>
</li>
))}
</ul>
</section>
</div>
)
}

View File

@ -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() {
<div className="flex min-w-0 flex-1 items-center gap-1 truncate text-[13px] font-medium text-slate-900">
{/* S77 — cờ gấp PILL đồng bộ mọi danh sách: 🔴 GẤP (PRO) / 🟢 GẤP (CCM). */}
<PeUrgentChips isUrgentByPro={p.isUrgentByPro} isUrgentByCcm={p.isUrgentByCcm} />
<PeFinalizeChip endsBeforeCeo={p.endsBeforeCeo} finalizeStepName={p.finalizeStepName} />
<span className="truncate">{p.tenGoiThau}</span>
</div>
<span

View File

@ -145,6 +145,12 @@ export type PeListItem = {
// null selectedSupplierName khi joint ≥2 winner → list card ưu tiên field này,
// fallback selectedSupplierName cho phiếu 1-winner cũ.
winnerSupplierNames: string[]
// [Mig 58 AllowApproverFinalize — anh Kiệt FDC, S89] Quy trình có cấp bật "Duyệt là
// KẾT THÚC" (không trình CEO). BE first-reachable finalize theo Step.Order↑ → Level.Order↑.
// V1 (approvalWorkflowId==null) → endsBeforeCeo=false + names null. FE render PeFinalizeChip.
endsBeforeCeo: boolean
finalizeStepName: string | null
finalizeLevelName: string | null
}
export type PeSupplier = {
@ -265,6 +271,8 @@ export type PeApprovalFlowLevel = {
name: string | null
approvers: PeCurrentApprovalLevelApprover[]
status: 'Done' | 'Current' | 'Pending'
// [Mig 58 — anh Kiệt FDC, S89] Cấp này bật "Duyệt là KẾT THÚC quy trình" (không trình CEO).
allowApproverFinalize: boolean
}
export type PeApprovalFlowStep = {
@ -495,6 +503,12 @@ export type PeDetailBundle = {
currentApproval: PeCurrentApproval | null
// Mig 24 — full flow snapshot (Bước → Cấp → NV) với Status per level
approvalFlow: PeApprovalFlow | null
// [Mig 58 AllowApproverFinalize — anh Kiệt FDC, S89] Quy trình có cấp bật "Duyệt là KẾT
// THÚC" (không trình CEO). BE first-reachable finalize theo Step.Order↑ → Level.Order↑
// (dùng nơi flow THỰC kết thúc). V1 (approvalWorkflowId==null) → endsBeforeCeo=false, names null.
endsBeforeCeo: boolean
finalizeStepName: string | null
finalizeLevelName: string | null
suppliers: PeSupplier[]
details: PeDetailRow[]
approvals: PeApproval[]

View File

@ -189,6 +189,19 @@ public class ListApprovedPurchaseEvaluationsQueryHandler(IApplicationDbContext d
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())).ToListAsync(ct); // [S69] cờ gấp + [Mig 58] winner names
select sup.Name).ToList(), // [S69] cờ gấp + [Mig 58] winner names
// [S89 Mig 58] "Duyệt là KẾT THÚC (không trình CEO)" — 3 subquery riêng mirror
// winner-names (EF-safe). Cùng DTO PurchaseEvaluationListItemDto. V1 → false/null.
(from lv in db.ApprovalWorkflowLevels
where lv.AllowApproverFinalize && lv.Step!.ApprovalWorkflowId == e.ApprovalWorkflowId
select 1).Any(),
(from lv in db.ApprovalWorkflowLevels
where lv.AllowApproverFinalize && lv.Step!.ApprovalWorkflowId == e.ApprovalWorkflowId
orderby lv.Step!.Order, lv.Order
select lv.Step!.Name).FirstOrDefault(),
(from lv in db.ApprovalWorkflowLevels
where lv.AllowApproverFinalize && lv.Step!.ApprovalWorkflowId == e.ApprovalWorkflowId
orderby lv.Step!.Order, lv.Order
select lv.Name).FirstOrDefault())).ToListAsync(ct);
}
}

View File

@ -42,7 +42,13 @@ public record PurchaseEvaluationListItemDto(
bool IsUrgentByCcm,
// [Mig 58 multi-winner] Tên các đơn vị IsWinner (FE list card join ", "; ≥2 = liên-danh).
// Thay nhãn từ SelectedSupplierName (null khi ≥2 cùng trúng → card trống). [] khi chưa chọn.
List<string> WinnerSupplierNames);
List<string> 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<PurchaseEvaluationApprovalLevelApproverDto> 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<PurchaseEvaluationLevelOpinionDto> 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

View File

@ -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<PurchaseEvaluationListItemDto>(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(),

View File

@ -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<TestApplicationDbContext>();
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
}
}