[CLAUDE] Contract: K7 SP-002 bridge KHKK->HD hoi-2-khoa + Line.ContractId + grant seeder (gate PASS-WITH-FLAGS 10, F-8 fixed)
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Has been cancelled

BE: POST {id}/create-contract (Contracts.Create AND KeHoachKyKet.Read) + guard 4-ve IsActive/IsUserSelectable
+ Line.ContractId write-site DAU TIEN + list-DTO ContractIds + SeedContractCreateAccessAsync (Drafter/
Procurement/Admin upgrade-only, cau III default cho ratify). FE x2 app SHA-pair 3/3: nut bridge gate 2-khoa
+ dialog khuon-san + cay GD3/GD4 doc TAP contractIds (union pe.contractId legacy). Test +17 (12 bridge T1-T12
+ 5 authz) suite 639/0; ngoai-le CreateContract trong AuthorizePolicyRegressionTests (#85).
Gate F-8 fixed truoc commit: neo so-dong :2516 chet-luc-land -> neo ten ham (3 cho) + 13/13->12/13 theo do
prod + khai chu-dich CanRead + sua tro-nham-ham. No mang theo (0-blocker): MAJOR-race-2-request-dong-thoi
(chong-trung in-memory, phuong an unique/RowVersion = nhip sau) + DraftData-khong-carry + a11y/O(n.m) FE.
UAT-NOI-TRUOC: nut sang sau restart + user RE-LOGIN (menu cache); acceptance seeder log = 0 added + 2 upgraded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-01 13:04:49 +07:00
parent c97f948a7b
commit aaed699228
13 changed files with 2241 additions and 46 deletions

View File

@ -28,7 +28,9 @@ import {
KHKK_APPROVAL_GROUPS,
KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS,
KhkkPhase,
khkkGroupMenuLabel,
khkkPlanContractIds,
type KhkkListItemDto,
type PagedResult,
} from '@/types/khkk'
@ -56,6 +58,10 @@ const KHKK_MENU_KEYS = ['KeHoachKyKet']
const CONTRACT_MENU_KEYS = ['Contracts', ...CONTRACT_TYPE_CODES.map(c => `Ct_${c}_List`)]
const HARD_COPY_MENU_KEYS = ['HopDongCung', ...CONTRACT_TYPE_CODES.map(c => `Hdc_${c}`)]
// [K7 S167 — vá-4b] Chuông DEV bắn TỐI ĐA 1 LẦN/phiên (buildStages chạy lại theo từng gói
// thầu ⇒ không có cờ này thì console ngập). Xem chỗ dùng ở GĐ3.
let warnedMissingLineContractIds = false
export type PipelineLinkage = {
canPe: boolean
canKhkk: boolean
@ -164,7 +170,8 @@ export type BuildStagesOptions = {
* GĐ1 = chính các phiếu ấy
* GĐ2 = KHKK có `purchaseEvaluationId` thuộc tập phiếu, chia thành 8 NGĂN theo
* nhóm duyệt (+ ngăn "(chưa phân nhóm)" khi có phiếu lạc) — K5
* GĐ3 = HĐ trong TẬP id của gói (xem forward-provision B3-A5 ở thân hàm)
* GĐ3 = HĐ trong TẬP id của gói — hợp của `pe.contractId` (legacy) và contract-id trên
* các DÒNG phiếu KHKK (K7 vá-4b; xem thân hàm)
* GĐ4 = đúng các HĐ ấy khi `hasSealedCopy === true`
*/
export function usePipelineStages() {
@ -269,26 +276,41 @@ export function usePipelineStages() {
// HĐ nằm ngoài index (list kẹp 200) vẫn được nêu bằng leaf tối giản thay vì
// biến mất im lặng — nhưng KHÔNG suy được `hasSealedCopy` ⇒ chỉ vào GĐ3.
//
// [K5 S166 — forward-provision B3-A5] Lấy HĐ của gói bằng một TẬP id gộp từ
// NHIỀU nguồn, thay vì đọc thẳng một ô `pe.contractId`. Hôm nay tập ấy chỉ có
// một nguồn, nhưng hình dạng đã đúng nên thêm nguồn sau là thêm một dòng.
// [K5 S166 — forward-provision B3-A5 · K7 S167 vá-4b ĐÃ CẮM NGUỒN 2] Lấy HĐ của gói
// bằng một TẬP id gộp từ NHIỀU nguồn, thay vì đọc thẳng một ô `pe.contractId`.
//
// 🔴 VÌ SAO CẦN: 1 phiếu có thể đẻ N hợp đồng (liên danh — endpoint
// `create-contract` trả `contractIds: string[]`, xem `PeDetailTabs.tsx:2186`),
// NHƯNG BE chỉ ghi ngược HĐ ĐẦU vào phiếu:
// 🔴 VÌ SAO CẦN: 1 gói có thể đẻ N hợp đồng (liên danh, và từ K7 là N dòng phiếu
// KHKK × mỗi lần bấm "Đưa vào HĐ"), NHƯNG phía PE chỉ ghi ngược HĐ ĐẦU:
// `CreateContractFromEvaluationFeatures.cs:143-145`
// "Idempotency: pe.ContractId = HĐ ĐẦU … N HĐ liên-danh truy qua changelog"
// ⇒ HĐ thứ 2 trở đi hiện KHÔNG có đường nào để cây nhìn thấy: `PeListItem`
// không có `lines[]`, `ContractListItem` không có `purchaseEvaluationId`,
// `Contract.cs` cũng không có khoá ngược. Đây là giới hạn DỮ LIỆU, không
// phải chỗ này thiếu logic — đừng "sửa" bằng cách bịa field không tồn tại.
// ⇒ KHI BE mở đường (một trong hai: list DTO trả `contractIds[]`, hoặc dòng
// phiếu có `contractId` riêng), nối vào ngay dưới đây và xoá ghi chú này.
// ⇒ đọc mỗi `pe.contractId` thì HĐ thứ 2 trở đi VÔ HÌNH trên cây (spec cụm-3 vá-4).
//
// 2 nguồn (dedupe bằng `Set` — chúng CỐ Ý chồng nhau ở HĐ đầu tiên):
// (1) `pe.contractId` — LEGACY, giữ cho phiếu PE cũ đã bắc cầu bằng đường PE→HĐ.
// (2) `khkkPlanContractIds(k)` — HĐ bắc cầu từ TỪNG DÒNG phiếu KHKK của gói. K7 ghi
// `Line.ContractId` là NGUỒN THẬT, `pe.ContractId ??=` chỉ còn là fallback hiển
// thị ⇒ N HĐ / 1 PE hiện ĐỦ, đúng OG-1 "máy rộng, UI hẹp".
const contractIdsOfPackage = new Set<string>()
for (const p of peItems) {
if (p.contractId) contractIdsOfPackage.add(p.contractId)
// ← chỗ cắm nguồn thứ hai (HĐ 2..N). Ví dụ khi có: `p.contractIds?.forEach(...)`
// hoặc `p.lines?.forEach(l => l.contractId && add(l.contractId))`.
}
for (const k of khkkItems) {
for (const cid of khkkPlanContractIds(k)) contractIdsOfPackage.add(cid)
}
// Nguồn (2) IM khi BE chưa trả field trên LIST payload — mà "im vì chưa nối" trông y
// hệt "im vì gói chưa có HĐ nào". Nói ra ở DEV thay vì để người sau tưởng cây đã đủ.
// Chỉ báo khi có phiếu ĐÃ DUYỆT (chỉ phiếu duyệt rồi mới bắc cầu được) mà KHÔNG phiếu
// nào mang field — `[]` (BE đã trả, gói chưa có HĐ) thì im, đúng như phải thế.
if (import.meta.env.DEV && !warnedMissingLineContractIds) {
const approved = khkkItems.filter(k => k.phase === KhkkPhase.DaDuyet)
if (approved.length > 0 && approved.every(k => k.contractIds == null)) {
warnedMissingLineContractIds = true
console.warn(
'[pipeline GĐ3] Phiếu KHKK đã duyệt nhưng payload list KHÔNG có `contractIds` ⇒ cây ' +
'chỉ thấy HĐ qua `pe.contractId` (HĐ ĐẦU). API cũ chưa deploy hoặc cache cũ — xem ghi ' +
'chú `types/khkk.ts` (K7 vá-4b, BE `…ListItemDto:75`).',
)
}
}
const stage3Leaves: PipelineStage['leaves'] = []
@ -296,8 +318,13 @@ export function usePipelineStages() {
// `Set` đã khử trùng ⇒ không cần biến `seen` riêng như trước.
for (const contractId of contractIdsOfPackage) {
const c = linkage.contractById.get(contractId)
// Tên gói thầu để làm dòng phụ khi HĐ nằm ngoài cửa sổ index 200.
const fallbackName = peItems.find(p => p.contractId === contractId)?.tenGoiThau ?? null
// Tên gói thầu để làm dòng phụ khi HĐ nằm ngoài cửa sổ index 200. [K7 vá-4b] tra CẢ
// 2 nguồn: HĐ đến từ dòng phiếu KHKK thì KHÔNG phiếu PE nào trỏ tới nó ⇒ chỉ tra
// `peItems` sẽ rơi về `null` và dòng phụ trống trơn.
const fallbackName =
peItems.find(p => p.contractId === contractId)?.tenGoiThau
?? khkkItems.find(k => khkkPlanContractIds(k).includes(contractId))?.peTenGoiThau
?? null
if (linkage.canContract) {
stage3Leaves.push({
id: contractId,

View File

@ -12,7 +12,8 @@ import { useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom'
import {
ArrowLeft, ClipboardList, FileCheck, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
ArrowLeft, ClipboardList, FileCheck, FileSignature, Paperclip, Pencil, Plus, Save, Trash2,
Upload, Users,
} from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/ui/PageHeader'
@ -20,17 +21,21 @@ import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { usePermission } from '@/hooks/usePermission'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import { ContractTypeLabel } from '@/types/forms'
import {
DOSSIER_KIND_LABELS, DOSSIER_STATUS_BADGE, DOSSIER_STATUS_LABELS,
DossierItemKind, DossierItemStatus, KHKK_APPROVAL_GROUP_LABELS,
KHKK_PHASE_BADGE, KHKK_PHASE_LABELS,
KhkkAttachmentPurpose, KhkkPhase,
type CreateContractFromKhkkInput, type CreateContractFromKhkkResult,
type DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto,
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue,
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkLineDto, type KhkkPhaseValue,
type UpsertKhkkDossierItemInput,
} from '@/types/khkk'
import { KhkkWorkflowPanel } from './KhkkWorkflowPanel'
@ -123,6 +128,18 @@ export function KhkkDetailPage() {
const [ghiChu, setGhiChu] = useState('')
const [hoSoLink, setHoSoLink] = useState('')
const [itemForm, setItemForm] = useState<UpsertKhkkDossierItemInput | null>(null)
const [bridgeOpen, setBridgeOpen] = useState(false)
// [K7 S167 — vá-1 vế FE] HỘI 2 KHÓA, đúng bằng server, KHÔNG lỏng hơn:
// · `KeHoachKyKet.Read` — cửa đọc chính phiếu này (`ContractSigningPlansController.cs:36`,
// `MenuPermissionHandler.cs:40` so khớp CHÍNH XÁC 1 key ⇒ KHÔNG OR `Khkk_*`, gotcha #85);
// · `Contracts.Create` — cửa của endpoint bắc cầu: tài nguyên SINH RA là Contract nên key
// phải là của endpoint đích, không phải của màn đang đứng.
// Chữ ký hook là `can(menuKey, action)` (`hooks/usePermission.ts:17`) — không có dạng
// `usePermission('X','Y')`.
const { can } = usePermission()
const canReadPlan = can('KeHoachKyKet', 'Read')
const canCreateContract = can('Contracts', 'Create')
const plan = useQuery({
queryKey: ['khkk-detail', id],
@ -276,6 +293,23 @@ export function KhkkDetailPage() {
const isDraft = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
const canDeletePlan = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TuChoi
// [K7 S167] Dòng bắc cầu được = CHƯA có HĐ ∧ ĐÃ có giá duyệt chốt — đúng 2 guard BE
// (`§K7.1` guard 4 + 5). Chặn sớm ở FE để người dùng không bấm rồi mới ăn 409.
const bridgeableLines = k.lines.filter(l => l.contractId == null && l.approvedAmount != null)
// Nút chỉ có nghĩa khi phiếu ĐÃ DUYỆT (giá mới chốt) và người dùng đọc được phiếu.
const showBridge = phase === KhkkPhase.DaDuyet && canReadPlan
// Thiếu vế nào thì NÓI RA vế đó thay vì ẩn nút: người dùng đang đứng trên phiếu, ẩn đi họ
// chỉ đoán "hệ thống không có chức năng". Khuôn nút-khoá-kèm-title `PeWorkflowPanel.tsx:547-561`.
const bridgeBlockReason = !canCreateContract
? 'Bạn chưa có quyền "Tạo hợp đồng" (Contracts.Create) — nhờ quản trị cấp quyền rồi thử lại.'
: k.lines.length === 0
? 'Phiếu chưa có dòng nhà cung cấp nào.'
: bridgeableLines.length === 0
? (k.lines.every((l) => l.contractId != null)
? 'Mọi dòng của phiếu đã được đưa vào hợp đồng.'
: 'Chưa dòng nào có giá duyệt chốt — phiếu phải qua bước chốt giá trước khi đưa vào HĐ.')
: null
const startEditHeader = () => {
setGhiChu(k.ghiChu ?? '')
setHoSoLink(k.hoSoLink ?? '')
@ -311,6 +345,21 @@ export function KhkkDetailPage() {
>
{KHKK_PHASE_LABELS[phase]}
</span>
{/* [K7 S167 — vá-1 vế FE] Nút bắc cầu KHKK → HĐ. `<span title>` bọc ngoài vì
`Button` có `disabled:pointer-events-none` (Button.tsx:12) ⇒ title đặt thẳng
trên nút đã khoá sẽ không hiện, người dùng mất luôn câu giải thích. */}
{showBridge && (
<span title={bridgeBlockReason ?? 'Tạo hợp đồng từ một dòng của kế hoạch này'}>
<Button
variant="outline"
disabled={bridgeBlockReason != null}
onClick={() => setBridgeOpen(true)}
>
<FileSignature className="mr-2 h-4 w-4" />
Đưa vào
</Button>
</span>
)}
{canDeletePlan && (
<Button
variant="outline"
@ -467,7 +516,21 @@ export function KhkkDetailPage() {
<tbody>
{k.lines.map((l) => (
<tr key={l.id} className="border-b border-slate-100 last:border-0">
<td className="px-3 py-2 font-medium text-brand-800">{l.supplierName ?? '—'}</td>
<td className="px-3 py-2 font-medium text-brand-800">
{l.supplierName ?? '—'}
{/* [K7 S167] Dòng đã bắc cầu — khuôn dấu "✓ HĐ" của danh sách PE
(`PurchaseEvaluationsListPage.tsx:599`). Thấy ngay ở bảng chứ không
phải mở dialog mới biết dòng nào đã xong. */}
{l.contractId && (
<a
href={`/contracts/${l.contractId}`}
className="ml-2 text-[10px] font-medium text-brand-600 hover:underline"
title="Dòng này đã được đưa vào hợp đồng — bấm để mở HĐ"
>
</a>
)}
</td>
<td className="px-3 py-2 text-xs text-slate-700">
{l.tenHangMuc ? l.tenHangMuc : <span className="text-slate-300"></span>}
</td>
@ -713,6 +776,245 @@ export function KhkkDetailPage() {
</div>
)}
</Dialog>
{/* [K7 S167 — vá-16] Dialog bắc cầu. Render có điều kiện (không dùng prop `open`) để
mỗi lần mở là state mới — khuôn dialog Xóa-phiếu `PeWorkflowPanel.tsx:879`. */}
{bridgeOpen && (
<CreateContractFromKhkkDialog plan={k} onClose={() => setBridgeOpen(false)} />
)}
</div>
)
}
// ===================== K7 S167 — cầu KHKK → Hợp đồng =================================
// [vá-16] 0 KHUÔN MỚI — ghép 2 khuôn đang sống:
// · vỏ dialog + khối "nói thẳng hệ quả" = nút Xóa-phiếu S155 (`PeWorkflowPanel.tsx:879-918`);
// · ô chọn Quy trình duyệt = picker `ContractCreatePage.tsx:333-343`
// (GET `/approval-workflows-v2?applicableType=3` → lọc theo cờ).
// [OG-1 "máy rộng, UI hẹp"] Máy BE nhận `lineIds[]` (gộp được N dòng vào 1 HĐ); ô chọn ở
// đây CỐ Ý 1-1 — mỗi lần bấm đúng 1 dòng. Mở gộp sau = đổi radio thành checkbox, KHÔNG
// đụng hợp-đồng API.
type BridgeWorkflowOption = {
id: string
code: string
version: number
name: string
isActive: boolean
isUserSelectable: boolean
}
function CreateContractFromKhkkDialog({ plan, onClose }: { plan: KhkkDetailDto; onClose: () => void }) {
const navigate = useNavigate()
const qc = useQueryClient()
const bridgeable = plan.lines.filter((l) => l.contractId == null && l.approvedAmount != null)
const [lineId, setLineId] = useState(() => bridgeable[0]?.id ?? '')
const [contractType, setContractType] = useState(1)
const [workflowId, setWorkflowId] = useState('')
// null = "đang dùng tên mặc định theo dòng đang chọn". Giữ null thay vì đồng bộ bằng
// useEffect: đổi dòng thì tên gợi ý đổi theo NGAY trong render, không có nhịp lệch.
const [tenHopDongOverride, setTenHopDongOverride] = useState<string | null>(null)
const selected: KhkkLineDto | null = plan.lines.find((l) => l.id === lineId) ?? null
const defaultName = selected
? `${plan.peTenGoiThau ?? 'Kế hoạch ký kết'}${selected.tenHangMuc ?? selected.supplierName ?? ''}`.trim()
: ''
const tenHopDong = tenHopDongOverride ?? defaultName
const workflows = useQuery({
// Query key RIÊNG (không dùng lại `approval-workflows-v2-contract` của ContractCreatePage):
// cùng URL nhưng LỌC KHÁC ⇒ dùng chung ô cache là để trang này ăn dữ liệu trang kia một
// cách câm (bài S159).
queryKey: ['khkk-bridge-contract-workflows'],
queryFn: async () => {
const res = await api.get<{ types: { applicableType: number; history: BridgeWorkflowOption[] }[] }>(
'/approval-workflows-v2',
{ params: { applicableType: 3 } },
)
const bucket = res.data.types.find((t) => t.applicableType === 3)
// 🔴 LỌC ĐỦ 2 CỜ (spec cụm-3 vá-2): `isUserSelectable` (admin ghim cho user chọn) ∧
// `isActive` (bản còn hiệu lực). Picker cũ `ContractCreatePage.tsx:341` chỉ lọc 1 cờ ⇒
// mời chọn được bản đã retire rồi ăn 409 từ guard BE. Ở đây lọc đủ ngay từ nguồn.
return (bucket?.history ?? []).filter((w) => w.isUserSelectable && w.isActive)
},
})
const create = useMutation({
mutationFn: async () => {
const body: CreateContractFromKhkkInput = {
lineIds: [lineId],
contractType,
approvalWorkflowId: workflowId,
tenHopDong: tenHopDong.trim() || null,
}
return (
await api.post<CreateContractFromKhkkResult>(
`/contract-signing-plans/${plan.id}/create-contract`,
body,
)
).data
},
onSuccess: (data) => {
toast.success(data.maHopDong ? `Đã tạo hợp đồng ${data.maHopDong}` : 'Đã tạo hợp đồng từ kế hoạch')
qc.invalidateQueries({ queryKey: ['khkk-detail', plan.id] })
qc.invalidateQueries({ queryKey: ['khkk-list'] })
// Cây 4 folder đọc 2 index riêng (`usePipelineStages.ts`) với `staleTime` 60s — không
// gọi 2 dòng này thì HĐ vừa tạo cả phút sau mới hiện trên cây.
qc.invalidateQueries({ queryKey: ['pipeline-khkk-index'] })
qc.invalidateQueries({ queryKey: ['pipeline-contract-index'] })
onClose()
navigate(`/contracts/${data.contractId}`)
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const noWorkflow = !workflows.isLoading && (workflows.data?.length ?? 0) === 0
return (
<Dialog
open
size="lg"
onClose={onClose}
title="Đưa dòng kế hoạch vào Hợp đồng"
footer={
<>
<Button variant="ghost" onClick={onClose}>
Huỷ
</Button>
<Button
onClick={() => create.mutate()}
disabled={create.isPending || !lineId || !workflowId}
>
{create.isPending ? 'Đang tạo...' : 'Tạo hợp đồng'}
</Button>
</>
}
>
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-900">
<div className="font-semibold">Tạo hợp đng từ kế hoạch &ldquo;{plan.maKeHoach ?? 'chưa cấp mã'}&rdquo;</div>
<ul className="mt-1.5 list-disc space-y-1 pl-4 text-[11px]">
<li>Mỗi lần bấm tạo <strong>1 hợp đng cho 1 dòng</strong> (1 nhà cung cấp). Liên danh nhiều NCC thì làm lần lượt.</li>
<li>Giá trị lấy đúng <strong>giá duyệt đã chốt</strong> của dòng không lấy giá đ xuất.</li>
<li> mới trạng thái <strong>Nháp</strong> đi theo quy trình duyệt bạn chọn dưới đây.</li>
<li>Dòng đã đưa vào thì <strong>không đưa lần hai</strong> cần sửa thì xử chính hợp đng đó.</li>
</ul>
</div>
<div className="space-y-3">
<div>
<Label>Chọn dòng đưa vào hợp đng</Label>
{plan.lines.length === 0 ? (
<div className="text-sm text-muted-foreground">Phiếu chưa dòng nhà cung cấp nào.</div>
) : (
<ul className="mt-1 space-y-1.5">
{plan.lines.map((l) => {
const done = l.contractId != null
const noPrice = l.approvedAmount == null
const disabled = done || noPrice
return (
<li key={l.id}>
<label
className={cn(
'flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm',
disabled && 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400',
!disabled && lineId === l.id && 'border-brand-400 bg-brand-50',
!disabled && lineId !== l.id && 'border-slate-200 hover:bg-slate-50',
)}
title={
done
? 'Dòng này đã có hợp đồng.'
: noPrice
? 'Dòng chưa có giá duyệt chốt — phiếu phải qua bước chốt giá.'
: undefined
}
>
<input
type="radio"
name="khkk-bridge-line"
checked={lineId === l.id}
disabled={disabled}
onChange={() => {
setLineId(l.id)
setTenHopDongOverride(null)
}}
/>
<span className="min-w-0 flex-1">
<span className="font-medium">{l.tenHangMuc ?? '(chưa gán hạng mục)'}</span>
<span className="text-slate-500"> · {l.supplierName ?? '—'}</span>
</span>
<span className="shrink-0 tabular-nums">{formatVnd(l.approvedAmount)}</span>
{done && (
<a
href={`/contracts/${l.contractId}`}
className="shrink-0 text-[11px] font-medium text-brand-600 hover:underline"
>
Đã
</a>
)}
{!done && noPrice && (
<span className="shrink-0 text-[11px]">Chưa giá chốt</span>
)}
</label>
</li>
)
})}
</ul>
)}
</div>
<div>
<Label htmlFor="bridge-type">Loại hợp đng</Label>
<Select
id="bridge-type"
value={contractType}
onChange={(e) => setContractType(Number(e.target.value))}
>
{Object.entries(ContractTypeLabel).map(([v, label]) => (
<option key={v} value={v}>
{label}
</option>
))}
</Select>
</div>
<div>
<Label htmlFor="bridge-workflow">Quy trình duyệt hợp đng</Label>
<Select
id="bridge-workflow"
value={workflowId}
onChange={(e) => setWorkflowId(e.target.value)}
disabled={workflows.isLoading || noWorkflow}
>
<option value=""> Chọn quy trình </option>
{(workflows.data ?? []).map((w) => (
<option key={w.id} value={w.id}>
{w.code} v{w.version} {w.name}
</option>
))}
</Select>
{noWorkflow && (
// Rỗng-vì-chưa-cấu-hình phải khác câu với rỗng-vì-đang-tải: người dùng cần biết
// đây là việc của quản trị, không phải chờ thêm.
<p className="mt-1 text-[11px] text-red-700">
Chưa quy trình duyệt hợp đng nào đưc ghim cho người dùng chọn (cần đang hiệu lực
đưc ghim). Nhờ quản trị mở trong màn Quy trình duyệt rồi quay lại.
</p>
)}
</div>
<div>
<Label htmlFor="bridge-name">Tên hợp đng</Label>
<Input
id="bridge-name"
value={tenHopDong}
onChange={(e) => setTenHopDongOverride(e.target.value)}
maxLength={500}
/>
<p className="mt-1 text-[11px] text-slate-500">
Bỏ trống thì hệ thống tự đt theo tên gói thầu hạng mục của dòng.
</p>
</div>
</div>
</Dialog>
)
}

View File

@ -189,6 +189,17 @@ export interface KhkkListItemDto {
totalApprovedAmount: number | null
createdAt: string
updatedAt: string | null
// [K7 S167 — vá-4b] TẬP id HĐ đã sinh ra từ CÁC DÒNG của phiếu này.
// ✅ KHỚP BE: `ContractSigningPlanListItemDto:75 List<Guid> ContractIds` (lane BE K7 vá-4a,
// append-CUỐI) ⇒ JSON `contractIds`. Đối chiếu đã ghi ở `sub-implfe-k7.md` mục KHỚP-DTO.
// VÌ SAO CẦN: cây 4 folder phải hiện ĐỦ N hợp đồng của một gói, mà `pe.contractId` chỉ
// giữ HĐ ĐẦU (`CreateContractFromEvaluationFeatures.cs:143-145`) — HĐ thứ 2 trở đi
// CHỈ có ở dòng phiếu KHKK (`ContractSigningPlanLineDto:74 Guid? ContractId`).
// 🔴 KHAI OPTIONAL DÙ BE KHÔNG NULL: payload đang nằm trong cache React Query từ trước
// lượt này (và API cũ chưa deploy) KHÔNG có field ⇒ optional để cây bớt nguồn chứ
// KHÔNG vỡ; chỗ đọc phân biệt được vắng-field ⟂ mảng-rỗng (xem `usePipelineStages`).
// ⇒ Mọi nơi đọc qua `khkkPlanContractIds()` bên dưới — đổi tên chỉ sửa ĐÚNG 1 hàm đó.
contractIds?: string[] | null
}
export interface KhkkLineDto {
@ -290,6 +301,22 @@ export interface KhkkDetailDto extends KhkkListItemDto {
budgetFrozen: boolean
}
// [K7 S167 — vá-4b] Tập id HỢP ĐỒNG mà một phiếu KHKK đã đẻ ra. Gom từ MỌI nguồn CÓ THẬT
// trên payload thay vì đọc một ô cố định — hình dạng "một TẬP" là thứ K5 đã chừa sẵn:
// (1) `contractIds` — LIST payload (BE `…ListItemDto:75`; vắng khi payload đến từ cache
// cũ / API chưa deploy ⇒ nhánh này trả rỗng, KHÔNG ném lỗi);
// (2) `lines[].contractId` — DETAIL payload, TỒN TẠI HÔM NAY trên đĩa
// (`KhkkDetailDto.lines` + `KhkkLineDto.contractId`, mirror BE `…LineDto:74`).
// Trả mảng CÓ THỂ TRÙNG — nơi gọi đang đổ vào `Set` nên không khử trùng hai lần.
export function khkkPlanContractIds(
plan: KhkkListItemDto & Partial<Pick<KhkkDetailDto, 'lines'>>,
): string[] {
const out: string[] = []
for (const id of plan.contractIds ?? []) if (id) out.push(id)
for (const line of plan.lines ?? []) if (line.contractId) out.push(line.contractId)
return out
}
// Phiếu PE nguồn đủ điều kiện lập kế hoạch (DaDuyet + có NCC trúng thầu).
// CỐ Ý là TẬP CON của `ApprovedPeAwaitingPlanDto` BE trả — field thừa bỏ qua runtime.
// 🔴 [K2 S164 — OG-7] Vị ngữ "chưa có plan sống" ĐÃ BỎ khỏi picker (`Features.cs:1291-1295`):
@ -372,6 +399,28 @@ export interface KhkkTransitionResult {
currentApprovalLevelOrder: number | null
}
// ===================== K7 S167 — cầu KHKK → Hợp đồng ================================
// 🔴 HỢP-ĐỒNG LITERAL với lane BE K7 (nguồn `sub-invest-fable-b2-cum3.md §K7.1:29-54`,
// lane BE viết SONG SONG lượt này — lệch 1 chữ = đứt CÂM, bài W2 6-điểm-đứt):
// POST /contract-signing-plans/{id}/create-contract policy **`Contracts.Create`**
// (gotcha #85 — key theo TÀI NGUYÊN SINH RA là Contract, KHÔNG phải `KeHoachKyKet.*`)
// 201 → { contractId, maHopDong }
// `lineIds` là MẢNG vì máy BE gộp-capable (OG-1 "máy rộng"), còn ô chọn hiện gửi ĐÚNG 1
// phần tử (OG-1 "giao diện 1-1") ⇒ mở gộp sau = đổi ô chọn, KHÔNG đụng hợp-đồng này.
export interface CreateContractFromKhkkInput {
lineIds: string[]
contractType: number
approvalWorkflowId: string
tenHopDong?: string | null
}
export interface CreateContractFromKhkkResult {
contractId: string
// BE gen mã NGAY lúc bắc cầu (khuôn bridge PE `:113`), nhưng khai nullable để lỡ nhánh
// nào chưa gen thì FE hiện câu khác chứ không in "undefined" lên toast.
maHopDong: string | null
}
export interface PagedResult<T> {
items: T[]
total: number