[CLAUDE] PurchaseEvaluation: rang buoc du 4 thong tin muc 3 moi gui duyet + bypass nguoi soan trong chuoi duyet (UAT anh Kiet S60)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m38s

- Rename muc 3: "Chon NCC / TP thang thau" -> "Don vi NCC/TP duoc chon" (anh Kiet chot chu) x2 app + wording phu nhat quan
- Guard gui duyet du CA 4 (anh chot): don vi duoc chon + gia chao thau >0 + ngan sach (Budget link HOAC nhap tay) + bang so sanh dinh kem
  + BE ConflictException gop moi muc thieu 1 lan, ap ca Admin (TransitionAsync submit branch)
  + FE pre-check missingForApproval cung predicate -> disable nut + tooltip liet ke du (computeGiaChaoThau extract single-source)
- Bypass drafter-in-chain (luat GENERIC theo cap, anh chot): V2-only, BUOC DAU only - nguoi soan la approver cap k -> auto qua Cap 1..k khi gui
  + Audit 3 tang: Approval row AutoApprove per cap + LevelOpinion CHI slot chinh chu (khong gan chu ky NV bi skip) + Changelog
  + Pointer: k<max -> Cap k+1; het buoc -> Buoc 2 Cap 1; workflow 1 buoc -> terminal DaDuyet
  + TraLai resubmit ap lai idempotent (opinion UPSERT)
- Tests: +14 PeSubmitGuardAndBypassTests (240 -> 254 PASS)
- Reviewer die mid-run (gotcha #53 class) -> em main self-gate evidence-checklist PASS 0 blocker

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-12 11:53:26 +07:00
parent 6bf28bfdb4
commit 37122f0f64
7 changed files with 949 additions and 28 deletions

View File

@ -69,6 +69,21 @@ const NCC_PALETTES = [
'border-l-pink-400 bg-pink-50/40',
] as const
// Giá chào thầu của NCC/TP được chọn (winner) = sum quotes.thanhTien của winner
// supplier-row. Single source of truth — Section 3 (ChonNccSection) + pre-check
// nút "Lưu & Gửi Duyệt" cùng gọi để KHÔNG lệch predicate. Trả null khi chưa chọn
// NCC; trả số (có thể 0) khi đã chọn nhưng chưa nhập báo giá.
function computeGiaChaoThau(ev: PeDetailBundle): number | null {
const winnerSupplierRowId = ev.selectedSupplierId
? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null
: null
if (winnerSupplierRowId === null) return null
return ev.details
.flatMap(d => d.quotes)
.filter(q => q.purchaseEvaluationSupplierId === winnerSupplierRowId)
.reduce((sum, q) => sum + q.thanhTien, 0)
}
// Main detail content — flat render 3 section không tabs.
// Tên giữ PeDetailTabs để không break callsite (rename gây churn).
//
@ -143,20 +158,51 @@ export function PeDetailTabs({
const forwardPhase = evaluation.workflow.nextPhases.find(p =>
p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
// Pre-check data-completeness cho action "Lưu & Gửi Duyệt" (S60 — anh Kiệt chốt).
// CHỈ áp cho action gửi duyệt — liệt kê TẤT CẢ mục thiếu của Section 3 "Đơn vị
// NCC/TP được chọn". Predicate khớp BE guard TransitionAsync (em main song song).
// Dùng cùng computeGiaChaoThau như Section 3 để KHÔNG lệch.
const missingForApproval = useMemo(() => {
const missing: string[] = []
// 1. Chưa chọn Đơn vị NCC/TP
if (evaluation.selectedSupplierId == null) {
missing.push("Chưa chọn Đơn vị NCC/TP")
} else {
// 2. Đơn vị được chọn chưa có giá chào thầu (sum quotes.thanhTien ≤ 0).
// Chỉ check khi đã chọn (không spam khi chưa chọn — đã có mục 1).
const gia = computeGiaChaoThau(evaluation)
if (gia == null || gia <= 0) missing.push("Đơn vị được chọn chưa có giá chào thầu")
}
// 3. Chưa nhập Ngân sách (không link Budget entity VÀ không nhập manual amount)
if (evaluation.budgetId == null && (evaluation.budgetManualAmount == null || evaluation.budgetManualAmount <= 0)) {
missing.push("Chưa nhập Ngân sách")
}
// 4. Chưa đính kèm Bảng so sánh (attachment với supplier-row null — chuẩn Section 3)
if (!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null)) {
missing.push("Chưa đính kèm Bảng so sánh")
}
return missing
}, [evaluation])
const canSubmitForApproval = mode === 'workspace'
&& canEditPhase
&& !readOnly
&& forwardPhase != null
&& missingForApproval.length === 0
// Tooltip reason cho button disabled (giúp diagnose tại sao "Lưu & Gửi Duyệt"
// không bấm được — user feedback 2026-05-07).
// không bấm được — user feedback 2026-05-07). Reason cũ (workspace/canEditPhase/
// readOnly/forwardPhase) giữ nguyên; append data-completeness check S60 sau cùng.
const submitDisabledReason = !canEditPhase
? `Phiếu đã ở phase ${PurchaseEvaluationPhaseLabel[evaluation.phase]} — chỉ Bản nháp / Trả lại mới sửa + gửi được.`
: readOnly
? 'Chế độ chỉ đọc.'
: !forwardPhase
? `Workflow không có phase tiếp theo từ ${PurchaseEvaluationPhaseLabel[evaluation.phase]}. Liên hệ admin kiểm tra cấu hình quy trình.`
: null
: missingForApproval.length > 0
? `Chưa đủ thông tin mục 3 'Đơn vị NCC/TP được chọn':\n${missingForApproval.map(m => `${m}`).join('\n')}`
: null
return (
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
@ -225,7 +271,7 @@ export function PeDetailTabs({
)}
<ItemsTab ev={evaluation} readOnly={itemsReadOnly} />
</Section>
<Section title="3. Chọn NCC / TP thắng thầu">
<Section title="3. Đơn vị NCC/TP được chọn">
<ChonNccSection ev={evaluation} readOnly={readOnly} />
</Section>
<Section title="4. Ý kiến cấp duyệt (sign-off theo workflow)">
@ -1136,16 +1182,12 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly
const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId
const [createOpen, setCreateOpen] = useState(false)
// c. Giá chào thầu = sum quotes của NCC được chọn (winner)
// c. Giá chào thầu = sum quotes của NCC được chọn (winner). Dùng helper
// module-level (computeGiaChaoThau) — cùng predicate với pre-check gửi duyệt.
const winnerSupplierRowId = ev.selectedSupplierId
? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null
: null
const giaChaoThau = winnerSupplierRowId
? ev.details
.flatMap(d => d.quotes)
.filter(q => q.purchaseEvaluationSupplierId === winnerSupplierRowId)
.reduce((sum, q) => sum + q.thanhTien, 0)
: null
const giaChaoThau = computeGiaChaoThau(ev)
// d. Bản so sánh — attachments với purpose=ComparisonTable hoặc supplier-row null
const banSoSanhAttachments = ev.attachments.filter(
@ -1661,7 +1703,7 @@ function HangMucCard({
const setWinner = useMutation({
mutationFn: async (supplierId: string) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
onSuccess: () => { toast.success('Đã chọn NCC thắng.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
onSuccess: () => { toast.success('Đã chọn đơn vị NCC/TP.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
onError: e => toast.error(getErrorMessage(e)),
})
@ -1847,7 +1889,7 @@ function HangMucCard({
'rounded px-1 py-0.5',
isWinner ? 'bg-emerald-100 text-emerald-700' : 'text-slate-400 hover:bg-emerald-50 hover:text-emerald-700',
)}
title={isWinner ? 'NCC đã được chọn (winner)' : 'Chọn NCC thắng'}
title={isWinner ? 'Đơn vị NCC/TP đã được chọn' : 'Chọn đơn vị NCC/TP'}
>
<Check className="h-3 w-3" />
</button>