[CLAUDE] Contract: chặn mọi đường tạo HĐ ngoài KHKK — YC-040 (106)=(b), chuỗi NCC→KHKK→HĐ khép kín
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 10m0s

Owner chốt: "(b) Chặn HẾT 7 loại — mọi HĐ đều phải từ KHKK -> Tao nghĩ nên như vậy trước đã."

BE: ContractCreationGate (1 chỗ, AllowedDirectTypes rỗng — nới = thêm loại, không sửa handler)
chặn 409 ở 2/3 write-site: POST /contracts (tạo tự do) + POST /purchase-evaluations/{id}/
create-contract (PE→HĐ 1-click nhảy cóc KHKK, di tích Phase 6). Bridge KHKK giữ nguyên
(write-site duy nhất). Gate đứng TRƯỚC mọi load — không lộ tồn tại dữ liệu. Seeder demo
là write-site miễn-trừ có khai (flag-gated).

FE ×2 app (8 lối): menu Create per-loại ẩn (Layout, policy Ct_*_Create giữ DB) · nút
"Tạo HĐ mới"/"Thêm mới" → "Tạo từ KHKK" trỏ /khkk/list (MyContracts + Dashboard +
ContractCreatePage) · ?mode=new → EmptyState chỉ đường KHKK · nút PE→HĐ ẩn (PeDetailTabs).
ContractCreatePage + PeDetailTabs giữ SHA-mirror 2 app.

Tests 697 pass + 6 skip / 0 fail (total 703): +4 gate mới (quét-trọn-enum fail-closed +
chặn-trước-NotFound + đổi message ApplicableType); 6 test multi-winner PE→HĐ chuyển
Skip-có-khai (spec di sản Mig 58, mở lại khi owner nới gate). Reviewer PASS_WITH_FIXES,
6/6 issue vá trong lượt (kể cả 3 chuỗi EmptyState/ListPanel còn mời tạo trực tiếp).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-14 19:39:28 +07:00
parent 93a19a01f2
commit 55a01643ae
20 changed files with 219 additions and 75 deletions

View File

@ -134,7 +134,12 @@ function resolvePath(key: string): string | null {
const typeInt = TYPE_CODE_TO_INT[code]
if (!typeInt) return null
if (action === 'List') return `/contracts?type=${typeInt}`
if (action === 'Create') return `/contracts/new?type=${typeInt}`
// [YC-040 (106)(b) @S197] Menu "Tạo mới" per-loại ẨN — mọi HĐ sinh từ KHKK ("Đưa vào HĐ").
// Menu-row + policy `Ct_*_Create` GIỮ DB; nới gate thì trả lại:
// if (action === 'Create') return `/contracts/new?type=${typeInt}`
// ⚠️ Edge chấp nhận (reviewer #5 @S197): role cấp ĐÚNG MỖI `Ct_X_Create` ⇒ header nhóm
// bung thân RỖNG (không crash; seeding hiện tại cấp rộng nên không tái hiện).
if (action === 'Create') return null
if (action === 'Pending') return `/contracts?type=${typeInt}&pendingMe=1`
}

View File

@ -1924,7 +1924,11 @@ function SuggestedPriceRows({ ev }: { ev: PeDetailBundle }) {
// ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) =====
function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.suppliers.some(s => s.isWinner)
// [YC-040 (106)(b) @S197] Đường PE→HĐ 1-click ĐÓNG (di tích Phase 6 trước-KHKK) — chuỗi
// đúng nay: PE (DaDuyet) → KHKK → HĐ. BE gate 409 (`ContractCreationGate`); nút ẩn theo.
// 🧊 điều kiện cũ giữ làm vết — nới gate thì khôi phục:
// !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.suppliers.some(s => s.isWinner)
const canCreateContract = false
const [createOpen, setCreateOpen] = useState(false)
// [anh Kiệt FDC S87+] c/d/e (Giá chào thầu + Đề xuất PRO/CCM + Bảng so sánh + Link hồ
@ -2183,6 +2187,7 @@ function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBun
mutationFn: async () =>
// [Mig 58 — anh Kiệt FDC] Multi-winner: BE tạo 1 HĐ / mỗi đơn vị trúng (IsWinner)
// → trả LIST contractIds. ContractType người dùng chọn áp cho mọi HĐ.
// [YC-040] KHÔNG mở lại dialog này khi gate còn đóng — BE trả 409 (ContractCreationGate).
api.post<{ contractIds: string[] }>(`/purchase-evaluations/${evaluation.id}/create-contract`, form),
onSuccess: res => {
toast.success(`Đã tạo ${res.data.contractIds.length} hợp đồng từ phiếu.`)

View File

@ -12,7 +12,7 @@
// hiển thị Chi tiết section.
import { useCallback, useState, useMemo, type FormEvent, useEffect } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link, useSearchParams } from 'react-router-dom'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { FileText, Plus, Search, Save, Pencil, Trash2, Lock } from 'lucide-react'
import { toast } from 'sonner'
import { ContractDetailsTab } from '@/components/contracts/ContractDetailsTab'
@ -69,6 +69,7 @@ const BODY_DOC_BY_KIND: Record<string, BodyDoc> = {
export function ContractCreatePage() {
const qc = useQueryClient()
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const typeFilter = searchParams.get('type') ? Number(searchParams.get('type')) : 2
const selectedId = searchParams.get('id')
@ -133,10 +134,12 @@ export function ContractCreatePage() {
}
function startNew() {
const next = new URLSearchParams(searchParams)
next.set('mode', 'new')
next.delete('id')
setSearchParams(next, { replace: false })
// [YC-040 (106)(b) @S197] Cửa tạo TRỰC TIẾP đã đóng — mọi HĐ sinh từ KHKK đã duyệt
// qua "Đưa vào HĐ" (KhkkDetailPage). Nút này nay dẫn sang KHKK thay vì mở form trống.
// 🧊 Thân cũ (mở ?mode=new) giữ làm vết — nới lại thì khôi phục:
// const next = new URLSearchParams(searchParams); next.set('mode','new');
// next.delete('id'); setSearchParams(next, { replace: false })
navigate('/khkk/list')
}
const typeLabel = ContractTypeLabel[typeFilter] ?? 'HĐ'
@ -182,7 +185,7 @@ export function ContractCreatePage() {
<EmptyState
icon={FileText}
title="Chưa có HĐ"
description="Bấm + Thêm mới phía dưới để tạo HĐ đầu tiên."
description="HĐ mới được tạo từ Kế hoạch Ký kết — bấm 'Tạo từ KHKK' phía dưới danh sách."
/>
</div>
)}
@ -263,12 +266,9 @@ export function ContractCreatePage() {
{/* + Thêm mới button — sticky cuối Panel 1 */}
<div className="shrink-0 border-t border-slate-200 bg-white p-3">
<Button
onClick={startNew}
className={cn('w-full justify-center', isNewMode && 'ring-2 ring-brand-300')}
>
<Button onClick={startNew} className="w-full justify-center">
<Plus className="h-4 w-4" />
Thêm mới
Tạo từ KHKK
</Button>
</div>
</aside>
@ -280,22 +280,23 @@ export function ContractCreatePage() {
<EmptyState
icon={FileText}
title="Chọn 1 HĐ ở danh sách bên trái"
description="Hoặc bấm + Thêm mới phía dưới list để tạo HĐ mới."
description="HĐ mới được tạo từ Kế hoạch Ký kết — bấm 'Tạo từ KHKK' phía dưới danh sách."
/>
</div>
)}
{isNewMode && (
<ContractHeaderForm
key="new"
defaultType={typeFilter}
onCreated={(newId) => {
// Sau khi tạo: refresh list + chuyển sang edit mode để user nhập Chi tiết
const next = new URLSearchParams(searchParams)
next.delete('mode')
next.set('id', newId)
setSearchParams(next, { replace: true })
}}
/>
// [YC-040 (106)(b)] ?mode=new chỉ còn tới được qua URL cũ/bookmark — chặn form,
// chỉ đường về KHKK. ContractHeaderForm giữ nguyên (không render) để nới lại rẻ.
<div className="p-6">
<EmptyState
icon={FileText}
title="Đường tạo Hợp đồng trực tiếp đã đóng"
description="Mọi Hợp đồng được tạo từ Kế hoạch Ký kết (KHKK) đã duyệt — mở phiếu KHKK, chọn dòng hạng mục rồi bấm 'Đưa vào HĐ'. Thông tin sẽ tự kế thừa theo chuỗi Duyệt NCC → KHKK → Hợp đồng."
action={
<Button onClick={() => navigate('/khkk/list')}>Mở Kế hoạch kết</Button>
}
/>
</div>
)}
{!isNewMode && selectedId && detail.data && (
<ContractEditForm
@ -317,7 +318,9 @@ export function ContractCreatePage() {
// ===== Form components =====
function ContractHeaderForm({
// [YC-040] `export` CHỈ để giữ form sống qua noUnusedLocals trong lúc cửa tạo trực tiếp
// đóng (không ai import — nới gate thì gỡ export + render lại ở block isNewMode).
export function ContractHeaderForm({
defaultType,
onCreated,
}: {
@ -379,6 +382,7 @@ function ContractHeaderForm({
const create = useMutation({
mutationFn: async () => {
// [YC-040] KHÔNG import/gọi lại form này khi gate còn đóng — BE trả 409 (ContractCreationGate).
const res = await api.post<{ id: string }>('/contracts', {
type: Number(type),
supplierId,