[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

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

View File

@ -5,6 +5,7 @@ using SolutionErp.Application.Common.Models;
using SolutionErp.Application.ContractSigningPlans;
using SolutionErp.Application.ContractSigningPlans.Services;
using SolutionErp.Domain.ContractSigningPlans;
using SolutionErp.Domain.Contracts; // ContractType (cầu GĐ2 → GĐ3, K7)
namespace SolutionErp.Api.Controllers;
@ -213,6 +214,30 @@ public class ContractSigningPlansController(
=> Ok(await workflow.TransitionAsync(id, body.Action, body.Comment,
body.ApplyLevelFinalize ?? true, ct));
// ===================== Cầu GĐ2 → GĐ3 (K7 — S167) =====================
// 🔴 [vá-1 spec-cụm-3 §②-1] AUTHZ = HỘI 2 KHOÁ, cả 2 vế đều phải chặn được:
// vế 1 (class, dòng :28) `KeHoachKyKet.Read` — không được xem kế hoạch thì không được
// lấy dòng của nó đi tạo HĐ;
// vế 2 (action, ngay dưới) `Contracts.Create` — tài nguyên SINH RA là Contract ⇒ khoá phải
// là khoá của thứ được tạo (gotcha #85:
// gate bằng đúng key policy của endpoint).
// ASP.NET Core cộng dồn [Authorize] class + action theo AND ⇒ thiếu 1 trong 2 = 403.
// ⚠️ `Contracts.Create` trước S167 KHÔNG role nào có (seeder `SeedAllRolesReviewReadPermissionsAsync`
// đặt `CanCreate = isPe` ⇒ false cho key `Contracts` — grep tên hàm, ĐỪNG neo số dòng: file này
// dài ra là số thối [gate-K7 F-8 bắt neo cũ sai ngay lúc land]); `SeedContractCreateAccessAsync` cấp cho
// Drafter + Procurement + Admin — grant đó ăn @restart, KHÔNG phải deploy FE.
// ⚠️ KHÔNG động tới `POST /api/contracts` (`ContractsController` `[Authorize]` trần, #82 treo
// chủ đích): siết ở đây là siết CỬA MỚI, không phải sửa cửa cũ.
[HttpPost("{id:guid}/create-contract")]
[Authorize(Policy = "Contracts.Create")]
public async Task<ActionResult<CreateContractFromSigningPlanResult>> CreateContract(
Guid id, [FromBody] CreateContractFromSigningPlanBody body, CancellationToken ct)
{
var result = await mediator.Send(new CreateContractFromSigningPlanCommand(
id, body.LineIds ?? [], body.ContractType, body.ApprovalWorkflowId, body.TenHopDong), ct);
return Created($"/api/contracts/{result.ContractId}", result);
}
// ========================= Body records =========================
// PUT header: KHÔNG mang `Id` trong body (id lấy từ route) — tránh 2 nguồn sự thật.
// Mọi field nullable = null-safe: client không gửi ⇒ giữ giá trị cũ (#73).
@ -231,4 +256,13 @@ public class ContractSigningPlansController(
string Action,
string? Comment = null,
bool? ApplyLevelFinalize = null);
// [K7 S167] Cầu → HĐ. `PlanId` lấy từ route (không nhận trong body — 1 nguồn sự thật).
// `LineIds` nullable ở BODY để client gửi thiếu ra 400 của validator ("chọn ít nhất 1 dòng")
// thay vì 500 NullReference ở tầng dưới.
public record CreateContractFromSigningPlanBody(
List<Guid>? LineIds,
ContractType ContractType,
Guid ApprovalWorkflowId,
string? TenHopDong = null);
}

View File

@ -61,7 +61,18 @@ public record ContractSigningPlanListItemDto(
// đề xuất trong khi màn chi tiết đã khoá theo `ApprovedAmount` = hai màn lệch số trên
// cùng một phiếu. Σ này đóng đúng khe đó. Phiếu chưa duyệt → mọi dòng null → 0.
int ApprovalGroup,
decimal TotalApprovedAmount);
decimal TotalApprovedAmount,
// [K7 S167 — vá-4a] ID các HĐ đã sinh ra TỪ CÁC DÒNG của phiếu này (`Line.ContractId`,
// write-site `CreateContractFromSigningPlanFeatures.cs`). Rỗng = phiếu chưa đưa dòng nào
// vào HĐ.
// 🔴 VÌ SAO Ở LIST (không chỉ ở detail — detail đã có `lines[].contractId` từ W2 `:74`):
// cây 4-folder (`usePipelineStages.ts:287-291`) dựng GĐ3 từ payload LIST và hôm nay chỉ
// có MỘT nguồn là ô đơn `pe.contractId` ⇒ gói có N HĐ thì HĐ thứ 2+ VÔ HÌNH trên cây
// (K5 FLAG-1 "chỗ cắm 1 dòng" — lane K5 đã dựng sẵn union, thiếu đúng nguồn thứ hai này).
// Trả mảng PHẲNG thay vì `lines[]` lồng: consumer là một `Set<string>`, không cần hạt dòng.
// ⚠️ KHÔNG `Distinct()` trong projection: N dòng gộp 1 HĐ ra ID trùng — vô hại (consumer là
// Set) và tránh một tầng dịch SQL nữa trên projection dùng chung 3 endpoint.
List<Guid> ContractIds);
public record ContractSigningPlanLineDto(
Guid Id,
@ -821,7 +832,15 @@ public class ListContractSigningPlansQueryHandler(IApplicationDbContext db, ICur
// nói hai con số trên CÙNG một phiếu. `ApprovedAmount` vốn nullable ⇒ phiếu chưa
// duyệt cho ra `null` → `0`, đúng nghĩa "chưa chốt đồng nào".
db.ContractSigningPlanLines.Where(l => l.ContractSigningPlanId == p.Id)
.Sum(l => l.ApprovedAmount) ?? 0m))
.Sum(l => l.ApprovedAmount) ?? 0m,
// [K7 S167 — vá-4a] Nguồn THẬT của đường GĐ2 → GĐ3 cho cây 4-folder (xem chú
// thích tại DTO). Cùng khuôn collection-subquery với `WinnerSupplierNames`
// (`CreateContractFromEvaluationFeatures.cs:185-189`) — đã chạy thật ở list PE.
db.ContractSigningPlanLines
.Where(l => l.ContractSigningPlanId == p.Id && l.ContractId != null)
.OrderBy(l => l.CreatedAt)
.Select(l => l.ContractId!.Value)
.ToList()))
.ToListAsync(ct);
return new PagedResult<ContractSigningPlanListItemDto>(items, total, page, pageSize);

View File

@ -0,0 +1,251 @@
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Contracts.Services;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.ContractSigningPlans;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Application.ContractSigningPlans;
// =====================================================================================
// [K7 — S167 2026-08-01] CẦU GĐ2 → GĐ3: Kế hoạch ký kết (KHKK) đã duyệt → Hợp đồng draft.
//
// Khuôn = bridge PE→HĐ cũ `CreateContractFromEvaluationFeatures.cs` + guard workflow V2 của
// `ContractFeatures.cs:74-87`. Luật spec-4gd §①: GIỐNG NHAU — CHỈ KHÁC FORM — ĐỪNG CHẾ THÊM.
//
// 🔴 KHÁC bridge PE cũ ở 3 điểm CÓ CHỦ ĐÍCH:
// 1. Hạt là DÒNG (`ContractSigningPlanLine`), không phải phiếu: 1 call = 1 HĐ cho ĐÚNG tập
// `LineIds` gửi lên. Đây là write-site ĐẦU TIÊN toàn codebase của `Line.ContractId`
// (đo @S164: 0 assignment) ⇒ cây 4-folder có nguồn THẬT thay vì suy từ `pe.ContractId` đơn.
// 2. Pin V2 `ApprovalWorkflowId` (bridge cũ chỉ pin V1) — `ApprovalWorkflow.ApplicableType`
// = Contract(3) là "HĐ general (any ContractType)" ⇒ KHÔNG suy được từ ContractType,
// người bấm phải chọn trong dialog.
// 3. Trả 1 HĐ (`Guid` + mã) thay vì `List<Guid>` — liên-danh N NCC = N lần bấm (OG-1 UI-1-1).
//
// 🔴 4 VÁ của spec-cụm-3 §② nằm TRONG file này (đừng gỡ khi refactor):
// vá-1 authz HỘI 2 khoá — ở controller (`KeHoachKyKet.Read` class ∧ `Contracts.Create` action).
// vá-3 guard workflow pin phải `IsActive` ∧ `IsUserSelectable` — KHÔNG chỉ tồn-tại+đúng-type
// (khuôn `ContractFeatures.cs:79-87` thiếu 2 vế này ⇒ forge POST pin được quy trình đã
// retired; dropdown FE lọc 2 cờ `ContractCreatePage.tsx:334-341` nhưng FE không phải rào).
// vá-4a `line.ContractId` ghi LUÔN (nguồn thật) + `pe.ContractId ??=` giữ vai FALLBACK hiển thị.
// vá-6 nạp `Project.Code` + `Supplier.Code` (2 guard NotFound) cho codegen RG-001 · field-map
// += `SlaDeadline` · pin `WorkflowDefinitionId` như 2 tiền lệ (`ContractFeatures.cs:105-109`
// + bridge cũ `:108`) — để null là rơi về policy hardcode `ContractFeatures.cs:647-662`.
// =====================================================================================
/// Kết quả bridge — trả CẢ mã HĐ vì mã gen NGAY lúc tạo (FE toast + link không phải gọi lại).
public record CreateContractFromSigningPlanResult(Guid ContractId, string? MaHopDong);
/// <param name="LineIds">
/// Tập dòng đưa vào HĐ này. Máy nhận N (schema GỘP: N dòng cùng NCC → 1 HĐ); UI hướng-1
/// truyền đúng 1 phần tử. `GiaTri` = Σ `ApprovedAmount` của ĐÚNG tập này — không hơn không kém.
/// </param>
/// <param name="ApprovalWorkflowId">
/// Quy trình duyệt HĐ (V2, `ApplicableType = Contract`). BẮT BUỘC — xem chú thích đầu file
/// điểm 2: không suy được từ `ContractType`.
/// </param>
public record CreateContractFromSigningPlanCommand(
Guid PlanId,
List<Guid> LineIds,
ContractType ContractType,
Guid ApprovalWorkflowId,
string? TenHopDong = null) : IRequest<CreateContractFromSigningPlanResult>;
public class CreateContractFromSigningPlanCommandValidator
: AbstractValidator<CreateContractFromSigningPlanCommand>
{
public CreateContractFromSigningPlanCommandValidator()
{
RuleFor(x => x.PlanId).NotEmpty();
RuleFor(x => x.ApprovalWorkflowId).NotEmpty();
RuleFor(x => x.ContractType).IsInEnum();
RuleFor(x => x.LineIds)
.NotEmpty().WithMessage("Chọn ít nhất 1 dòng của kế hoạch để đưa vào Hợp đồng.");
RuleFor(x => x.LineIds)
.Must(ids => ids.Distinct().Count() == ids.Count)
.When(x => x.LineIds is { Count: > 0 })
.WithMessage("Danh sách dòng bị trùng — mỗi dòng chỉ được chọn 1 lần.");
// MaxLength MATCH EF config (`ContractConfiguration` TenHopDong = 500) — EF là source
// of truth, KHÔNG lấy số từ spec (bài S35).
RuleFor(x => x.TenHopDong).MaximumLength(500);
}
}
public class CreateContractFromSigningPlanCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser,
IContractWorkflowService workflow,
IContractCodeGenerator codeGenerator)
: IRequestHandler<CreateContractFromSigningPlanCommand, CreateContractFromSigningPlanResult>
{
public async Task<CreateContractFromSigningPlanResult> Handle(
CreateContractFromSigningPlanCommand request, CancellationToken ct)
{
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
throw new UnauthorizedException();
var actorId = currentUser.UserId.Value;
// ---- Rào (1) PHIẾU + PHASE — mirror rào (i) `ContractSigningPlanFeatures.cs:381-382`.
var plan = await db.ContractSigningPlans
.Include(p => p.Lines)
.FirstOrDefaultAsync(p => p.Id == request.PlanId, ct)
?? throw new NotFoundException("ContractSigningPlan", request.PlanId);
if (plan.Phase != ContractSigningPlanPhase.DaDuyet)
throw new ConflictException(
"Chỉ tạo Hợp đồng từ Kế hoạch ký kết đã duyệt xong (Đã duyệt).");
// ---- Rào (2) DÒNG ∈ PHIẾU. Đếm sau `Distinct` — id lạ/của phiếu khác đều rơi vào đây.
var lineIds = request.LineIds.Distinct().ToList();
var lines = plan.Lines
.Where(l => lineIds.Contains(l.Id))
.OrderBy(l => l.CreatedAt).ThenBy(l => l.Id)
.ToList();
if (lines.Count != lineIds.Count)
throw new ConflictException(
"Có dòng không thuộc kế hoạch này (đã bị xoá hoặc chọn nhầm phiếu) — tải lại trang rồi chọn lại.");
// ---- Rào (3) 1 HĐ = 1 NCC (`Contract.SupplierId` đơn). Liên-danh N NCC = N lần bấm.
var supplierIds = lines.Select(l => l.SupplierId).Distinct().ToList();
if (supplierIds.Count > 1)
throw new ConflictException(
"Các dòng chọn thuộc nhiều NCC khác nhau — mỗi Hợp đồng chỉ 1 NCC, tạo lần lượt.");
// ---- Rào (4)+(5) per-dòng: có giá CHỐT + chưa bridge. Message NÊU TÊN hạng mục để người
// bấm biết dòng nào — danh sách dòng trong dialog không có mã, chỉ có tên.
foreach (var l in lines)
{
var ten = l.TenHangMuc ?? "(chưa gán hạng mục)";
// KHÔNG fallback `ProposedAmount`: đó là số ĐỀ XUẤT chưa qua choke-point finalize
// (`ContractSigningPlanWorkflowService.cs:348` `ApprovedAmount ??=`). Lấy nhầm =
// HĐ mang số chưa ai chốt.
if (l.ApprovedAmount is null)
throw new ConflictException(
$"Dòng '{ten}' chưa có giá duyệt chốt — phiếu phải đi qua bước duyệt kết thúc trước khi đưa vào Hợp đồng.");
if (l.ContractId is not null)
throw new ConflictException($"Dòng '{ten}' đã có Hợp đồng.");
}
// ---- Rào (6) WORKFLOW PIN — 4 vế. 2 vế đầu = khuôn `ContractFeatures.cs:79-87`;
// 2 vế sau = [vá-3 spec-cụm-3 §②-3] đóng cửa forge-POST pin quy trình đã nghỉ.
var aw = await db.ApprovalWorkflows.AsNoTracking()
.FirstOrDefaultAsync(w => w.Id == request.ApprovalWorkflowId, ct)
?? throw new NotFoundException("ApprovalWorkflow", request.ApprovalWorkflowId);
if (aw.ApplicableType != ApprovalWorkflowApplicableType.Contract)
throw new ConflictException(
$"Quy trình '{aw.Code}' áp dụng cho {aw.ApplicableType}, không khớp với Hợp đồng " +
"(cần quy trình loại Hợp đồng).");
// 2 cờ TÁCH RIÊNG (không gộp `!IsActive || !IsUserSelectable`): admin cần đọc được
// NGUYÊN NHÂN — "đã nghỉ" và "chưa ghim" xử lý ở 2 nút khác nhau trong Designer.
if (!aw.IsActive)
throw new ConflictException(
$"Quy trình '{aw.Code}' đã ngừng hoạt động — chọn quy trình đang hoạt động.");
if (!aw.IsUserSelectable)
throw new ConflictException(
$"Quy trình '{aw.Code}' chưa được ghim cho người dùng chọn — nhờ Admin ghim, hoặc chọn quy trình khác.");
// ---- [vá-6] Nạp Project + Supplier ĐỦ (cần `Code` cho codegen RG-001) — 2 guard NotFound
// theo khuôn bridge cũ `CreateContractFromEvaluationFeatures.cs:63-66/:85-86`.
// loose-Guid ⇒ không có FK vật lý đỡ lưng, thiếu guard = NRE lúc gen mã.
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == plan.ProjectId, ct)
?? throw new NotFoundException("Project", plan.ProjectId);
var supplierId = supplierIds[0];
var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == supplierId, ct)
?? throw new NotFoundException("Supplier", supplierId);
// ---- [vá-6] Pin V1 `WorkflowDefinitionId` NGANG bridge cũ `:108` + `ContractFeatures.cs:105`.
// Cả 2 cùng set là ĐÚNG ("Cả 2 set ok" `:107-108`): `ContractWorkflowService` dispatch
// V2 trước. Để null thì HĐ rơi về policy hardcode `ContractFeatures.cs:647-662` khi
// ai đó gỡ pin V2 — mất luôn đường về quy trình đã cấu hình cho loại HĐ đó.
var activeWfId = await db.WorkflowDefinitions.AsNoTracking()
.Where(w => w.ContractType == request.ContractType && w.IsActive)
.Select(w => (Guid?)w.Id)
.FirstOrDefaultAsync(ct);
// PE nguồn: TRACKED (cần ghi `ContractId` fallback bên dưới) + lấy tên gói cho tên HĐ mặc
// định. loose-Guid ⇒ có thể null (PE bị xoá cứng) — mọi chỗ dùng đều null-safe.
var pe = await db.PurchaseEvaluations
.FirstOrDefaultAsync(e => e.Id == plan.PurchaseEvaluationId, ct);
var giaTri = lines.Sum(l => l.ApprovedAmount!.Value);
var tenHangMuc = string.Join(", ", lines
.Select(l => l.TenHangMuc)
.Where(t => !string.IsNullOrWhiteSpace(t))
.Distinct());
var tenMacDinh = string.IsNullOrWhiteSpace(tenHangMuc)
? (pe?.TenGoiThau ?? plan.MaKeHoach ?? "Hợp đồng")
: $"{pe?.TenGoiThau ?? plan.MaKeHoach} — {tenHangMuc}";
var tenHopDong = request.TenHopDong ?? tenMacDinh;
// Clamp theo EF max length (500) — tên tự ghép từ N hạng mục có thể vượt.
if (tenHopDong.Length > 500) tenHopDong = tenHopDong[..500];
var contract = new Contract
{
Type = request.ContractType,
Phase = ContractPhase.DangSoanThao,
SupplierId = supplierId,
ProjectId = plan.ProjectId,
DepartmentId = plan.DepartmentId,
DrafterUserId = actorId,
GiaTri = giaTri,
TenHopDong = tenHopDong,
NoiDung = pe?.MoTa,
// NS tham chiếu: Σ số PE (snapshot lúc lập kế hoạch), KHÔNG phải số đã chốt — cột
// "ngân sách nhập tay" của HĐ là mốc so sánh, giá trị HĐ nằm ở `GiaTri`.
BudgetManualName = plan.MaKeHoach is null ? null : $"NS tham chiếu KHKK {plan.MaKeHoach}",
BudgetManualAmount = lines.Sum(l => l.PeReferenceAmount),
WorkflowDefinitionId = activeWfId, // [vá-6]
ApprovalWorkflowId = request.ApprovalWorkflowId, // pin V2
// [vá-6] SlaDeadline — thiếu thì HĐ sinh từ cầu này là HĐ DUY NHẤT trong hệ không có
// hạn phase (cả 2 tiền lệ `ContractFeatures.cs:110` + bridge cũ `:109-110` đều set).
SlaDeadline = DateTime.UtcNow.Add(
workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)),
};
// Codegen TỰ-COMMIT (SERIALIZABLE + `SaveChangesAsync` nội bộ — `ContractCodeGenerator`):
// gọi TRƯỚC `Add` để lượt save nội bộ của nó không flush HĐ dở (bài S88, đúng thứ tự
// bridge cũ `:112-113` và `CreateContractSigningPlanCommandHandler:426-429`).
contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct);
db.Contracts.Add(contract);
db.ContractChangelogs.Add(new ContractChangelog
{
ContractId = contract.Id,
EntityType = ChangelogEntityType.Contract,
Action = ChangelogAction.Insert,
PhaseAtChange = contract.Phase,
UserId = actorId,
Summary = $"Tạo HĐ {contract.MaHopDong} từ Kế hoạch ký kết {plan.MaKeHoach ?? plan.Id.ToString()}",
ContextNote = $"ContractSigningPlan {plan.Id} — {lines.Count} dòng",
});
db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
{
ContractSigningPlanId = plan.Id,
EntityType = ContractSigningPlanEntityType.Line,
Action = ChangelogAction.Update,
PhaseAtChange = plan.Phase,
UserId = actorId,
Summary = $"Đưa {lines.Count} dòng vào HĐ {contract.MaHopDong}",
ContextNote = $"Contract {contract.Id}",
});
// ---- [vá-4a] WRITE-SITE `Line.ContractId` — NGUỒN THẬT của đường KHKK → HĐ.
foreach (var l in lines) l.ContractId = contract.Id;
// ---- [vá-4a] `pe.ContractId` giữ vai FALLBACK HIỂN THỊ (cây 4-folder đọc ô đơn này).
// `??=` CÓ CHIỀU: chỉ điền khi còn trống ⇒ HĐ đầu tiên của gói thắng, các HĐ sau
// KHÔNG ghi đè (chúng vẫn nhìn thấy được qua `Line.ContractId` — đó là lý do vá-4a
// tồn tại). Bridge PE cũ gán thẳng `= contracts[0].Id` sau khi đã chặn ở `:59-60`,
// nên ở ĐÂY phải là `??=`: 2 cầu cùng chạy trên 1 PE thì cầu chạy sau không được
// cướp ô của cầu chạy trước.
if (pe is not null) pe.ContractId ??= contract.Id;
await db.SaveChangesAsync(ct);
return new CreateContractFromSigningPlanResult(contract.Id, contract.MaHopDong);
}
}

View File

@ -2353,6 +2353,14 @@ public static class DbInitializer
// @S159 ⇒ nhánh insert skip-existing = NO-OP im lặng ⇒ 403 giết dry-run (gotcha #44).
await SeedKeHoachKyKetAccessAsync(db, roleManager, logger);
// [K7 — S167 2026-08-01] Cầu GĐ2 → GĐ3 golive: cấp `Contracts.Create` cho Drafter +
// Procurement + Admin (câu III default — chờ anh ratify, đổi bộ vai TRONG method).
// CHẠY SAU revoke cùng lý do 2 seeder trên. Không có nó thì nút "Đưa vào Hợp đồng"
// 403 với 12/13 role (`SeedAllRolesReviewReadPermissionsAsync` để CanCreate=false cho key
// `Contracts`; Admin qua nhờ bypass + prod sẵn row CanCreate=True — đo @gate-K7) —
// nghiệm thu = restart THẬT rồi đếm row, không đọc code (#75/#76).
await SeedContractCreateAccessAsync(db, roleManager, logger);
// [S65 2026-06-16] CHẠY SAU revoke để THẮNG: mở lại quyền XEM "Hồ sơ Nhân sự"
// (+ root nhóm "Nhân sự") cho MỌI role — anh chốt public module Nhân sự cho
// user thường tra cứu hồ sơ. Dashboard NS (Hrm_Dashboard) + 6 catalog
@ -2728,6 +2736,88 @@ public static class DbInitializer
}
}
// [K7 — S167 2026-08-01] Cấp `Contracts.Create` cho các vai được bấm "Đưa vào Hợp đồng"
// (cầu GĐ2 → GĐ3, `ContractSigningPlansController` `POST {id}/create-contract`).
//
// 🔴 LÝ DO PHẢI CÓ HÀM NÀY: trước S167 **KHÔNG role nào** mang `Contracts.Create` —
// `SeedAllRolesReviewReadPermissionsAsync` (grep tên hàm, đừng neo số dòng) tạo row key
// `Contracts` với `CanCreate = isPe` mà `isPe` = false cho key này ⇒ nút bridge 403 với
// 12/13 role (kể cả Drafter); Admin qua nhờ bypass và prod ĐÃ sẵn row CanCreate=True cho
// Admin (đo prod @gate-K7). Đây là lỗ ĐO ĐƯỢC, không phải suy đoán.
// CanRead nâng KÈM là CHỦ ĐÍCH, không phải tràn-nhãn: vai được tạo HĐ phải THẤY menu
// "Hợp đồng" để mở HĐ vừa tạo từ cầu KHKK — CanRead là cờ hiển thị menu (2-tầng #82).
//
// ⏳ [K7 — câu III default, chờ anh ratify] Bộ vai = Drafter + Procurement + Admin
// ("người có quyền tạo HĐ"): Drafter = người soạn HĐ, Procurement = Mua hàng chủ trì cầu
// NCC, Admin = quản trị/UAT. Anh chốt khác thì SỬA ĐÚNG mảng `roleNames` dưới đây (1 chỗ),
// KHÔNG đụng controller — máy BE không đổi theo câu trả lời, chỉ đổi ai thấy nút.
//
// Khuôn = `SeedKeHoachKyKetAccessAsync` (W1 S161): UPGRADE-ONLY (row có sẵn → NÂNG cờ
// desired, KHÔNG hạ cờ nào, KHÔNG đụng Update/Delete), row chưa có (fresh DB) → tạo
// Read+Create. CẤM SQL tay (#84): grant tay bị seeder/revoker lật ở restart kế (bài S159
// Run #423 — 447/494 row rơi). Chạy SAU `RevokeTemporarilyHiddenModulesAsync` để thắng
// revoke (revoker hiện KHÔNG còn nhánh `Contracts` từ S159, nhưng giữ thứ tự cho an toàn).
// Thu hồi khi cần: xoá call trong `SeedAdminPermissionsAsync` (gate-K7 bắt bản đầu trỏ nhầm SeedAsync).
private static async Task SeedContractCreateAccessAsync(
ApplicationDbContext db, RoleManager<Role> roleManager, ILogger logger)
{
// CHỈ key root `Contracts` — đó là key mà policy `Contracts.Create` của endpoint đọc
// (`MenuPermissionHandler` so khớp CHÍNH XÁC 1 key, KHÔNG kế thừa xuống `Ct_*`).
// Cấp thêm cho `Ct_*` là cấp thừa: không endpoint nào gate bằng key con đó.
const string key = MenuKeys.Contracts;
var roleNames = new[] { AppRoles.Drafter, AppRoles.Procurement, AppRoles.Admin };
var roles = await roleManager.Roles
.Where(r => r.Name != null && roleNames.Contains(r.Name))
.ToListAsync();
if (roles.Count == 0)
{
logger.LogWarning("SeedContractCreateAccessAsync: skip — chưa seed role nào trong {Roles}.",
string.Join("/", roleNames));
return;
}
var roleIds = roles.Select(r => r.Id).ToList();
var existing = (await db.Permissions
.Where(p => p.MenuKey == key && roleIds.Contains(p.RoleId))
.ToListAsync())
.ToDictionary(p => p.RoleId);
var added = 0;
var upgraded = 0;
foreach (var role in roles)
{
if (existing.TryGetValue(role.Id, out var row))
{
var changed = false;
if (!row.CanRead) { row.CanRead = true; changed = true; }
if (!row.CanCreate) { row.CanCreate = true; changed = true; }
if (changed) upgraded++;
continue;
}
db.Permissions.Add(new Permission
{
RoleId = role.Id,
MenuKey = key,
CanRead = true,
CanCreate = true,
CanUpdate = false,
CanDelete = false,
});
added++;
}
if (added > 0 || upgraded > 0)
{
await db.SaveChangesAsync();
logger.LogInformation(
"Seeded Contracts.Create access: {Added} added + {Upgraded} upgraded " +
"(K7 cầu KHKK→HĐ — {Roles}; câu III default chờ ratify)",
added, upgraded, string.Join("/", roleNames));
}
}
// [S65 2026-06-16] Mở quyền XEM (Read-only) "Hồ sơ Nhân sự" cho MỌI role: anh chốt
// public module Nhân sự (trọng tâm Hồ sơ NS) để user thường tra cứu hồ sơ nhân sự.
// CHẠY SAU RevokeTemporarilyHiddenModulesAsync (SeedAsync) để THẮNG revoke: revoke

View File

@ -355,6 +355,23 @@ public class AuthorizePolicyRegressionTests
.Select(x => (x.Name, x.Http!.HttpMethods.First().ToUpperInvariant(), x.Authz))
.ToArray();
/// 🔴 [K7 S167] NGOẠI LỆ CÓ TÊN — action tạo ra tài nguyên của MODULE KHÁC phải gate bằng
/// key policy của **thứ được tạo**, không phải key của controller đang đứng (gotcha #85:
/// "gate quyền dùng đúng key policy của endpoint"; `MenuPermissionHandler.cs:40` so khớp
/// CHÍNH XÁC 1 key, không kế-thừa ⇒ gate nhầm key = cấp quyền chéo hoặc khoá oan).
///
/// Cầu GĐ2→GĐ3 `POST {id}/create-contract` SINH RA một `Contract` ⇒ vế ghi = `Contracts.Create`.
/// Vế đọc vẫn còn: class-level `KeHoachKyKet.Read` (`:28`) — ASP.NET Core cộng dồn class +
/// action theo **AND**, nên endpoint này là HỘI 2 KHOÁ, KHÔNG phải nới lỏng.
///
/// Viết dạng BẢN ĐỒ kỳ-vọng-CHÍNH-XÁC (không phải danh sách "bỏ qua"): action trong đây vẫn
/// bị đo, chỉ đổi key kỳ vọng. Gate sai thành `KeHoachKyKet.Create` (hoặc bất cứ key nào khác)
/// vẫn ĐỎ.
private static readonly Dictionary<string, string> KhkkCrossModuleGates = new()
{
[nameof(ContractSigningPlansController.CreateContract)] = "Contracts.Create",
};
[Fact]
public void ContractSigningPlansController_EveryWriteEndpoint_HasAuthorizePolicy()
{
@ -395,10 +412,23 @@ public class AuthorizePolicyRegressionTests
"policy \"{0}\" của {1} phải đúng dạng {{menuKey}}.{{action}}", authz.Policy, name);
// ---- (b) Tên policy phải là tên CÓ THẬT trong bộ sinh policy của Program.cs.
parts[0].Should().Be(MenuKeys.KeHoachKyKet,
"action {0} phải gate bằng menu key của CHÍNH module này — Δ W1 chốt prefix " +
"`KeHoachKyKet` (spec cũ ghi `ContractSigningPlans`, đã bỏ); gate nhầm key module " +
"khác = cấp quyền chéo", name);
// Kỳ vọng mặc định = key của CHÍNH module; ngoại lệ CÓ TÊN (K7 cầu → HĐ) đổi kỳ vọng
// sang key của tài nguyên ĐƯỢC TẠO — vẫn là so-khớp TUYỆT ĐỐI, không phải bỏ qua.
var expectedPolicy = KhkkCrossModuleGates.GetValueOrDefault(name);
if (expectedPolicy is not null)
{
authz.Policy.Should().Be(expectedPolicy,
"action {0} tạo ra tài nguyên của module KHÁC ⇒ gate bằng key policy của " +
"endpoint đích (gotcha #85). Gate bằng `KeHoachKyKet.*` sẽ cho người chỉ có " +
"quyền trên kế hoạch ký kết ĐẺ RA hợp đồng — quyền chéo đúng nghĩa", name);
}
else
{
parts[0].Should().Be(MenuKeys.KeHoachKyKet,
"action {0} phải gate bằng menu key của CHÍNH module này — Δ W1 chốt prefix " +
"`KeHoachKyKet` (spec cũ ghi `ContractSigningPlans`, đã bỏ); gate nhầm key module " +
"khác = cấp quyền chéo", name);
}
MenuKeys.All.Should().Contain(parts[0],
"policy \"{0}\" của {1} trỏ menu key KHÔNG có trong MenuKeys.All ⇒ Program.cs không " +
"sinh policy đó ⇒ 500 lúc chạy chứ không phải 403", authz.Policy, name);
@ -438,5 +468,15 @@ public class AuthorizePolicyRegressionTests
"quyền sửa phiếu nhưng không được lập phiếu mới");
byName[nameof(ContractSigningPlansController.UploadAttachment)].Should().Be("KeHoachKyKet.Update",
"upload đính kèm = ghi vào phiếu đã có ⇒ Update");
// ---- (e) [K7 S167] Cầu GĐ2→GĐ3 — chốt bằng TÊN, 2 chiều.
byName[nameof(ContractSigningPlansController.CreateContract)].Should().Be("Contracts.Create",
"cầu KHKK→HĐ sinh ra Contract ⇒ vế GHI phải là khoá của Contract (gotcha #85). " +
"Đây là vế 2 của HỘI-2-KHOÁ; vế 1 = class-level `KeHoachKyKet.Read` đã chốt ở (0)");
// Ngoại lệ phải trỏ action CÓ THẬT: đổi tên/xoá action mà quên dọn bản đồ thì mục đó
// thành cấu-hình-chết (im lặng, không ai đo) — vắng-mặt trông giống ổn.
KhkkCrossModuleGates.Keys.Should().BeSubsetOf(byName.Keys,
"bản đồ ngoại lệ trỏ action không còn tồn tại trên controller");
}
}

View File

@ -0,0 +1,245 @@
using System.Reflection;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Api.Authorization;
using SolutionErp.Api.Controllers;
using SolutionErp.Domain.Identity;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Api;
// [K7 — S167 2026-08-01] AUTHZ 2 CHIỀU cho cầu GĐ2→GĐ3 `POST {id}/create-contract`.
//
// Khác `AuthorizePolicyRegressionTests` (chỉ đọc ATTRIBUTE bằng reflection = "cửa có khoá tên gì"),
// file này chạy **máy quyết định thật** `MenuPermissionHandler` = "khoá đó có mở/không mở cho ai".
// Hai lớp bổ nhau: pin tên khoá mà không đo quyết định thì vẫn có thể sai ở tầng dữ liệu quyền,
// và ngược lại.
//
// 🔴 ĐIỀU KIỆN LÀM PHÉP ĐO CÓ RĂNG: 2 tên policy KHÔNG hardcode — ĐỌC TỪ CHÍNH CONTROLLER
// (class-level + action-level của `CreateContract`). Ai sửa attribute thì test này đo theo cái
// MỚI, và vẫn hỏi đúng câu: "hai vế có phải HỘI không". Hardcode chuỗi ở đây là tự tay tách
// test khỏi thứ nó bảo vệ.
//
// Vì sao "hội 2 khoá" quan trọng (gotcha #85 + #82): ASP.NET Core cộng dồn `[Authorize]` class +
// action theo **AND**. Nếu ai đó gỡ 1 vế:
// - gỡ `Contracts.Create` ⇒ người chỉ được xem/sửa kế hoạch ký kết ĐẺ RA hợp đồng (quyền chéo);
// - gỡ `KeHoachKyKet.Read` ⇒ người ngoài module lấy dòng của phiếu người khác đi tạo hợp đồng.
public class KhkkBridgeAuthzTests
{
/// Nạp 2 vế khoá của endpoint cầu TỪ ĐĨA (không hardcode).
private static (string ClassPolicy, string ActionPolicy) BridgePolicies()
{
var t = typeof(ContractSigningPlansController);
var classPolicy = t.GetCustomAttributes<AuthorizeAttribute>(inherit: false)
.FirstOrDefault()?.Policy;
var actionPolicy = t
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.First(m => m.Name == nameof(ContractSigningPlansController.CreateContract))
.GetCustomAttributes<AuthorizeAttribute>(inherit: false).FirstOrDefault()?.Policy;
classPolicy.Should().NotBeNullOrEmpty("vế 1 (class-level) của hội-2-khoá biến mất");
actionPolicy.Should().NotBeNullOrEmpty("vế 2 (action-level) của hội-2-khoá biến mất");
actionPolicy.Should().NotBe(classPolicy,
"hai vế TRÙNG NHAU thì 'hội 2 khoá' chỉ còn là 1 khoá — cửa tự nới ra mà nhìn vẫn như cũ");
return (classPolicy!, actionPolicy!);
}
/// Chạy ĐÚNG máy production: `MenuPermissionHandler` + `UserManager`/`RoleManager` thật.
/// Trả về quyết định cho MỘT policy "{menuKey}.{action}".
private static async Task<bool> AllowsAsync(IdentityFixture fix, User user, string policy)
{
var parts = policy.Split('.');
parts.Should().HaveCount(2, "policy \"{0}\" không đúng dạng {{menuKey}}.{{action}}", policy);
var handler = new MenuPermissionHandler(
fix.Services.GetRequiredService<TestApplicationDbContext>(),
fix.Services.GetRequiredService<UserManager<User>>(),
fix.Services.GetRequiredService<RoleManager<Role>>());
var principal = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())], "test"));
var req = new MenuPermissionRequirement(parts[0], parts[1]);
var ctx = new AuthorizationHandlerContext([req], principal, resource: null);
await handler.HandleAsync(ctx);
return ctx.HasSucceeded;
}
/// Kết quả THẬT của endpoint = HỘI 2 vế (ASP.NET Core cộng dồn theo AND).
private static async Task<bool> BridgeAllowedAsync(IdentityFixture fix, User user)
{
var (classPolicy, actionPolicy) = BridgePolicies();
return await AllowsAsync(fix, user, classPolicy)
&& await AllowsAsync(fix, user, actionPolicy);
}
/// Menu row PHẢI có trước Permission: FK `Permission.MenuKey → MenuItem.Key` (Cascade).
private static async Task SeedMenuRowsAsync(TestApplicationDbContext db, params string[] keys)
{
var order = 1;
foreach (var k in keys)
{
if (await db.MenuItems.AnyAsync(m => m.Key == k)) continue;
db.MenuItems.Add(new MenuItem { Key = k, Label = k, Order = order++ });
}
await db.SaveChangesAsync(CancellationToken.None);
}
/// Tạo 1 vai + user của vai đó, cấp đúng bộ cờ được liệt kê.
private static async Task<User> UserWithAsync(
IdentityFixture fix, TestApplicationDbContext db, string roleName,
params (string MenuKey, string Action)[] grants)
{
var user = await fix.CreateUserAsync(
$"{roleName.ToLowerInvariant()}-{Guid.NewGuid():N}@test.local", roleName,
departmentId: null, roles: [roleName]);
var role = await fix.Services.GetRequiredService<RoleManager<Role>>().FindByNameAsync(roleName);
role.Should().NotBeNull();
foreach (var g in grants.GroupBy(x => x.MenuKey))
{
var actions = g.Select(x => x.Action).ToHashSet();
db.Permissions.Add(new Permission
{
Id = Guid.NewGuid(),
RoleId = role!.Id,
MenuKey = g.Key,
CanRead = actions.Contains("Read"),
CanCreate = actions.Contains("Create"),
CanUpdate = actions.Contains("Update"),
CanDelete = actions.Contains("Delete"),
});
}
await db.SaveChangesAsync(CancellationToken.None);
return user;
}
private static (string MenuKey, string Action) Split(string policy)
{
var p = policy.Split('.');
return (p[0], p[1]);
}
// ===================================================================
// (1) ĐỦ 2 VẾ → qua. Đây là ĐỐI CHỨNG DƯƠNG: thiếu nó thì 2 test 403 bên dưới
// vẫn xanh trong một thế giới mà KHÔNG AI vào được cửa này (403 cho tất cả).
// ===================================================================
[Fact]
public async Task CauKhkkSangHd_VaiDuCaHaiVe_ThiQua()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (classPolicy, actionPolicy) = BridgePolicies();
var (khkkKey, khkkAction) = Split(classPolicy);
var (contractKey, contractAction) = Split(actionPolicy);
await SeedMenuRowsAsync(db, khkkKey, contractKey);
var user = await UserWithAsync(fix, db, "BridgeFull",
(khkkKey, khkkAction), (contractKey, contractAction));
(await AllowsAsync(fix, user, classPolicy)).Should().BeTrue();
(await AllowsAsync(fix, user, actionPolicy)).Should().BeTrue();
(await BridgeAllowedAsync(fix, user)).Should().BeTrue(
"vai mang ĐỦ `{0}` ∧ `{1}` phải bấm được nút Đưa vào Hợp đồng", classPolicy, actionPolicy);
}
// ===================================================================
// (2) THIẾU ĐÚNG 1 VẾ (không có `Contracts.Create`) → 403.
// Đây chính là hình dạng của LỖ mà lane BE phải vá bằng seeder: trước S167 KHÔNG vai nào
// mang `Contracts.Create` (`SeedAllRolesReviewReadPermissionsAsync` đặt `CanCreate = isPe` ⇒ false cho key
// `Contracts`) ⇒ mọi người-không-Admin đều rơi vào nhánh này.
// ===================================================================
[Fact]
public async Task CauKhkkSangHd_VaiChiCoQuyenKeHoach_ThiBiChan()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (classPolicy, actionPolicy) = BridgePolicies();
var (khkkKey, khkkAction) = Split(classPolicy);
var (contractKey, _) = Split(actionPolicy);
await SeedMenuRowsAsync(db, khkkKey, contractKey);
// Cấp RỘNG TAY trên module kế hoạch (cả 4 cờ) nhưng KHÔNG có gì trên `Contracts`.
var user = await UserWithAsync(fix, db, "KhkkOnly",
(khkkKey, "Read"), (khkkKey, "Create"), (khkkKey, "Update"), (khkkKey, "Delete"));
(await AllowsAsync(fix, user, classPolicy)).Should().BeTrue(
"vế 1 PHẢI qua — nếu vế này cũng chặn thì test không đo được vế 2 nào cả");
(await AllowsAsync(fix, user, actionPolicy)).Should().BeFalse(
"vế 2 `{0}` là khoá của tài nguyên ĐƯỢC TẠO; quyền trên kế hoạch ký kết " +
"KHÔNG được đẻ ra hợp đồng (gotcha #85 — không có kế-thừa key)", actionPolicy);
(await BridgeAllowedAsync(fix, user)).Should().BeFalse("thiếu 1 vế = 403");
}
// ===================================================================
// (3) THIẾU ĐÚNG 1 VẾ CÒN LẠI (không có `KeHoachKyKet.Read`) → 403.
// Chiều ngược: người được tạo HĐ nhưng ngoài module kế hoạch cũng KHÔNG được lấy dòng
// của phiếu người khác đi bắc cầu.
// ===================================================================
[Fact]
public async Task CauKhkkSangHd_VaiChiCoQuyenTaoHopDong_ThiBiChan()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (classPolicy, actionPolicy) = BridgePolicies();
var (khkkKey, _) = Split(classPolicy);
var (contractKey, contractAction) = Split(actionPolicy);
await SeedMenuRowsAsync(db, khkkKey, contractKey);
var user = await UserWithAsync(fix, db, "ContractOnly", (contractKey, contractAction));
(await AllowsAsync(fix, user, actionPolicy)).Should().BeTrue(
"vế 2 PHẢI qua — chứng minh phép đo không xanh nhờ 'chặn tất');");
(await AllowsAsync(fix, user, classPolicy)).Should().BeFalse();
(await BridgeAllowedAsync(fix, user)).Should().BeFalse("thiếu vế còn lại = vẫn 403");
}
// ===================================================================
// (4) Admin đi cửa bypass — KHÔNG có 1 dòng `Permissions` nào vẫn qua cả 2 vế.
// Khoá hành vi ĐANG CÓ (`MenuPermissionHandler.cs` nhánh `AppRoles.Admin`) để không ai
// "siết cho nhất quán" rồi khoá luôn Admin khỏi cầu.
// ===================================================================
[Fact]
public async Task CauKhkkSangHd_Admin_KhongCanDongQuyenNao_VanQua()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var admin = await fix.CreateUserAsync(
$"admin-{Guid.NewGuid():N}@test.local", "Admin", null, [AppRoles.Admin]);
(await db.Permissions.CountAsync()).Should().Be(0, "cố ý 0 dòng quyền — đo đúng nhánh bypass");
(await BridgeAllowedAsync(fix, admin)).Should().BeTrue();
}
// ===================================================================
// (5) User bị vô hiệu hoá: có ĐỦ quyền nhưng `IsActive = false` → chặn cả 2 vế.
// Vế này bắt lỗi "nghỉ việc nhưng token còn hạn vẫn đẻ được hợp đồng".
// ===================================================================
[Fact]
public async Task CauKhkkSangHd_UserBiVoHieuHoa_ThiChan_DuCoDuQuyen()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (classPolicy, actionPolicy) = BridgePolicies();
var (khkkKey, khkkAction) = Split(classPolicy);
var (contractKey, contractAction) = Split(actionPolicy);
await SeedMenuRowsAsync(db, khkkKey, contractKey);
var user = await UserWithAsync(fix, db, "BridgeDisabled",
(khkkKey, khkkAction), (contractKey, contractAction));
(await BridgeAllowedAsync(fix, user)).Should().BeTrue("chốt trước: lúc còn hoạt động thì QUA");
var tracked = await db.Users.SingleAsync(u => u.Id == user.Id);
tracked.IsActive = false;
await db.SaveChangesAsync(CancellationToken.None);
db.ChangeTracker.Clear();
(await BridgeAllowedAsync(fix, user)).Should().BeFalse(
"đổi ĐÚNG 1 biến `IsActive` ⇒ cùng bộ quyền, kết quả phải ngược lại");
}
}

View File

@ -0,0 +1,760 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.ContractSigningPlans;
using SolutionErp.Application.Contracts;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.ContractSigningPlans;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Domain.Master.Catalogs;
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// [K7 — S167 2026-08-01] CẦU GĐ2 → GĐ3: `CreateContractFromSigningPlanCommandHandler`
// (`Application/ContractSigningPlans/CreateContractFromSigningPlanFeatures.cs`, file MỚI của
// lane BE cùng đợt). Test-after: BE land trước, đây là lưới khoá hành-vi.
//
// ── Đo cái gì (map bảng T1-T8 `sub-invest-fable-b2-cum3.md:75-82` + 4 vá spec-cụm-3 §②) ──
// T1 happy 1 dòng (trọn field-map + 2 changelog + write-site) T2 gộp 2 dòng CÙNG NCC
// T3 chưa có giá chốt → chặn, KHÔNG đốt mã T4 phiếu chưa duyệt xong
// T5 dòng đã có HĐ (double-bridge) T6 2 dòng KHÁC NCC
// T7 quy trình pin sai — 4 nhánh + ĐỐI CHỨNG DƯƠNG cùng lượt T8 mối nối cầu → đường-ống V2
// T9 dòng-của-phiếu-khác + rào tập LineIds T10 bridge lần 2 dòng khác (`??=` CÓ CHIỀU)
// T11 double-click cùng tập dòng (idempotency)
//
// ── 3 quyết định hạ tầng (điều kiện để phép đo có nghĩa) ──────────────────────────────
//
// (1) `IdentityFixture` chứ không phải fixture KHKK riêng: `ContractWorkflowService` (deps của
// handler cầu, và là máy chạy T8) BẮT BUỘC `UserManager<User>` trong ctor. Khuôn sao y
// bridge PE cũ `CreateContractFromEvaluationMultiWinnerTests.BuildHandler`.
// (2) DÙNG DEPS THẬT (`ContractCodeGenerator` + `ContractWorkflowService` + service KHKK thật ở
// T9-B6), KHÔNG stub: mã HĐ RG-001 và `GetPhaseSla` là hai thứ vá-6 yêu cầu — stub chúng thì
// phép đo tự trả lời chính mình. `BeginTransactionAsync(Serializable)` chạy sạch trên SQLite
// (tiền lệ `SqliteDbFixture.cs:36-42` + codegen tests) — KHÔNG try/skip.
// (3) `ChangeTracker.Clear()` giữa 2 lần bấm (T10/T11): prod là 2 request ⇒ 2 DbContext scoped.
// Giữ nguyên context đã track dòng = một đường đi KHÔNG có thật, và nó CHE mất câu hỏi
// "đọc lại từ DB có thấy `ContractId` đã ghi không".
//
// ── 🔴 LỆCH SPEC ĐÃ ĐO, test theo CODE (single source of truth) ────────────────────────
// Bảng invest T1 (`:75`) ghi kỳ vọng `WorkflowDefinitionId null`. **Vá-6** của spec-cụm-3
// (`spec-cum3-chi-tiet-31-07-2026.md:16`) ĐẢO lại: pin V1 như 2 tiền lệ, và spec `:4` chốt
// "16 vá THẮNG khi xung đột với nền". Mã đang theo vá-6 (`:163-166` + `:201`).
// ⇒ T1 assert **pin ĐÚNG bản active của loại HĐ đó**, và assert luôn vế PHỦ ĐỊNH (không phải
// bản inactive / không phải bản của loại khác) — nếu chỉ assert `!= null` thì `activeWfId` lấy
// nhầm bản nào cũng xanh.
public class CreateContractFromSigningPlanTests
{
private static readonly DateTime Clock = new(2026, 8, 1, 3, 0, 0, DateTimeKind.Utc);
// ===================================================================
// Handler dưới test + deps THẬT (mirror wiring prod ContractSigningPlansController → MediatR)
// ===================================================================
private static CreateContractFromSigningPlanCommandHandler BuildHandler(
TestApplicationDbContext db, IServiceProvider sp, User actor, params string[] roles)
{
var um = sp.GetRequiredService<UserManager<User>>();
var dt = new FixedDateTime(Clock);
var currentUser = new TestCurrentUser(actor.Id, actor.FullName, actor.Email,
roles.Length > 0 ? roles : [AppRoles.Drafter]);
var changelog = new ChangelogService(db, currentUser, um);
var codeGen = new ContractCodeGenerator(db, dt);
var workflowSvc = new ContractWorkflowService(
db, codeGen, dt, new NoOpNotificationServiceApp(), changelog, um);
return new CreateContractFromSigningPlanCommandHandler(db, currentUser, workflowSvc, codeGen);
}
private const string ProjectCode = "FLOCK01";
private const string SupplierCode = "PVL";
private sealed record Seeded(
ContractSigningPlan Plan,
PurchaseEvaluation Pe,
Project Project,
List<Supplier> Suppliers,
Guid ContractWorkflowId, // V2 `ApplicableType = Contract(3)` — hợp lệ
Guid ActiveWorkflowDefId, // V1 active ĐÚNG loại HĐ dùng trong test
User Actor);
/// Mỗi phần tử = 1 dòng của phiếu. `SupplierIndex` trỏ vào `Suppliers` đã seed.
private sealed record LineSeed(int SupplierIndex, decimal PeReference, decimal Proposed, decimal? Approved);
private const ContractType TestType = ContractType.HopDongThauPhu;
/// Seed đủ 1 thế giới nhỏ nhưng THẬT: phiếu KHKK + PE nguồn + Project/Supplier CÓ `Code`
/// (cầu nạp 2 bảng này cho codegen, thiếu = `NotFoundException`) + 1 quy trình V2 loại HĐ
/// hợp lệ + 1 `WorkflowDefinition` V1 active của `TestType`.
///
/// 🔴 MỖI dòng 1 `CatalogEntryId` RIÊNG: UNIQUE `(PlanId, SupplierId, CatalogEntryId)` filtered
/// (`ContractSigningPlanLineConfiguration.cs:39-41`, K2 Mig 71). Chính vì Mig 71 đổi unique từ
/// (Plan, Supplier) sang bộ-3 mà **T2 gộp 2 dòng cùng NCC mới tồn tại được** (vá-5 spec-cụm-3).
private static async Task<Seeded> SeedAsync(
IdentityFixture fix,
TestApplicationDbContext db,
LineSeed[] lines,
int supplierCount = 1,
ContractSigningPlanPhase phase = ContractSigningPlanPhase.DaDuyet,
string maKeHoach = "KHKK/2026/001")
{
// Hậu tố ngẫu nhiên cho MỌI mã: `Projects.Code` / `Suppliers.Code` / `ApprovalWorkflows`
// (Code,Version) / `WorkflowDefinitions` (Code,Version) đều UNIQUE ⇒ T9 gọi `SeedAsync`
// HAI LẦN trên cùng DB sẽ nổ SQLite 19 nếu mã trùng.
var tag = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
var actor = await fix.CreateUserAsync(
$"drafter-k7-{Guid.NewGuid():N}@test.local", "Người soạn K7",
departmentId: null, roles: [AppRoles.Drafter]);
var project = new Project
{
Id = Guid.NewGuid(), Code = $"{ProjectCode}{tag}", Name = "Dự án FLOCK 01",
};
db.Projects.Add(project);
var suppliers = new List<Supplier>();
for (var i = 0; i < supplierCount; i++)
{
var s = new Supplier
{
Id = Guid.NewGuid(),
// NCC[0] mang mã dùng để soi mã HĐ (T1); các NCC sau khác mã để không lẫn.
Code = i == 0 ? $"{SupplierCode}{tag}" : $"NCC{i}{tag}",
Name = $"Nhà cung cấp {i + 1}",
Type = SupplierType.NhaThauPhu,
};
suppliers.Add(s);
db.Suppliers.Add(s);
}
var pe = new PurchaseEvaluation
{
Id = Guid.NewGuid(),
MaPhieu = $"PE-2026-K7-{tag}",
Type = PurchaseEvaluationType.DuyetNccPhuongAn,
Phase = PurchaseEvaluationPhase.DaDuyet,
TenGoiThau = "Gói thầu K7",
ProjectId = project.Id,
DrafterUserId = actor.Id,
MoTa = "Mô tả gói thầu K7",
};
db.PurchaseEvaluations.Add(pe);
var plan = new ContractSigningPlan
{
Id = Guid.NewGuid(),
MaKeHoach = maKeHoach,
PurchaseEvaluationId = pe.Id,
ProjectId = project.Id,
DepartmentId = Guid.NewGuid(),
DrafterUserId = actor.Id,
Phase = phase,
ApprovalGroup = 1,
};
foreach (var l in lines)
{
var catalog = new ContractCatalogEntry
{
Id = Guid.NewGuid(),
Code = "K7-" + Guid.NewGuid().ToString("N")[..8],
TenVi = $"Hạng mục K7 #{plan.Lines.Count + 1}",
GroupCode = "B1",
ApprovalGroup = 1,
SignerRole = "TP.PRO",
IsActive = true,
SortOrder = 1,
};
db.ContractCatalogEntries.Add(catalog);
plan.Lines.Add(new ContractSigningPlanLine
{
Id = Guid.NewGuid(),
ContractSigningPlanId = plan.Id,
SupplierId = suppliers[l.SupplierIndex].Id,
CatalogEntryId = catalog.Id,
TenHangMuc = catalog.TenVi,
PeReferenceAmount = l.PeReference,
ProposedAmount = l.Proposed,
ApprovedAmount = l.Approved,
});
}
db.ContractSigningPlans.Add(plan);
var aw = NewContractWorkflow($"QT-HD-V2-K7-{tag}");
db.ApprovalWorkflows.Add(aw);
// V1 active của ĐÚNG `TestType` + 1 bản NHIỄU (loại khác) để vế pin của T1 không thể
// xanh nhờ "chỉ có mỗi một bản trong bảng".
var activeDef = new WorkflowDefinition
{
Id = Guid.NewGuid(), Code = $"QT-TP-{tag}", Version = 1,
ContractType = TestType, Name = "QT Thầu phụ v01", IsActive = true,
};
db.WorkflowDefinitions.Add(activeDef);
db.WorkflowDefinitions.Add(new WorkflowDefinition
{
Id = Guid.NewGuid(), Code = $"QT-MB-{tag}", Version = 1,
ContractType = ContractType.HopDongMuaBan, Name = "QT Mua bán v01", IsActive = true,
});
await db.SaveChangesAsync(CancellationToken.None);
return new Seeded(plan, pe, project, suppliers, aw.Id, activeDef.Id, actor);
}
private static ApprovalWorkflow NewContractWorkflow(
string code, bool isActive = true, bool isUserSelectable = true,
ApprovalWorkflowApplicableType type = ApprovalWorkflowApplicableType.Contract)
=> new()
{
Id = Guid.NewGuid(),
Code = code,
Version = 1,
Name = $"Quy trình {code}",
ApplicableType = type,
IsActive = isActive,
IsUserSelectable = isUserSelectable,
};
private static CreateContractFromSigningPlanCommand Cmd(
Seeded s, IEnumerable<Guid> lineIds, Guid? workflowId = null, string? ten = null)
=> new(s.Plan.Id, lineIds.ToList(), TestType, workflowId ?? s.ContractWorkflowId, ten);
private static Guid LineId(Seeded s, int index) => s.Plan.Lines[index].Id;
// ===================================================================
// T1 — Happy 1 dòng: trọn field-map + write-site + 2 changelog.
// ===================================================================
[Fact]
public async Task T1_MotDong_TaoDungMotHopDong_VaGhiNguocContractId()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db, [new LineSeed(0, PeReference: 900m, Proposed: 999m, Approved: 750m)]);
var handler = BuildHandler(db, fix.Services, s.Actor);
var result = await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
var c = await db.Contracts.AsNoTracking().SingleAsync(x => x.Id == result.ContractId);
// ---- Tiền + trạng thái.
c.GiaTri.Should().Be(750m,
"`GiaTri` = Σ ApprovedAmount của ĐÚNG tập dòng chọn — KHÔNG phải `ProposedAmount` (999) " +
"cũng không phải `PeReferenceAmount` (900); 3 số cố ý khác nhau để chỉ một cái đúng");
c.Phase.Should().Be(ContractPhase.DangSoanThao);
c.Type.Should().Be(TestType);
c.SupplierId.Should().Be(s.Suppliers[0].Id, "NCC của HĐ lấy từ DÒNG, không phải từ phiếu");
c.ProjectId.Should().Be(s.Project.Id);
c.DepartmentId.Should().Be(s.Plan.DepartmentId);
c.DrafterUserId.Should().Be(s.Actor.Id);
// ---- Pin quy trình: V2 (mới) + V1 (vá-6). Xem khối "LỆCH SPEC" đầu file.
c.ApprovalWorkflowId.Should().Be(s.ContractWorkflowId);
c.WorkflowDefinitionId.Should().Be(s.ActiveWorkflowDefId,
"[vá-6] pin bản V1 ĐANG ACTIVE của ĐÚNG loại HĐ — để null thì HĐ rơi về policy hardcode; " +
"pin nhầm bản của loại khác (bảng có sẵn 1 bản nhiễu `HopDongMuaBan`) cũng sai");
// ---- [vá-6] SLA: `GetPhaseSla(DangSoanThao)` trả null ⇒ nhánh dự phòng 7 ngày.
// Thiếu dòng này thì HĐ sinh từ cầu là HĐ DUY NHẤT trong hệ không có hạn phase.
c.SlaDeadline.Should().NotBeNull("[vá-6] cầu phải set hạn phase như 2 tiền lệ");
c.SlaDeadline!.Value.Should().BeCloseTo(DateTime.UtcNow.AddDays(7), TimeSpan.FromMinutes(5));
// ---- [vá-6] Mã HĐ: soi RUỘT chứ không chỉ non-null — mã có `Project.Code` + `Supplier.Code`
// CHÍNH LÀ bằng chứng cầu đã nạp 2 bảng đó (nếu quên nạp thì `GenerateAsync` NRE/mã rỗng).
c.MaHopDong.Should().Be($"{s.Project.Code}/HĐTP/SOL&{s.Suppliers[0].Code}/01",
"RG-001 `{ProjectCode}/HĐTP/SOL&{SupplierCode}/{Seq:D2}` — gen NGAY lúc bắc cầu; " +
"mã CHỨA mã dự án + mã NCC chính là bằng chứng cầu đã nạp 2 bảng đó [vá-6]");
result.MaHopDong.Should().Be(c.MaHopDong, "kết quả trả về phải khớp DB (FE toast đọc số này)");
// ---- NS tham chiếu = Σ `PeReferenceAmount` (số PE), KHÁC `GiaTri` (số đã chốt).
c.BudgetManualAmount.Should().Be(900m);
c.BudgetManualName.Should().Contain(s.Plan.MaKeHoach!);
// ---- [vá-4a] WRITE-SITE: `Line.ContractId` = nguồn THẬT của đường KHKK → HĐ.
var line = await db.ContractSigningPlanLines.AsNoTracking().SingleAsync(l => l.Id == LineId(s, 0));
line.ContractId.Should().Be(c.Id, "[vá-4a] write-site ĐẦU TIÊN toàn codebase của cột này");
// ---- [vá-4a] `pe.ContractId` = fallback hiển thị cho cây 4-folder (ô còn trống ⇒ điền).
var pe = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(p => p.Id == s.Pe.Id);
pe.ContractId.Should().Be(c.Id);
// ---- 2 changelog, mỗi bên 1 hàng, ĐÚNG bảng (không cùng bảng, không thiếu bên nào).
var ctLog = await db.ContractChangelogs.AsNoTracking().Where(x => x.ContractId == c.Id).ToListAsync();
ctLog.Should().HaveCount(1);
ctLog[0].Action.Should().Be(ChangelogAction.Insert);
ctLog[0].EntityType.Should().Be(ChangelogEntityType.Contract);
ctLog[0].UserId.Should().Be(s.Actor.Id);
ctLog[0].Summary.Should().Contain(c.MaHopDong!).And.Contain(s.Plan.MaKeHoach!);
var planLog = await db.ContractSigningPlanChangelogs.AsNoTracking()
.Where(x => x.ContractSigningPlanId == s.Plan.Id).ToListAsync();
planLog.Should().HaveCount(1);
planLog[0].Action.Should().Be(ChangelogAction.Update);
planLog[0].EntityType.Should().Be(ContractSigningPlanEntityType.Line);
planLog[0].ContextNote.Should().Contain(c.Id.ToString());
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(1, "1 lần bấm = 1 HĐ");
}
// ===================================================================
// T2 — Gộp 2 dòng CÙNG NCC (máy nhận N, UI hướng 1-1).
// 🔴 Case này CHỈ TỒN TẠI ĐƯỢC nhờ K2 Mig 71 đổi UNIQUE (Plan, Supplier) → bộ-3 có
// `CatalogEntryId` (vá-5): trước đó 2 dòng cùng NCC trên 1 phiếu là bất khả về schema.
// ===================================================================
[Fact]
public async Task T2_HaiDongCungNcc_GopVaoMotHopDong_GiaTriLaTong()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db,
[
new LineSeed(0, 100m, 500m, Approved: 300m),
new LineSeed(0, 200m, 500m, Approved: 450m),
]);
var handler = BuildHandler(db, fix.Services, s.Actor);
var result = await handler.Handle(Cmd(s, [LineId(s, 0), LineId(s, 1)]), CancellationToken.None);
var c = await db.Contracts.AsNoTracking().SingleAsync();
c.Id.Should().Be(result.ContractId);
c.GiaTri.Should().Be(750m, "Σ 300 + 450 của ĐÚNG 2 dòng gửi lên");
c.BudgetManualAmount.Should().Be(300m, "NS tham chiếu = Σ PeReferenceAmount (100 + 200)");
var lines = await db.ContractSigningPlanLines.AsNoTracking()
.Where(l => l.ContractSigningPlanId == s.Plan.Id).ToListAsync();
lines.Should().HaveCount(2);
lines.Should().OnlyContain(l => l.ContractId == c.Id, "CẢ HAI dòng cùng trỏ 1 HĐ");
}
// ===================================================================
// T3 — 1 dòng chưa có giá chốt ⇒ chặn, message NÊU TÊN hạng mục, KHÔNG đốt mã.
// "Không đốt mã" = phép đo side-effect của sequence: rào (4) phải đứng TRƯỚC codegen
// (`:119-130` vs `:212`). Nếu ai đảo thứ tự, HĐ không sinh ra nhưng số RG-001 đã cháy
// — hỏng câm lặng, chỉ lộ khi soi bảng sequence.
// ===================================================================
[Fact]
public async Task T3_DongChuaCoGiaChot_ThiChan_KhongTaoHd_VaKhongDotMa()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db,
[
new LineSeed(0, 100m, 500m, Approved: 300m), // dòng lành
new LineSeed(0, 100m, 700m, Approved: null), // dòng thiếu giá chốt
]);
var handler = BuildHandler(db, fix.Services, s.Actor);
var tenHangMucThieu = s.Plan.Lines[1].TenHangMuc!;
var act = async () => await handler.Handle(
Cmd(s, [LineId(s, 0), LineId(s, 1)]), CancellationToken.None);
(await act.Should().ThrowAsync<ConflictException>())
.WithMessage($"*{tenHangMucThieu}*",
"message phải NÊU TÊN hạng mục — dialog chỉ hiện tên, không hiện id");
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0, "atomic: 0 HĐ dở dang");
(await db.ContractSigningPlanLines.AsNoTracking()
.CountAsync(l => l.ContractId != null)).Should().Be(0, "kể cả dòng LÀNH cũng không bị ghi");
(await db.ContractCodeSequences.AsNoTracking().CountAsync()).Should().Be(0,
"rào phải đứng TRƯỚC codegen — chặn xong mà số RG-001 đã cháy là hỏng câm lặng");
}
// ===================================================================
// T4 — Phiếu chưa duyệt xong (`ChoDuyet`) ⇒ chặn.
// ===================================================================
[Fact]
public async Task T4_PhieuChuaDuyetXong_ThiChan()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
// Dòng CÓ đủ giá chốt: nếu bỏ rào phase, call này sẽ THÀNH CÔNG ⇒ test phân biệt được
// đúng một biến (phase), không phải xanh nhờ vướng rào khác.
var s = await SeedAsync(fix, db,
[new LineSeed(0, 100m, 500m, Approved: 300m)],
phase: ContractSigningPlanPhase.ChoDuyet);
var handler = BuildHandler(db, fix.Services, s.Actor);
var act = async () => await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
(await act.Should().ThrowAsync<ConflictException>()).WithMessage("*Đã duyệt*");
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0);
}
// ===================================================================
// T5 — Dòng đã có HĐ ⇒ chặn double-bridge PER-DÒNG (không phải per-phiếu như cầu PE cũ).
// ===================================================================
[Fact]
public async Task T5_DongDaCoHopDong_ThiChan()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db, [new LineSeed(0, 100m, 500m, Approved: 300m)]);
// Đặt sẵn dấu "đã bắc cầu" bằng chính cột write-site.
var seededContractId = Guid.NewGuid();
var line = await db.ContractSigningPlanLines.SingleAsync(l => l.Id == LineId(s, 0));
line.ContractId = seededContractId;
await db.SaveChangesAsync(CancellationToken.None);
db.ChangeTracker.Clear();
var handler = BuildHandler(db, fix.Services, s.Actor);
var act = async () => await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
(await act.Should().ThrowAsync<ConflictException>())
.WithMessage($"*{s.Plan.Lines[0].TenHangMuc}*đã có Hợp đồng*");
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0);
(await db.ContractSigningPlanLines.AsNoTracking().SingleAsync(l => l.Id == LineId(s, 0)))
.ContractId.Should().Be(seededContractId, "HĐ cũ KHÔNG bị ghi đè");
}
// ===================================================================
// T6 — 2 dòng KHÁC NCC trong 1 lần bấm ⇒ chặn (1 HĐ = 1 NCC; liên-danh = N lần bấm).
// ===================================================================
[Fact]
public async Task T6_HaiDongKhacNcc_ThiChan()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db,
[
new LineSeed(0, 100m, 500m, Approved: 300m),
new LineSeed(1, 100m, 500m, Approved: 300m), // NCC KHÁC
], supplierCount: 2);
var handler = BuildHandler(db, fix.Services, s.Actor);
var act = async () => await handler.Handle(
Cmd(s, [LineId(s, 0), LineId(s, 1)]), CancellationToken.None);
(await act.Should().ThrowAsync<ConflictException>()).WithMessage("*nhiều NCC khác nhau*");
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0);
// ĐỐI CHỨNG DƯƠNG cùng lượt: tách ra bấm từng dòng thì QUA — chứng minh 2 dòng này
// hợp lệ về mọi mặt khác, cái chặn ở trên đúng là vế "khác NCC".
var r1 = await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
db.ChangeTracker.Clear();
var r2 = await handler.Handle(Cmd(s, [LineId(s, 1)]), CancellationToken.None);
r1.ContractId.Should().NotBe(r2.ContractId);
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(2, "liên-danh 2 NCC = 2 HĐ");
}
// ===================================================================
// T7 — Rào quy trình pin: 4 nhánh SAI + 1 ĐỐI CHỨNG DƯƠNG, tất cả trên CÙNG một phiếu.
// [vá-3] 2 vế `IsActive`/`IsUserSelectable` là phần VÁ (khuôn gốc `ContractFeatures.cs:79-87`
// chỉ hỏi tồn-tại + đúng-loại ⇒ forge POST pin được quy trình đã nghỉ / chưa ghim).
// Ma-trận cùng-ruột: mọi thứ khác giữ y nguyên, đổi ĐÚNG 1 biến là `ApprovalWorkflowId`.
// Message TÁCH RIÊNG từng nguyên nhân — gộp `||` thì admin không biết bấm nút nào.
// ===================================================================
[Fact]
public async Task T7_QuyTrinhPinSai_Chan4Nhanh_VaQuyTrinhDungThiQua()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db, [new LineSeed(0, 100m, 500m, Approved: 300m)]);
var wrongType = NewContractWorkflow("QT-KHKK-V2",
type: ApprovalWorkflowApplicableType.ContractSigningPlan); // loại 10, không phải 3
var retired = NewContractWorkflow("QT-HD-NGHI", isActive: false);
var unpinned = NewContractWorkflow("QT-HD-CHUA-GHIM", isUserSelectable: false);
db.ApprovalWorkflows.AddRange(wrongType, retired, unpinned);
await db.SaveChangesAsync(CancellationToken.None);
var handler = BuildHandler(db, fix.Services, s.Actor);
var lineIds = new[] { LineId(s, 0) };
// (a) không tồn tại → NotFound (khác Conflict: id rác là lỗi tham chiếu, không phải trạng thái).
await ((Func<Task>)(async () => await handler.Handle(
Cmd(s, lineIds, Guid.NewGuid()), CancellationToken.None)))
.Should().ThrowAsync<NotFoundException>();
// (b) đúng tồn tại nhưng SAI LOẠI (quy trình của chính module KHKK).
(await ((Func<Task>)(async () => await handler.Handle(
Cmd(s, lineIds, wrongType.Id), CancellationToken.None)))
.Should().ThrowAsync<ConflictException>())
.WithMessage("*không khớp với Hợp đồng*");
// (c) [vá-3] đã ngừng hoạt động.
(await ((Func<Task>)(async () => await handler.Handle(
Cmd(s, lineIds, retired.Id), CancellationToken.None)))
.Should().ThrowAsync<ConflictException>())
.WithMessage("*ngừng hoạt động*");
// (d) [vá-3] chưa được ghim cho người dùng chọn — message PHẢI khác (c).
(await ((Func<Task>)(async () => await handler.Handle(
Cmd(s, lineIds, unpinned.Id), CancellationToken.None)))
.Should().ThrowAsync<ConflictException>())
.WithMessage("*chưa được ghim*");
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0, "4 nhánh sai = 0 HĐ");
(await db.ContractCodeSequences.AsNoTracking().CountAsync()).Should().Be(0,
"rào quy trình cũng phải đứng TRƯỚC codegen");
// ---- ĐỐI CHỨNG DƯƠNG: đổi ĐÚNG 1 biến (quy trình hợp lệ) → QUA.
// Thiếu vế này thì 4 khẳng-định trên vẫn xanh trong một thế giới mà cầu chặn TẤT CẢ.
var ok = await handler.Handle(Cmd(s, lineIds, s.ContractWorkflowId), CancellationToken.None);
ok.ContractId.Should().NotBeEmpty();
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(1);
}
// ===================================================================
// T8 (khoá V3) — MỐI NỐI cầu → đường ống V2.
//
// `ContractV2PipelineTests` (W6-S161) đã khoá đường ống, NHƯNG nó **seed thẳng** HĐ ở
// `ChoDuyet` + `ApprovalWorkflowId` + `stepIndex` — tức cho sẵn thứ mà cầu phải tự sinh.
// Test này đo phần KHÔNG ai đo: HĐ do CẦU đẻ có đi vào máy V2 được không.
// Thế giới hỏng phân biệt được: cầu quên pin `ApprovalWorkflowId` ⇒ `TransitionAsync:97`
// để `CurrentApprovalLevelOrder = null` ⇒ `ResolveV2InboxIdsAsync` không khớp ⇒ hộp thư
// của ĐÚNG người phải duyệt rỗng, mà HĐ thì vẫn "đã trình" — treo vô thời hạn, im lặng.
// ===================================================================
[Fact]
public async Task T8_HdSinhTuCau_TrinhDuyet_ThiVaoDungHopThuCapDangCho()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db, [new LineSeed(0, 100m, 500m, Approved: 300m)]);
// 2 Cấp trong 1 Bước: cấp 1 = a, cấp 2 = b (OR-of-N không cần thiết ở đây).
var a = await fix.CreateUserAsync($"a-{Guid.NewGuid():N}@test.local", "Duyệt cấp 1", null, []);
var b = await fix.CreateUserAsync($"b-{Guid.NewGuid():N}@test.local", "Duyệt cấp 2", null, []);
var step = new ApprovalWorkflowStep
{
Id = Guid.NewGuid(), ApprovalWorkflowId = s.ContractWorkflowId,
Order = 1, Name = "Bước 1", DepartmentId = null,
};
db.ApprovalWorkflowSteps.Add(step);
db.ApprovalWorkflowLevels.AddRange(
new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = step.Id, Order = 1, Name = "Cấp 1", ApproverUserId = a.Id },
new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = step.Id, Order = 2, Name = "Cấp 2", ApproverUserId = b.Id });
await db.SaveChangesAsync(CancellationToken.None);
var handler = BuildHandler(db, fix.Services, s.Actor);
var result = await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
// ---- Trình duyệt QUA MÁY THẬT (không set Phase bằng tay — seed thẳng là đo nhầm cơ chế).
var dt = new FixedDateTime(Clock);
var drafterUser = new TestCurrentUser(s.Actor.Id, s.Actor.FullName, s.Actor.Email, AppRoles.Drafter);
var svc = new ContractWorkflowService(
db, new ContractCodeGenerator(db, dt), dt, new NoOpNotificationServiceApp(),
new ChangelogService(db, drafterUser, fix.Services.GetRequiredService<UserManager<User>>()),
fix.Services.GetRequiredService<UserManager<User>>());
var contract = await db.Contracts.SingleAsync(x => x.Id == result.ContractId);
await svc.TransitionAsync(contract, ContractPhase.ChoDuyet, s.Actor.Id,
[AppRoles.Drafter], ApprovalDecision.Approve, "trình", ct: CancellationToken.None);
var after = await db.Contracts.AsNoTracking().SingleAsync(x => x.Id == result.ContractId);
after.Phase.Should().Be(ContractPhase.ChoDuyet);
after.CurrentWorkflowStepIndex.Should().Be(0);
after.CurrentApprovalLevelOrder.Should().Be(1,
"con-trỏ Cấp CHỈ được khởi tạo khi HĐ pin V2 (`ContractWorkflowService.cs:97`) — " +
"null ở đây nghĩa là cầu quên pin `ApprovalWorkflowId`");
// ---- Hộp thư: người của Cấp ĐANG CHỜ thấy; người Cấp SAU chưa thấy.
// Cả 2 user KHÔNG mang role legacy nào ⇒ chỉ có chân V2, đo đúng vế V2.
var inboxA = await new GetMyInboxQueryHandler(db, new TestCurrentUser(a.Id, a.FullName, a.Email))
.Handle(new GetMyInboxQuery(), CancellationToken.None);
inboxA.Select(i => i.Id).Should().BeEquivalentTo(new[] { result.ContractId },
"HĐ sinh từ cầu phải nằm trong hộp thư của Cấp 1 — đây là toàn bộ lý do cầu tồn tại");
var inboxB = await new GetMyInboxQueryHandler(db, new TestCurrentUser(b.Id, b.FullName, b.Email))
.Handle(new GetMyInboxQuery(), CancellationToken.None);
inboxB.Should().BeEmpty("Cấp 2 chưa tới lượt");
}
// ===================================================================
// T9 — Rào tập `LineIds`: id của phiếu KHÁC (hoặc id rác) không được lọt.
// Đây là rào duy nhất chặn "đưa dòng của phiếu người khác vào HĐ của mình".
// ===================================================================
[Fact]
public async Task T9_DongCuaPhieuKhac_ThiChan()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s1 = await SeedAsync(fix, db, [new LineSeed(0, 100m, 500m, Approved: 300m)]);
var s2 = await SeedAsync(fix, db, [new LineSeed(0, 100m, 500m, Approved: 999m)],
maKeHoach: "KHKK/2026/002");
var handler = BuildHandler(db, fix.Services, s1.Actor);
// Trộn 1 dòng của phiếu 1 + 1 dòng của phiếu 2 → chặn.
var act = async () => await handler.Handle(
Cmd(s1, [LineId(s1, 0), LineId(s2, 0)]), CancellationToken.None);
(await act.Should().ThrowAsync<ConflictException>()).WithMessage("*không thuộc kế hoạch này*");
// Id rác cũng rơi vào cùng rào (đếm sau Distinct).
var act2 = async () => await handler.Handle(
Cmd(s1, [LineId(s1, 0), Guid.NewGuid()]), CancellationToken.None);
(await act2.Should().ThrowAsync<ConflictException>()).WithMessage("*không thuộc kế hoạch này*");
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0);
(await db.ContractSigningPlanLines.AsNoTracking()
.CountAsync(l => l.ContractId != null)).Should().Be(0, "dòng của phiếu hàng xóm không bị đụng");
}
// ===================================================================
// T10 — Bấm lần 2 trên DÒNG KHÁC của cùng phiếu ⇒ HĐ thứ 2 sinh ra,
// nhưng `pe.ContractId` GIỮ HĐ ĐẦU. Đây là phép đo CHIỀU của `??=` (vá-4a):
// viết `=` thay `??=` thì mọi assert khác vẫn xanh, chỉ vế này ĐỎ.
// Đồng thời là phép đo cardinality của DoD vá-15 (N lần bấm = N ContractId).
// ===================================================================
[Fact]
public async Task T10_BacCauLanHai_TaoHdThuHai_NhungPeGiuHdDau()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db,
[
new LineSeed(0, 100m, 500m, Approved: 300m),
new LineSeed(0, 100m, 500m, Approved: 450m),
]);
var handler = BuildHandler(db, fix.Services, s.Actor);
var first = await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
db.ChangeTracker.Clear();
var second = await handler.Handle(Cmd(s, [LineId(s, 1)]), CancellationToken.None);
second.ContractId.Should().NotBe(first.ContractId);
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(2);
var lines = await db.ContractSigningPlanLines.AsNoTracking()
.Where(l => l.ContractSigningPlanId == s.Plan.Id).OrderBy(l => l.ApprovedAmount).ToListAsync();
lines[0].ContractId.Should().Be(first.ContractId);
lines[1].ContractId.Should().Be(second.ContractId);
var pe = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(p => p.Id == s.Pe.Id);
pe.ContractId.Should().Be(first.ContractId,
"[vá-4a] `??=` CÓ CHIỀU: HĐ đầu giữ ô đơn của PE; HĐ sau KHÔNG cướp ô " +
"(chúng vẫn nhìn thấy được qua `Line.ContractId` — chính là lý do vá-4a tồn tại)");
// Mã HĐ thứ 2 tăng seq trên CÙNG prefix (cùng dự án + cùng NCC) — chứng codegen chạy thật.
var codes = await db.Contracts.AsNoTracking().Select(c => c.MaHopDong).ToListAsync();
codes.Should().BeEquivalentTo(new[]
{
$"{s.Project.Code}/HĐTP/SOL&{s.Suppliers[0].Code}/01",
$"{s.Project.Code}/HĐTP/SOL&{s.Suppliers[0].Code}/02",
});
}
// ===================================================================
// T11 — DOUBLE-CLICK: bấm 2 lần liên tiếp CÙNG tập dòng.
// Hành vi ĐANG CÓ của BE (đọc `:128-129`): lần 2 rơi vào rào per-dòng "đã có Hợp đồng"
// ⇒ `ConflictException`. Test khoá đúng hành vi đó, KHÔNG chế kỳ vọng "no-op im lặng".
// `ChangeTracker.Clear()` mô phỏng request thứ 2 (context mới đọc lại từ DB).
// ===================================================================
[Fact]
public async Task T11_BamHaiLanCungTapDong_ThiLanHaiBiChan_KhongTaoHdThua()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db, [new LineSeed(0, 100m, 500m, Approved: 300m)]);
var handler = BuildHandler(db, fix.Services, s.Actor);
var first = await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
db.ChangeTracker.Clear();
var act = async () => await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
(await act.Should().ThrowAsync<ConflictException>()).WithMessage("*đã có Hợp đồng*");
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(1, "double-click không đẻ HĐ thứ 2");
(await db.ContractSigningPlanLines.AsNoTracking().SingleAsync(l => l.Id == LineId(s, 0)))
.ContractId.Should().Be(first.ContractId, "dòng vẫn trỏ HĐ của lần bấm đầu");
var seq = await db.ContractCodeSequences.AsNoTracking().SingleAsync();
seq.LastSeq.Should().Be(1, "lần bấm 2 bị chặn TRƯỚC codegen ⇒ số RG-001 không cháy oan");
}
// ===================================================================
// T12 — [vá-7 spec-cụm-3 §②-7] NEO ACCEPTANCE B6, đo END-TO-END trên máy THẬT.
//
// Vì sao phải có dù K3 đã có test finalize: các test kia dừng ở "finalize ghi
// `ApprovedAmount`". Cầu K7 thì ĐỌC cột đó. Mối nối giữa hai vế chưa ai đo — mà chính nó
// là điều kiện sống của toàn bộ happy-path (invest `:154`: "không có K3 thì `ApprovedAmount`
// NULL 100%" ⇒ cầu chặn mọi phiếu). Ở đây phiếu đi TRỌN đường qua
// `ContractSigningPlanWorkflowService` THẬT (trình → duyệt → terminal chốt giá), KHÔNG seed
// thẳng Phase/giá — seed thẳng là tự đưa cho mình đáp án rồi chấm.
//
// Tương phản để phép đo không rỗng:
// dòng A: `ApprovedAmount` = 111 CÓ SẴN (người có thẩm quyền chốt tay), `Proposed` = 999
// dòng B: `ApprovedAmount` = null → finalize `??=` điền = `Proposed` = 250
// ⇒ `GiaTri` phải = 361. Σ `Proposed` = 1249. Nếu cầu (hoặc finalize) lấy nhầm nguồn thì
// ra 1249 / 1109 / 500 — mọi cách sai đều KHÁC 361.
//
// Vế cuối = yêu cầu chữ của vá-7 ("`Line.ContractId` không đổi sau finalize"): sau khi bắc
// cầu, phiếu ĐÃ terminal nên không còn đường finalize lần nữa — chứng bằng cách BẤM THẬT
// (`approve` → Conflict) rồi đọc lại cả `ContractId` lẫn `ApprovedAmount`.
// ===================================================================
[Fact]
public async Task T12_DiTronDuongFinalizeRoiBacCau_GiaTriLaSoDaCHOT_VaKhongDoiVeSau()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db,
[
new LineSeed(0, 100m, Proposed: 999m, Approved: 111m), // đã chốt tay
new LineSeed(0, 100m, Proposed: 250m, Approved: null), // để finalize điền
], phase: ContractSigningPlanPhase.DangSoanThao);
// Quy trình KHKK (loại 10) 1 Bước × 1 Cấp — đường ngắn nhất tới terminal.
var approver = await fix.CreateUserAsync(
$"tp-{Guid.NewGuid():N}@test.local", "TP duyệt", null, []);
var khkkWf = NewContractWorkflow($"QT-KHKK-{Guid.NewGuid().ToString("N")[..6]}",
type: ApprovalWorkflowApplicableType.ContractSigningPlan);
db.ApprovalWorkflows.Add(khkkWf);
var step = new ApprovalWorkflowStep
{
Id = Guid.NewGuid(), ApprovalWorkflowId = khkkWf.Id,
Order = 1, Name = "Bước 1", DepartmentId = null,
};
db.ApprovalWorkflowSteps.Add(step);
db.ApprovalWorkflowLevels.Add(new ApprovalWorkflowLevel
{
Id = Guid.NewGuid(), ApprovalWorkflowStepId = step.Id,
Order = 1, Name = "Cấp 1", ApproverUserId = approver.Id,
});
var plan = await db.ContractSigningPlans.SingleAsync(p => p.Id == s.Plan.Id);
plan.ApprovalWorkflowId = khkkWf.Id;
await db.SaveChangesAsync(CancellationToken.None);
db.ChangeTracker.Clear();
var dt = new FixedDateTime(Clock);
ContractSigningPlanWorkflowService KhkkSvc(User u, params string[] roles)
=> new(db, new TestCurrentUser(u.Id, u.FullName, u.Email, roles), dt,
new NoOpNotificationServiceApp());
// ---- Trình → duyệt (Cấp cuối của Bước cuối ⇒ terminal + chốt giá).
await KhkkSvc(s.Actor, AppRoles.Drafter)
.TransitionAsync(s.Plan.Id, "submit", null, ct: CancellationToken.None);
var afterApprove = await KhkkSvc(approver)
.TransitionAsync(s.Plan.Id, "approve", "ok", ct: CancellationToken.None);
afterApprove.Phase.Should().Be((int)ContractSigningPlanPhase.DaDuyet,
"B6 neo (a): phiếu phải TỰ đi tới Đã duyệt qua máy, không phải do test gán");
db.ChangeTracker.Clear();
var frozen = await db.ContractSigningPlanLines.AsNoTracking()
.Where(l => l.ContractSigningPlanId == s.Plan.Id).OrderBy(l => l.ProposedAmount).ToListAsync();
frozen.Select(l => l.ApprovedAmount).Should().BeEquivalentTo(new decimal?[] { 250m, 111m },
"B6 neo (b): finalize `??=` — dòng chốt tay GIỮ 111 (không bị 999 đè), dòng trống lấy 250");
// ---- Bây giờ mới bắc cầu, trên đúng những con số vừa đông cứng.
var handler = BuildHandler(db, fix.Services, s.Actor);
var result = await handler.Handle(
Cmd(s, frozen.Select(l => l.Id)), CancellationToken.None);
var c = await db.Contracts.AsNoTracking().SingleAsync(x => x.Id == result.ContractId);
c.GiaTri.Should().Be(361m,
"= 111 + 250 (số ĐÃ CHỐT). Σ ProposedAmount = 1249 — mọi cách lấy nhầm nguồn đều ra số khác");
// ---- Vế cuối vá-7: sau khi bắc cầu, phiếu terminal ⇒ không còn đường chốt lại giá.
db.ChangeTracker.Clear();
var act = async () => await KhkkSvc(approver)
.TransitionAsync(s.Plan.Id, "approve", "duyệt lại", ct: CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>();
var after = await db.ContractSigningPlanLines.AsNoTracking()
.Where(l => l.ContractSigningPlanId == s.Plan.Id).OrderBy(l => l.ProposedAmount).ToListAsync();
after.Should().OnlyContain(l => l.ContractId == result.ContractId,
"`Line.ContractId` KHÔNG đổi sau khi phiếu đã terminal");
after.Select(l => l.ApprovedAmount).Should().BeEquivalentTo(new decimal?[] { 250m, 111m },
"giá đã chốt cũng không đổi — HĐ và phiếu kể cùng một câu chuyện tiền");
}
}