[CLAUDE] PurchaseEvaluation: cờ gấp PRO/CCM + CCM duyệt-final theo ngưỡng giá trị (Mig 53) + 14 test
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m41s

Yêu cầu anh Kiệt FDC (sau họp sếp). Mig 53 AddPeUrgentAndCeoApprovalThreshold — 3 AddColumn, no new table (Mig 52→53). Rollout an toàn: cột nullable, ngưỡng null = giữ luồng duyệt cũ 100% cho tới khi admin set.

B — CCM duyệt-final theo NGƯỠNG GIÁ TRỊ ("gói CEO phân quyền theo giá trị"):
- ApprovalWorkflow += CeoApprovalThreshold (decimal?, admin nhập trong Workflow Designer).
- ApproveV2Async: actor role CostControl (CCM) + winnerQuoteTotal (tổng giá NCC được chọn) < ngưỡng → DaDuyet luôn (bỏ CEO); ≥ ngưỡng → đẩy lên CEO như cũ. Ngưỡng null = luồng tuyến tính cũ. Q4 chốt nhận diện theo ROLE người duyệt.
- reviewer PASS 0 blocker: cascade-safe (Off/role không lan), tested load-bearing (CCM dưới ngưỡng → DaDuyet skip CEO).

A — cờ gấp per-vai (visibility-only, Q3 KHÔNG đổi luồng):
- PE += IsUrgentByPro (PRO đỏ) / IsUrgentByCcm (CCM xanh).
- Endpoint PUT /purchase-evaluations/{id}/urgent role-gated (Procurement→ByPro, CostControl→ByCcm, Admin→cả 2, khác→Forbidden) + notify CEO (Director) khi MỚI bật (best-effort).

FE ×2 app: Workflow Designer ô "Ngưỡng giá trị gói CEO" (fe-admin) + PE detail nút bật/tắt cờ gấp đỏ/xanh theo role + badge GẤP + hint "giá trị gói vs ngưỡng → CCM duyệt-final/cần CEO" + PE list badge gấp.
DTO: PE detail += isUrgentByPro/Ccm + winnerQuoteTotal + ceoApprovalThreshold; list += isUrgentByPro/Ccm; workflow V2 += ceoApprovalThreshold.

+14 test (292→306): PeCcmThresholdFinalizeTests 5 (B routing) + PeUrgentToggleAuthzTests 9 (A authz). Build slnx 0/0 · npm build ×2 0 err · dotnet test 306 PASS.

C (sau duyệt xong chuyển phiếu đến dự án) — chờ anh Kiệt làm chi tiết form, CHƯA làm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-17 13:27:50 +07:00
parent 1f8947e763
commit ebd7e1c42f
25 changed files with 7358 additions and 10 deletions

View File

@ -121,6 +121,13 @@ export function PeDetailTabs({
// Mig 28 (S21 t4) — F3: Approver edit Section 2 (Hạng mục + NCC + Báo giá).
const { user: currentUser } = useAuth()
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
// S69 — cờ gấp: role quyết định nút nào hiện. PRO (Procurement) → cờ ĐỎ
// (isUrgentByPro), CCM (CostControl) → cờ XANH (isUrgentByCcm), Admin → cả 2.
// BE chặn Forbidden role khác → FE chỉ ẩn nút (UX), không phải security.
const isPro = currentUser?.roles?.includes('Procurement') ?? false
const isCcm = currentUser?.roles?.includes('CostControl') ?? false
const canToggleProUrgent = isAdmin || isPro
const canToggleCcmUrgent = isAdmin || isCcm
const v2Approvers = evaluation.currentApproval?.approvers ?? []
const actorMatchesLevel = isAdmin
|| (currentUser?.id != null && v2Approvers.some(a => a.userId === currentUser.id))
@ -155,6 +162,19 @@ export function PeDetailTabs({
onError: e => toast.error(getErrorMessage(e)),
})
// S69 — toggle cờ gấp (PUT /urgent { isUrgent }). BE role-aware: PRO flip cờ ĐỎ,
// CCM flip cờ XANH, Admin set CẢ 2. FE optimistic + invalidate detail + list.
const toggleUrgent = useMutation({
mutationFn: async (isUrgent: boolean) =>
api.put(`/purchase-evaluations/${evaluation.id}/urgent`, { isUrgent }),
onSuccess: (_d, isUrgent) => {
toast.success(isUrgent ? 'Đã đánh dấu phiếu GẤP.' : 'Đã bỏ đánh dấu gấp.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
const forwardPhase = evaluation.workflow.nextPhases.find(p =>
p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
@ -223,6 +243,17 @@ export function PeDetailTabs({
<span className="text-[10px] text-slate-400" title="Phase workflow chi tiết">
({PurchaseEvaluationPhaseLabel[evaluation.phase]})
</span>
{/* S69 — badge cờ gấp: ĐỎ (PRO) / XANH-lá (CCM). Hiển thị độc lập. */}
{evaluation.isUrgentByPro && (
<span className="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-[11px] font-semibold text-red-700" title="Phòng Cung ứng (PRO) đánh dấu gấp">
🔴 GẤP (PRO)
</span>
)}
{evaluation.isUrgentByCcm && (
<span className="inline-flex items-center gap-1 rounded bg-green-100 px-1.5 py-0.5 text-[11px] font-semibold text-green-700" title="Phòng Kiểm soát chi phí (CCM) đánh dấu gấp">
🟢 GẤP (CCM)
</span>
)}
{readOnly && (
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[11px] font-medium text-slate-600">
chế đ duyệt
@ -239,6 +270,56 @@ export function PeDetailTabs({
{evaluation.workItemName && <><span></span><span>{evaluation.workItemName}</span></>}
{evaluation.drafterName && <><span>·</span><span>Soạn: {evaluation.drafterName}</span></>}
</div>
{/* S69 — nút bật/tắt cờ gấp (theo role) + hint giá trị gói vs ngưỡng CEO. */}
{(canToggleProUrgent || canToggleCcmUrgent || evaluation.ceoApprovalThreshold != null) && (
<div className="mt-1.5 flex flex-wrap items-center gap-2">
{canToggleProUrgent && (
<button
type="button"
disabled={toggleUrgent.isPending}
onClick={() => toggleUrgent.mutate(!evaluation.isUrgentByPro)}
className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-medium transition disabled:opacity-50',
evaluation.isUrgentByPro
? 'border-red-300 bg-red-50 text-red-700 hover:bg-red-100'
: 'border-slate-300 bg-white text-slate-600 hover:border-red-300 hover:text-red-700',
)}
title="Cờ ĐỎ — Phòng Cung ứng (PRO) đánh dấu gấp"
>
🔴 {evaluation.isUrgentByPro ? 'Bỏ gấp (PRO)' : 'Đánh dấu GẤP (PRO)'}
</button>
)}
{canToggleCcmUrgent && (
<button
type="button"
disabled={toggleUrgent.isPending}
onClick={() => toggleUrgent.mutate(!evaluation.isUrgentByCcm)}
className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-medium transition disabled:opacity-50',
evaluation.isUrgentByCcm
? 'border-green-300 bg-green-50 text-green-700 hover:bg-green-100'
: 'border-slate-300 bg-white text-slate-600 hover:border-green-300 hover:text-green-700',
)}
title="Cờ XANH — Phòng Kiểm soát chi phí (CCM) đánh dấu gấp"
>
🟢 {evaluation.isUrgentByCcm ? 'Bỏ gấp (CCM)' : 'Đánh dấu GẤP (CCM)'}
</button>
)}
{/* Hint giá trị gói vs ngưỡng CEO (chỉ khi workflow có set ngưỡng). */}
{evaluation.ceoApprovalThreshold != null && (
<span className="inline-flex items-center gap-1 rounded bg-slate-50 px-2 py-1 text-[11px] text-slate-600">
Giá trị gói: <strong className="text-slate-800">{fmtMoney(evaluation.winnerQuoteTotal)}đ</strong>
{' — '}
{evaluation.winnerQuoteTotal < evaluation.ceoApprovalThreshold ? (
<span className="font-medium text-emerald-600">CCM duyệt xong</span>
) : (
<span className="font-medium text-rose-600">Cần CEO duyệt</span>
)}
<span className="text-slate-400">(ngưỡng {fmtMoney(evaluation.ceoApprovalThreshold)}đ)</span>
</span>
)}
</div>
)}
</div>
{/* Header bar actions: User 2026-05-07 chốt bỏ "Sửa header" + "Xóa" +
"Đóng" (workspace mode actions chuyển xuống bottom action bar). Vẫn

View File

@ -349,7 +349,12 @@ export function PurchaseEvaluationsListPage() {
>
{/* Plan AG6 — compact card 3 row: title+badge / mã+time / drafter+dept+contract */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1 truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</div>
<div className="flex min-w-0 flex-1 items-center gap-1 truncate text-[13px] font-medium text-slate-900">
{/* S69 — chấm gấp: ĐỎ (PRO) / XANH-lá (CCM) cạnh tên gói. */}
{p.isUrgentByPro && <span className="shrink-0 text-[11px]" title="GẤP — Phòng Cung ứng (PRO)">🔴</span>}
{p.isUrgentByCcm && <span className="shrink-0 text-[11px]" title="GẤP — Phòng Kiểm soát chi phí (CCM)">🟢</span>}
<span className="truncate">{p.tenGoiThau}</span>
</div>
<span
className={cn(
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',

View File

@ -138,6 +138,9 @@ export type PeListItem = {
// S61 — 2 cột ngân sách mới (list DTO mirror detail; chưa render ở list UI)
budgetPeriodAmount: number | null
expectedRemainingAmount: number | null
// S69 — cờ gấp per-vai (PRO ĐỎ / CCM XANH). FE render chip nhỏ trên card list.
isUrgentByPro: boolean
isUrgentByCcm: boolean
}
export type PeSupplier = {
@ -434,6 +437,15 @@ export type PeDetailBundle = {
budgetPeriodAmount: number | null // 'Ngân sách - kỳ này' (row 3 Excel) — drafter nhập
expectedRemainingAmount: number | null // 'Giá trị thực hiện dự kiến còn lại' (row 8) — null = FE default NS còn lại
budgetSummary: PeBudgetSummary | null // bảng TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (BE compute)
// S69 — cờ gấp per-vai (PRO ĐỎ / CCM XANH). FE render badge header + toggle theo role.
isUrgentByPro: boolean
isUrgentByCcm: boolean
// S69 — tổng giá chào của đơn vị NCC/TP ĐƯỢC CHỌN (winner). 0 khi chưa chọn.
// FE so với ceoApprovalThreshold → hint "CCM duyệt là xong" / "Cần CEO duyệt".
winnerQuoteTotal: number
// S69 — ngưỡng gói CEO của workflow đã pin (PE.approvalWorkflowId). Null khi
// chưa pin workflow V2 hoặc admin chưa set ngưỡng.
ceoApprovalThreshold: number | null
// Mig 23 — Pin schema mới ApprovalWorkflowsV2 (User chọn lúc create).
approvalWorkflowId: string | null
approvalWorkflowCode: string | null