[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
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:
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user