diff --git a/fe-admin/src/hooks/usePipelineStages.ts b/fe-admin/src/hooks/usePipelineStages.ts index 1a30a12..c39f014 100644 --- a/fe-admin/src/hooks/usePipelineStages.ts +++ b/fe-admin/src/hooks/usePipelineStages.ts @@ -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() 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, diff --git a/fe-admin/src/pages/khkk/KhkkDetailPage.tsx b/fe-admin/src/pages/khkk/KhkkDetailPage.tsx index c8315d4..ca3dc80 100644 --- a/fe-admin/src/pages/khkk/KhkkDetailPage.tsx +++ b/fe-admin/src/pages/khkk/KhkkDetailPage.tsx @@ -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(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]} + {/* [K7 S167 — vá-1 vế FE] Nút bắc cầu KHKK → HĐ. `` 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 && ( + + + + )} {canDeletePlan && ( + + + } + > +
+
Tạo hợp đồng từ kế hoạch “{plan.maKeHoach ?? 'chưa cấp mã'}”
+
    +
  • Mỗi lần bấm tạo 1 hợp đồng cho 1 dòng (1 nhà cung cấp). Liên danh nhiều NCC thì làm lần lượt.
  • +
  • Giá trị HĐ lấy đúng giá duyệt đã chốt của dòng — không lấy giá đề xuất.
  • +
  • HĐ mới ở trạng thái Nháp và đi theo quy trình duyệt bạn chọn dưới đây.
  • +
  • Dòng đã đưa vào HĐ thì không đưa lần hai — cần sửa thì xử lý ở chính hợp đồng đó.
  • +
+
+ +
+
+ + {plan.lines.length === 0 ? ( +
Phiếu chưa có dòng nhà cung cấp nào.
+ ) : ( +
    + {plan.lines.map((l) => { + const done = l.contractId != null + const noPrice = l.approvedAmount == null + const disabled = done || noPrice + return ( +
  • + +
  • + ) + })} +
+ )} +
+ +
+ + +
+ +
+ + + {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. +

+ Chưa có 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 + và được ghim). Nhờ quản trị mở trong màn Quy trình duyệt rồi quay lại. +

+ )} +
+ +
+ + setTenHopDongOverride(e.target.value)} + maxLength={500} + /> +

+ Bỏ trống thì hệ thống tự đặt theo tên gói thầu và hạng mục của dòng. +

+
+
+ + ) +} diff --git a/fe-admin/src/types/khkk.ts b/fe-admin/src/types/khkk.ts index 78f9c2c..ffe3e3d 100644 --- a/fe-admin/src/types/khkk.ts +++ b/fe-admin/src/types/khkk.ts @@ -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 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>, +): 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 { items: T[] total: number diff --git a/fe-user/src/hooks/usePipelineStages.ts b/fe-user/src/hooks/usePipelineStages.ts index 1a30a12..c39f014 100644 --- a/fe-user/src/hooks/usePipelineStages.ts +++ b/fe-user/src/hooks/usePipelineStages.ts @@ -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() 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, diff --git a/fe-user/src/pages/khkk/KhkkDetailPage.tsx b/fe-user/src/pages/khkk/KhkkDetailPage.tsx index c8315d4..ca3dc80 100644 --- a/fe-user/src/pages/khkk/KhkkDetailPage.tsx +++ b/fe-user/src/pages/khkk/KhkkDetailPage.tsx @@ -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(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]}
+ {/* [K7 S167 — vá-1 vế FE] Nút bắc cầu KHKK → HĐ. `` 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 && ( + + + + )} {canDeletePlan && ( + + + } + > +
+
Tạo hợp đồng từ kế hoạch “{plan.maKeHoach ?? 'chưa cấp mã'}”
+
    +
  • Mỗi lần bấm tạo 1 hợp đồng cho 1 dòng (1 nhà cung cấp). Liên danh nhiều NCC thì làm lần lượt.
  • +
  • Giá trị HĐ lấy đúng giá duyệt đã chốt của dòng — không lấy giá đề xuất.
  • +
  • HĐ mới ở trạng thái Nháp và đi theo quy trình duyệt bạn chọn dưới đây.
  • +
  • Dòng đã đưa vào HĐ thì không đưa lần hai — cần sửa thì xử lý ở chính hợp đồng đó.
  • +
+
+ +
+
+ + {plan.lines.length === 0 ? ( +
Phiếu chưa có dòng nhà cung cấp nào.
+ ) : ( +
    + {plan.lines.map((l) => { + const done = l.contractId != null + const noPrice = l.approvedAmount == null + const disabled = done || noPrice + return ( +
  • + +
  • + ) + })} +
+ )} +
+ +
+ + +
+ +
+ + + {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. +

+ Chưa có 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 + và được ghim). Nhờ quản trị mở trong màn Quy trình duyệt rồi quay lại. +

+ )} +
+ +
+ + setTenHopDongOverride(e.target.value)} + maxLength={500} + /> +

+ Bỏ trống thì hệ thống tự đặt theo tên gói thầu và hạng mục của dòng. +

+
+
+ + ) +} diff --git a/fe-user/src/types/khkk.ts b/fe-user/src/types/khkk.ts index 78f9c2c..ffe3e3d 100644 --- a/fe-user/src/types/khkk.ts +++ b/fe-user/src/types/khkk.ts @@ -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 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>, +): 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 { items: T[] total: number diff --git a/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs b/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs index cc9e45b..18f1a92 100644 --- a/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs @@ -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> 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? LineIds, + ContractType ContractType, + Guid ApprovalWorkflowId, + string? TenHopDong = null); } diff --git a/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs b/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs index 87fd611..fb3c651 100644 --- a/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs +++ b/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs @@ -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`, 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 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(items, total, page, pageSize); diff --git a/src/Backend/SolutionErp.Application/ContractSigningPlans/CreateContractFromSigningPlanFeatures.cs b/src/Backend/SolutionErp.Application/ContractSigningPlans/CreateContractFromSigningPlanFeatures.cs new file mode 100644 index 0000000..b721760 --- /dev/null +++ b/src/Backend/SolutionErp.Application/ContractSigningPlans/CreateContractFromSigningPlanFeatures.cs @@ -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` — 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); + +/// +/// 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. +/// +/// +/// 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`. +/// +public record CreateContractFromSigningPlanCommand( + Guid PlanId, + List LineIds, + ContractType ContractType, + Guid ApprovalWorkflowId, + string? TenHopDong = null) : IRequest; + +public class CreateContractFromSigningPlanCommandValidator + : AbstractValidator +{ + 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 +{ + public async Task 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); + } +} diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs index 86817b1..8d5bc9c 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs @@ -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 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 diff --git a/tests/SolutionErp.Infrastructure.Tests/Api/AuthorizePolicyRegressionTests.cs b/tests/SolutionErp.Infrastructure.Tests/Api/AuthorizePolicyRegressionTests.cs index e586119..b957157 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Api/AuthorizePolicyRegressionTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Api/AuthorizePolicyRegressionTests.cs @@ -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 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"); } } diff --git a/tests/SolutionErp.Infrastructure.Tests/Api/KhkkBridgeAuthzTests.cs b/tests/SolutionErp.Infrastructure.Tests/Api/KhkkBridgeAuthzTests.cs new file mode 100644 index 0000000..e1e2875 --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Api/KhkkBridgeAuthzTests.cs @@ -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(inherit: false) + .FirstOrDefault()?.Policy; + var actionPolicy = t + .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .First(m => m.Name == nameof(ContractSigningPlansController.CreateContract)) + .GetCustomAttributes(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 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(), + fix.Services.GetRequiredService>(), + fix.Services.GetRequiredService>()); + + 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 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 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>().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(); + 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(); + 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(); + 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(); + 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(); + 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"); + } +} diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/CreateContractFromSigningPlanTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/CreateContractFromSigningPlanTests.cs new file mode 100644 index 0000000..a9f5b5b --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/CreateContractFromSigningPlanTests.cs @@ -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` 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>(); + 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 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 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(); + 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 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(); + 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(); + 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(); + 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()) + .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(); + // 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()).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(); + 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()) + .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(); + 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()).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(); + 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)(async () => await handler.Handle( + Cmd(s, lineIds, Guid.NewGuid()), CancellationToken.None))) + .Should().ThrowAsync(); + + // (b) đúng tồn tại nhưng SAI LOẠI (quy trình của chính module KHKK). + (await ((Func)(async () => await handler.Handle( + Cmd(s, lineIds, wrongType.Id), CancellationToken.None))) + .Should().ThrowAsync()) + .WithMessage("*không khớp với Hợp đồng*"); + + // (c) [vá-3] đã ngừng hoạt động. + (await ((Func)(async () => await handler.Handle( + Cmd(s, lineIds, retired.Id), CancellationToken.None))) + .Should().ThrowAsync()) + .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)(async () => await handler.Handle( + Cmd(s, lineIds, unpinned.Id), CancellationToken.None))) + .Should().ThrowAsync()) + .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(); + 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>()), + fix.Services.GetRequiredService>()); + 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(); + 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()).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()).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(); + 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(); + 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()).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(); + 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(); + + 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"); + } +}