[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

@ -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

@ -70,6 +70,9 @@ type DefinitionDto = {
description: string | null
isActive: boolean
isUserSelectable: boolean // Mig 25 — admin toggle cho user pick
// S69 — ngưỡng giá trị "gói CEO". Null = không áp ngưỡng (luồng đủ quy trình).
// Gói < ngưỡng: CCM duyệt-final không cần CEO. ≥ ngưỡng: phải lên CEO.
ceoApprovalThreshold: number | null
// Mig 29 (S21 t5) + Mig 30 (S22+5) + Mig 31 (S23 t1) — 7 Allow* options
// ALL xuống per slot Level (xem LevelDto). Admin opt-in per-Approver-slot
// pattern proven 3× cumulative: F1+F3 (5 flag Mig 29) + F4 (Mig 30) + F2
@ -393,6 +396,15 @@ function DefinitionCard({
Cho user chọn
</span>
)}
{/* S69 — badge ngưỡng gói CEO (nếu có set) */}
{def.ceoApprovalThreshold != null && (
<span
className="inline-flex items-center gap-1 rounded-full bg-rose-100 px-2 py-0.5 text-[10px] font-medium text-rose-700"
title="Gói ≥ ngưỡng phải lên CEO duyệt. Gói < ngưỡng: CCM duyệt là xong."
>
Ngưỡng CEO: {def.ceoApprovalThreshold.toLocaleString('vi-VN')}đ
</span>
)}
</div>
{def.description && <p className="mt-1 text-xs leading-relaxed text-slate-500">{def.description}</p>}
@ -498,6 +510,10 @@ function Designer({
const [code, setCode] = useState(cloneFrom?.code ?? defaultCode)
const [name, setName] = useState(cloneFrom ? cloneFrom.name : `Quy trình ${applicableTypeLabel}`)
const [description, setDescription] = useState(cloneFrom?.description ?? '')
// S69 — Ngưỡng gói CEO (nullable). String form, '' = null. Clone giữ ngưỡng cũ.
const [ceoThreshold, setCeoThreshold] = useState(
cloneFrom?.ceoApprovalThreshold != null ? String(cloneFrom.ceoApprovalThreshold) : '',
)
const [steps, setSteps] = useState<EditStep[]>(initialSteps)
// Mig 29 (S21 t5) + Mig 30 (S22+5) + Mig 31 (S23 t1) — 7 Allow* options
@ -550,6 +566,8 @@ function Designer({
code,
name,
description: description || null,
// S69 — ngưỡng gói CEO: '' → null (không áp ngưỡng), else parse số.
ceoApprovalThreshold: ceoThreshold.trim() === '' ? null : Number(ceoThreshold.replace(/[^\d]/g, '')),
steps: steps.map((s, i) => ({
order: i + 1,
name: s.name,
@ -628,6 +646,20 @@ function Designer({
<Label> tả</Label>
<Textarea rows={2} value={description} onChange={e => setDescription(e.target.value)} />
</div>
{/* S69 — Ngưỡng giá trị gói CEO (nullable). Empty = không áp ngưỡng. */}
<div className="col-span-2 space-y-1.5">
<Label>Ngưỡng giá trị gói CEO (đ)</Label>
<Input
inputMode="numeric"
value={ceoThreshold === '' ? '' : Number(ceoThreshold.replace(/[^\d]/g, '')).toLocaleString('vi-VN')}
onChange={e => setCeoThreshold(e.target.value.replace(/[^\d]/g, ''))}
placeholder="Để trống = luôn theo đủ quy trình"
/>
<div className="text-[11px] leading-relaxed text-slate-400">
Gói &lt; ngưỡng: CCM duyệt xong, không cần CEO. ngưỡng: phải lên CEO.
Đ trống = luôn theo đ quy trình.
</div>
</div>
</div>
{/* Mig 29 (S21 t5) — 5 Allow* F1+F3 per slot Approver.