[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
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:
@ -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`
|
||||
}
|
||||
|
||||
|
||||
@ -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.`)
|
||||
|
||||
@ -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ý 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,
|
||||
|
||||
Reference in New Issue
Block a user