[CLAUDE] Contract: W7 KHKK GD4 "Hop dong cung" 1-moc — HardCopiesPage x2 + ?type filter + hasSealedCopy badge + 3 test PIN (suite 590/0)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m50s

- BE: ListContractsQuery +ContractType? type (filter server-side F-C1/F-05) + DTO HasSealedCopy derive Attachments.Any(purpose=SealedCopy=3) — append-cuoi default false, 0 policy moi, 0 migration, attachment endpoints 0-diff (O-A giu)
- FE x2 SHA-PAIR: trang /hard-copies (list phase=9 hardcode + badge + panel upload multipart purpose=3 + Y kien cap duyet readonly) + 7 leaf Hdc_* re-point ?type=1..7 (coming-soon 0/0, menu 142 giu)
- Tests: ContractHardCopyTests 3 PIN (phase-only + type-filter Total-server-side + badge derive purpose=3 no-IsDeleted) — probe vi-phan 5/5 phan biet ban hong; suite 587->590/0
- Review: PASS-co-dieu-kien (FLAG-1 untracked da gate; FLAG-2/3 comment da va, SHA-pair C7DCC20B giu; +31/-4 errata BE self-report)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-30 00:17:52 +07:00
parent e16fa14ebd
commit 3119cb9104
12 changed files with 1358 additions and 18 deletions

View File

@ -20,6 +20,7 @@ import { FormsPage } from '@/pages/forms/FormsPage'
import { ContractsListPage } from '@/pages/contracts/ContractsListPage' import { ContractsListPage } from '@/pages/contracts/ContractsListPage'
import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage' import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
import { ContractCreatePage } from '@/pages/contracts/ContractCreatePage' import { ContractCreatePage } from '@/pages/contracts/ContractCreatePage'
import { HardCopiesPage } from '@/pages/contracts/HardCopiesPage'
import { ReportsPage } from '@/pages/ReportsPage' import { ReportsPage } from '@/pages/ReportsPage'
import { UsersPage } from '@/pages/system/UsersPage' import { UsersPage } from '@/pages/system/UsersPage'
import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage' import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage'
@ -81,6 +82,9 @@ function App() {
<Route path="/contracts" element={<ContractsListPage />} /> <Route path="/contracts" element={<ContractsListPage />} />
<Route path="/contracts/new" element={<ContractCreatePage />} /> <Route path="/contracts/new" element={<ContractCreatePage />} />
<Route path="/contracts/:id" element={<ContractDetailPage />} /> <Route path="/contracts/:id" element={<ContractDetailPage />} />
{/* [W7 KHKK S161] GĐ4 "Hợp đồng cứng" — 7 leaf `Hdc_*` deep-link ?type=1..7
(Layout.tsx staticMap). Không query = tất cả loại. */}
<Route path="/hard-copies" element={<HardCopiesPage />} />
<Route path="/purchase-evaluations" element={<PurchaseEvaluationsListPage />} /> <Route path="/purchase-evaluations" element={<PurchaseEvaluationsListPage />} />
<Route path="/purchase-evaluations/workspace" element={<PurchaseEvaluationWorkspacePage />} /> <Route path="/purchase-evaluations/workspace" element={<PurchaseEvaluationWorkspacePage />} />
<Route path="/purchase-evaluations/new" element={<PurchaseEvaluationCreatePage />} /> <Route path="/purchase-evaluations/new" element={<PurchaseEvaluationCreatePage />} />

View File

@ -38,13 +38,16 @@ function resolvePath(key: string): string | null {
Khkk_Pending: '/khkk/list?filter=ChoDuyet', Khkk_Pending: '/khkk/list?filter=ChoDuyet',
Khkk_Approved: '/khkk/list?filter=DaDuyet', Khkk_Approved: '/khkk/list?filter=DaDuyet',
Khkk_Deleted: '/khkk/list?view=deleted', // [S159-đợt5 FLAG-1] thiếu là leaf drop SILENT (gotcha #50) Khkk_Deleted: '/khkk/list?view=deleted', // [S159-đợt5 FLAG-1] thiếu là leaf drop SILENT (gotcha #50)
Hdc_ThauPhu: '/coming-soon?stage=4&t=01', // [W7 KHKK S161] GĐ4 hết skeleton — 7 leaf `Hdc_*` re-point trang thật
Hdc_GiaoKhoan: '/coming-soon?stage=4&t=02', // `/hard-copies?type=N` (N = ContractType int, mirror TYPE_CODE_TO_INT ở trên).
Hdc_NhaCungCap: '/coming-soon?stage=4&t=03', // gotcha #50: thiếu/để sai route ở đây = leaf DROP IM LẶNG khỏi sidebar.
Hdc_DichVu: '/coming-soon?stage=4&t=04', Hdc_ThauPhu: '/hard-copies?type=1',
Hdc_MuaBan: '/coming-soon?stage=4&t=05', Hdc_GiaoKhoan: '/hard-copies?type=2',
Hdc_NguyenTacNcc: '/coming-soon?stage=4&t=06', Hdc_NhaCungCap: '/hard-copies?type=3',
Hdc_NguyenTacDv: '/coming-soon?stage=4&t=07', Hdc_DichVu: '/hard-copies?type=4',
Hdc_MuaBan: '/hard-copies?type=5',
Hdc_NguyenTacNcc: '/hard-copies?type=6',
Hdc_NguyenTacDv: '/hard-copies?type=7',
Dashboard: '/dashboard', Dashboard: '/dashboard',
Suppliers: '/master/suppliers', Suppliers: '/master/suppliers',
Projects: '/master/projects', Projects: '/master/projects',

View File

@ -0,0 +1,447 @@
// [W7 KHKK — S161 2026-07-29] "Hợp đồng cứng" (GĐ4) — mô hình 1 MỐC.
// Anh chốt @S161 (verbatim): "... Upload file cứng lên -> Là xem như xong" ⇒ ký GĐ
// (b.19) · đóng dấu (b.20) · thủ tục lưu/phát hành (b.21) diễn ra NGOÀI hệ thống trên
// GIẤY. Hệ thống chỉ ghi nhận BẰNG CHỨNG CUỐI: 1 file scan bộ HĐ đã ký + đóng dấu
// (`AttachmentPurpose.SealedCopy = 3`, có sẵn — 0 migration, 0 enum-extend).
//
// Khuôn: `pages/khkk/KhkkListPage.tsx` (W2 — ui/PageHeader + card-accent table +
// phân trang) & `components/ContractAttachmentsSection.tsx` (multipart upload HĐ).
// File MIRROR SHA256 identical với fe-admin counterpart.
//
// URL vào từ menu (Layout.tsx staticMap — chỗ thứ 4 của Pattern 16-bis):
// Hdc_ThauPhu … Hdc_NguyenTacDv → /hard-copies?type=1 … ?type=7
// HopDongCung (root) có 7 con ⇒ render MenuGroup, path KHÔNG dùng — nhánh
// "không query = tất cả loại" chỉ tới được bằng gõ URL tay (review W7 FLAG-2).
// 🔴 Lọc loại HĐ đi qua THAM SỐ SERVER `?type=` (review F-C1/F-05) — KHÔNG lọc
// client-side trên trang `pageSize` (sẽ mất HĐ khi 1 loại vượt trần trang).
import { Fragment, useRef, useState, type ChangeEvent, type DragEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useSearchParams } from 'react-router-dom'
import {
Archive, ChevronDown, ChevronRight, Download, FileText, Inbox,
PenLine, Search, ShieldCheck, Upload,
} from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/ui/PageHeader'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Textarea } from '@/components/ui/Textarea'
import { api, TOKEN_KEY } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import {
ContractPhase,
type ContractAttachment,
type ContractDetail,
type ContractListItem,
} from '@/types/contracts'
import { ContractTypeLabel } from '@/types/forms'
import type { Paged } from '@/types/master'
const PAGE_SIZE = 20
// Mirror BE `AttachmentPurpose.SealedCopy` (Domain/Contracts/ContractAttachment.cs:5-11).
// 🔴 KHÔNG dùng `ScannedSigned = 2` — nghĩa của nó là "scan có chữ ký NCC ở phase
// Đang in ký" (gotcha #71), không phải bộ cứng đã đóng dấu.
const SEALED_COPY = 3
const BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? '') + '/api'
function formatVnd(n: number | null | undefined): string {
if (n === null || n === undefined) return '—'
return n.toLocaleString('vi-VN') + ' đ'
}
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString('vi-VN', { dateStyle: 'short', timeStyle: 'short' })
}
function fmtSize(n: number): string {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / (1024 * 1024)).toFixed(1)} MB`
}
export function HardCopiesPage() {
const [searchParams] = useSearchParams()
const rawType = searchParams.get('type')
const parsedType = rawType === null ? Number.NaN : Number(rawType)
const typeFilter = Number.isFinite(parsedType) && parsedType > 0 ? parsedType : null
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [openId, setOpenId] = useState<string | null>(null)
const list = useQuery({
queryKey: ['hard-copies', { type: typeFilter, search, page }],
queryFn: async () =>
(
await api.get<Paged<ContractListItem>>('/contracts', {
params: {
// GĐ4 chỉ theo dõi HĐ ĐÃ PHÁT HÀNH (phase 9 = terminal của GĐ3).
phase: ContractPhase.DaPhatHanh,
type: typeFilter ?? undefined, // `?? undefined` ⇒ axios bỏ hẳn key
search: search.trim() || undefined,
page,
pageSize: PAGE_SIZE,
},
})
).data,
})
const items = list.data?.items ?? []
const total = list.data?.total ?? 0
const totalPages = list.data?.totalPages ?? 1
const typeLabel = typeFilter !== null ? ContractTypeLabel[typeFilter] : null
return (
<div className="space-y-5">
<PageHeader
eyebrow="Giai đoạn 4"
title={typeLabel ? `Hợp đồng cứng — ${typeLabel}` : 'Hợp đồng cứng'}
subtitle="Theo dõi bộ hợp đồng bản cứng của các HĐ đã phát hành"
icon={<Archive className="h-5 w-5" />}
accent="teal"
/>
{/* Ghi chú 1-MỐC — nói rõ ký/đóng dấu/lưu là việc NGOÀI hệ thống, tránh user
tưởng còn thiếu trạm (rủi ro §③-C của spec W7). */}
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-[13px] leading-relaxed text-amber-900">
<span className="font-semibold"> giám đc · đóng dấu · thủ tục lưu bộ gốc đưc thực hiện NGOÀI hệ thống (trên giấy).</span>{' '}
Hệ thống chỉ ghi nhận bằng chứng cuối: tải lên bản scan bộ hợp đng đã + đóng dấu.
file scan xem như <span className="font-semibold">xong</span>.
</div>
<div
className="card-accent flex items-center gap-3 px-4 py-3"
style={{ ['--accent' as string]: 'var(--color-teal-500)' }}
>
<Search className="h-4 w-4 shrink-0 text-slate-400" />
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
placeholder="Tìm mã HĐ, tên HĐ hoặc nhà cung cấp..."
className="max-w-md border-0 bg-transparent px-0 shadow-none focus-visible:ring-0"
/>
</div>
<div
className="card-accent overflow-hidden"
style={{ ['--accent' as string]: 'var(--color-teal-500)' }}
>
<table className="w-full text-sm">
<thead className="border-b border-slate-200 bg-slate-50/70">
<tr>
<th className="label-eyebrow w-8 px-4 py-2.5 text-left" />
<th className="label-eyebrow px-4 py-2.5 text-left"> </th>
<th className="label-eyebrow px-4 py-2.5 text-left">Tên hợp đng</th>
<th className="label-eyebrow px-4 py-2.5 text-left">Nhà cung cấp</th>
<th className="label-eyebrow px-4 py-2.5 text-left">Dự án</th>
<th className="label-eyebrow px-4 py-2.5 text-right">Giá trị</th>
<th className="label-eyebrow px-4 py-2.5 text-left">Bản cứng</th>
</tr>
</thead>
<tbody>
{list.isLoading && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">
Đang tải...
</td>
</tr>
)}
{!list.isLoading && items.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-slate-500">
<span
className="icon-chip mx-auto mb-2 flex"
style={{ ['--chip-bg' as string]: '#f1f5f9', ['--chip-fg' as string]: '#94a3b8' }}
aria-hidden
>
<Inbox className="h-4 w-4" />
</span>
{typeLabel
? `Chưa có ${typeLabel} nào được phát hành.`
: 'Chưa có hợp đồng nào được phát hành.'}
</td>
</tr>
)}
{items.map((c) => {
const open = openId === c.id
return (
<Fragment key={c.id}>
<tr
onClick={() => setOpenId(open ? null : c.id)}
className={cn(
'cursor-pointer border-b border-slate-100 transition',
open ? 'bg-teal-50/60' : 'hover:bg-teal-50/40',
)}
>
<td className="px-4 py-2.5 text-slate-400">
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</td>
<td className="px-4 py-2.5 font-mono text-xs text-teal-700">{c.maHopDong ?? '—'}</td>
<td className="max-w-xs truncate px-4 py-2.5 font-medium text-slate-800">
{c.tenHopDong ?? '(chưa đặt tên)'}
</td>
<td className="max-w-[12rem] truncate px-4 py-2.5 text-xs text-slate-600">{c.supplierName}</td>
<td className="max-w-[12rem] truncate px-4 py-2.5 text-xs text-slate-600">{c.projectName}</td>
<td className="px-4 py-2.5 text-right text-xs tabular-nums text-slate-700">
{formatVnd(c.giaTri)}
</td>
<td className="px-4 py-2.5">
{/* Pattern 14 — class đầy đủ dạng literal (Tailwind JIT không
thấy chuỗi ghép). Badge derive 1-mốc từ `hasSealedCopy`. */}
{c.hasSealedCopy ? (
<span className="inline-flex items-center gap-1 rounded-md border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">
<ShieldCheck className="h-3.5 w-3.5" />
Đã lưu bản cứng
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
<Upload className="h-3.5 w-3.5" />
Chưa bản cứng
</span>
)}
</td>
</tr>
{open && (
<tr className="border-b border-slate-100">
<td colSpan={7} className="bg-slate-50/70 px-4 py-4">
<HardCopyPanel contractId={c.id} />
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-slate-200 px-4 py-2.5 text-sm">
<div className="text-slate-500">
{total} hợp đng Trang {page} / {totalPages}
</div>
<div className="flex gap-1">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
Trước
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Sau
</Button>
</div>
</div>
)}
</div>
</div>
)
}
// ===== Panel mở dưới mỗi hàng — upload 1 purpose + danh sách file + chữ ký duyệt =====
function HardCopyPanel({ contractId }: { contractId: string }) {
const qc = useQueryClient()
const inputRef = useRef<HTMLInputElement>(null)
const [dragging, setDragging] = useState(false)
const [note, setNote] = useState('')
// Dùng CHÍNH queryKey `['contract', id]` của module HĐ ⇒ mọi nơi invalidate
// (ContractAttachmentsSection…) đều làm panel này tươi theo.
const detail = useQuery({
queryKey: ['contract', contractId],
queryFn: async () => (await api.get<ContractDetail>(`/contracts/${contractId}`)).data,
})
const upload = useMutation({
mutationFn: async (file: File) => {
// Endpoint CÓ SẴN: POST /api/contracts/{id}/attachments (multipart) —
// field name khớp controller `IFormFile file` + `[FromForm] purpose/note`
// (ContractsController.cs:87-92). Không đẻ endpoint mới.
const form = new FormData()
form.append('file', file)
form.append('purpose', String(SEALED_COPY))
if (note.trim()) form.append('note', note.trim())
return (
await api.post(`/contracts/${contractId}/attachments`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
).data
},
onSuccess: () => {
setNote('')
qc.invalidateQueries({ queryKey: ['contract', contractId] })
qc.invalidateQueries({ queryKey: ['hard-copies'] }) // badge cột "Bản cứng" lật
toast.success('Đã lưu bản cứng')
},
onError: (err) => toast.error(`Tải lên lỗi: ${getErrorMessage(err)}`),
})
function handleFiles(files: FileList | null) {
if (!files || files.length === 0) return
for (const f of Array.from(files)) upload.mutate(f)
}
function onDrop(e: DragEvent<HTMLDivElement>) {
e.preventDefault()
setDragging(false)
handleFiles(e.dataTransfer.files)
}
function onPick(e: ChangeEvent<HTMLInputElement>) {
handleFiles(e.target.files)
e.target.value = '' // cho phép chọn lại đúng file vừa chọn
}
async function download(att: ContractAttachment) {
const token = localStorage.getItem(TOKEN_KEY)
const res = await fetch(`${BASE_URL}/contracts/${contractId}/attachments/${att.id}/download`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) {
toast.error(`Tải xuống lỗi (HTTP ${res.status})`)
return
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = att.fileName
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
const sealed = (detail.data?.attachments ?? []).filter((a) => a.purpose === SEALED_COPY)
const opinions = detail.data?.levelOpinions ?? []
return (
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_22rem]">
<div className="space-y-3">
<div
onDragOver={(e) => {
e.preventDefault()
setDragging(true)
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
className={cn(
'cursor-pointer rounded-lg border-2 border-dashed px-4 py-6 text-center transition',
dragging ? 'border-teal-500 bg-teal-50' : 'border-slate-300 bg-white hover:bg-slate-50',
)}
>
<Upload className="mx-auto h-5 w-5 text-slate-400" />
<div className="mt-2 text-sm font-medium text-slate-600">
Kéo thả bản scan vào đây hoặc <span className="text-teal-700">chọn file</span>
</div>
<div className="mt-0.5 text-xs text-slate-400">
Bộ đã + đóng dấu · PDF / DOCX / XLSX / PNG / JPG · tối đa 20 MB
</div>
<input
ref={inputRef}
type="file"
multiple
onChange={onPick}
className="hidden"
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
/>
{upload.isPending && <div className="mt-2 text-xs text-teal-700">Đang tải lên</div>}
</div>
<div>
<label className="label-eyebrow mb-1 block">Ghi chú (tùy chọn)</label>
<Textarea
rows={2}
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Số bộ gốc · đã giao CCM/NTP · nơi lưu…"
/>
<p className="mt-1 text-[11px] text-slate-400">
Ghi chú đính theo file sắp tải lên bỏ trống cũng đưc.
</p>
</div>
<div className="rounded-lg border border-slate-200 bg-white">
<div className="flex items-center gap-2 border-b border-slate-100 px-3 py-2 text-xs font-semibold text-slate-600">
<FileText className="h-3.5 w-3.5" />
Bản scan đã lưu ({sealed.length})
</div>
{detail.isLoading && <div className="px-3 py-4 text-xs text-slate-500">Đang tải</div>}
{!detail.isLoading && sealed.length === 0 && (
<div className="px-3 py-4 text-xs text-slate-500">
Chưa bản scan nào. Tải lên bản scan bộ đã + đóng dấu đ hoàn tất.
</div>
)}
{sealed.length > 0 && (
<ul className="divide-y divide-slate-100">
{sealed.map((a) => (
<li key={a.id} className="flex items-center gap-3 px-3 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-slate-700">{a.fileName}</div>
<div className="text-[11px] text-slate-400">
{fmtSize(a.fileSize)} · {formatDateTime(a.createdAt)}
</div>
{a.note && <div className="mt-0.5 text-[11px] text-slate-500">Ghi chú: {a.note}</div>}
</div>
<button
onClick={() => download(a)}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-slate-500 transition hover:bg-slate-100 hover:text-slate-700"
title="Tải xuống"
>
<Download className="h-4 w-4" />
</button>
</li>
))}
</ul>
)}
</div>
</div>
{/* Chữ ký duyệt điện tử từ GĐ3 — CHỈ ĐỌC (khớp "Duyệt qua (nếu có)" @S161).
HĐ V1 legacy không pin workflow V2 ⇒ opinions rỗng ⇒ khối VẪN HIỆN với
câu placeholder (không ẩn — review W7 FLAG-3). */}
<div className="rounded-lg border border-slate-200 bg-white">
<div className="flex items-center gap-2 border-b border-slate-100 px-3 py-2 text-xs font-semibold text-slate-600">
<PenLine className="h-3.5 w-3.5" />
Ý kiến cấp duyệt ({opinions.length})
</div>
{opinions.length === 0 ? (
<div className="px-3 py-4 text-xs text-slate-500">
Hợp đng này không chữ duyệt điện tử.
</div>
) : (
<ul className="divide-y divide-slate-100">
{opinions.map((o) => (
<li key={o.id} className="px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-[13px] font-medium text-slate-700">
{o.approverFullName ?? '—'}
</span>
<span className="shrink-0 text-[11px] text-slate-400">{formatDateTime(o.signedAt)}</span>
</div>
<div className="text-[11px] text-slate-500">
{o.stepName}
{o.levelName ? ` · ${o.levelName}` : ` · Cấp ${o.levelOrder}`}
</div>
<div className="mt-0.5 text-[12px] text-slate-600">
{o.comment?.trim() ? o.comment : '(duyệt — không ý kiến)'}
</div>
{o.signedByUserId !== o.approverUserId && (
<div className="mt-1 inline-flex items-center rounded border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[11px] font-medium text-amber-800">
Admin duyệt thay ({o.signedByFullName ?? '—'})
</div>
)}
</li>
))}
</ul>
)}
</div>
</div>
)
}

View File

@ -90,6 +90,10 @@ export type ContractListItem = {
giaTri: number giaTri: number
slaDeadline: string | null slaDeadline: string | null
createdAt: string createdAt: string
// [W7 KHKK S161] Cờ derive 1-MỐC cho trang "Hợp đồng cứng" (GĐ4): có ≥1 attachment
// `AttachmentPurpose.SealedCopy = 3` ⇒ badge "Đã lưu bản cứng". BE append CUỐI
// `ContractListItemDto` (projection, không trả cả list attachments).
hasSealedCopy: boolean
} }
export type ContractApproval = { export type ContractApproval = {

View File

@ -14,6 +14,7 @@ import { CatalogsPage } from '@/pages/master/CatalogsPage'
import { ContractCreatePage } from '@/pages/contracts/ContractCreatePage' import { ContractCreatePage } from '@/pages/contracts/ContractCreatePage'
import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage' import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
import { MyContractsPage } from '@/pages/contracts/MyContractsPage' import { MyContractsPage } from '@/pages/contracts/MyContractsPage'
import { HardCopiesPage } from '@/pages/contracts/HardCopiesPage'
import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage' import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage'
import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage' import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage'
import { PurchaseEvaluationWorkspacePage } from '@/pages/pe/PurchaseEvaluationWorkspacePage' import { PurchaseEvaluationWorkspacePage } from '@/pages/pe/PurchaseEvaluationWorkspacePage'
@ -63,6 +64,9 @@ function App() {
<Route path="/contracts/new" element={<ContractCreatePage />} /> <Route path="/contracts/new" element={<ContractCreatePage />} />
<Route path="/contracts/:id" element={<ContractDetailPage />} /> <Route path="/contracts/:id" element={<ContractDetailPage />} />
<Route path="/my-contracts" element={<MyContractsPage />} /> <Route path="/my-contracts" element={<MyContractsPage />} />
{/* [W7 KHKK S161] GĐ4 "Hợp đồng cứng" — 7 leaf `Hdc_*` deep-link ?type=1..7
(Layout.tsx staticMap). Không query = tất cả loại. */}
<Route path="/hard-copies" element={<HardCopiesPage />} />
<Route path="/purchase-evaluations" element={<PurchaseEvaluationsListPage />} /> <Route path="/purchase-evaluations" element={<PurchaseEvaluationsListPage />} />
<Route path="/purchase-evaluations/workflow-matrix" element={<WorkflowMatrixViewPage />} /> <Route path="/purchase-evaluations/workflow-matrix" element={<WorkflowMatrixViewPage />} />
{/* [S159-đợt5] alias cho leaf "Luồng duyệt" của 7 nhóm HĐ (?type=3) */} {/* [S159-đợt5] alias cho leaf "Luồng duyệt" của 7 nhóm HĐ (?type=3) */}

View File

@ -60,13 +60,16 @@ function resolvePath(key: string): string | null {
Khkk_Create: '/khkk/create', Khkk_Create: '/khkk/create',
Khkk_Pending: '/khkk/list?filter=ChoDuyet', Khkk_Pending: '/khkk/list?filter=ChoDuyet',
Khkk_Approved: '/khkk/list?filter=DaDuyet', Khkk_Approved: '/khkk/list?filter=DaDuyet',
Hdc_ThauPhu: '/coming-soon?stage=4&t=01', // [W7 KHKK S161] GĐ4 hết skeleton — 7 leaf `Hdc_*` re-point trang thật
Hdc_GiaoKhoan: '/coming-soon?stage=4&t=02', // `/hard-copies?type=N` (N = ContractType int, mirror TYPE_CODE_TO_INT ở trên).
Hdc_NhaCungCap: '/coming-soon?stage=4&t=03', // gotcha #50: thiếu/để sai route ở đây = leaf DROP IM LẶNG khỏi sidebar.
Hdc_DichVu: '/coming-soon?stage=4&t=04', Hdc_ThauPhu: '/hard-copies?type=1',
Hdc_MuaBan: '/coming-soon?stage=4&t=05', Hdc_GiaoKhoan: '/hard-copies?type=2',
Hdc_NguyenTacNcc: '/coming-soon?stage=4&t=06', Hdc_NhaCungCap: '/hard-copies?type=3',
Hdc_NguyenTacDv: '/coming-soon?stage=4&t=07', Hdc_DichVu: '/hard-copies?type=4',
Hdc_MuaBan: '/hard-copies?type=5',
Hdc_NguyenTacNcc: '/hard-copies?type=6',
Hdc_NguyenTacDv: '/hard-copies?type=7',
Dashboard: '/dashboard', Dashboard: '/dashboard',
Contracts: '/my-contracts', Contracts: '/my-contracts',
PurchaseEvaluations: '/purchase-evaluations', PurchaseEvaluations: '/purchase-evaluations',

View File

@ -0,0 +1,447 @@
// [W7 KHKK — S161 2026-07-29] "Hợp đồng cứng" (GĐ4) — mô hình 1 MỐC.
// Anh chốt @S161 (verbatim): "... Upload file cứng lên -> Là xem như xong" ⇒ ký GĐ
// (b.19) · đóng dấu (b.20) · thủ tục lưu/phát hành (b.21) diễn ra NGOÀI hệ thống trên
// GIẤY. Hệ thống chỉ ghi nhận BẰNG CHỨNG CUỐI: 1 file scan bộ HĐ đã ký + đóng dấu
// (`AttachmentPurpose.SealedCopy = 3`, có sẵn — 0 migration, 0 enum-extend).
//
// Khuôn: `pages/khkk/KhkkListPage.tsx` (W2 — ui/PageHeader + card-accent table +
// phân trang) & `components/ContractAttachmentsSection.tsx` (multipart upload HĐ).
// File MIRROR SHA256 identical với fe-admin counterpart.
//
// URL vào từ menu (Layout.tsx staticMap — chỗ thứ 4 của Pattern 16-bis):
// Hdc_ThauPhu … Hdc_NguyenTacDv → /hard-copies?type=1 … ?type=7
// HopDongCung (root) có 7 con ⇒ render MenuGroup, path KHÔNG dùng — nhánh
// "không query = tất cả loại" chỉ tới được bằng gõ URL tay (review W7 FLAG-2).
// 🔴 Lọc loại HĐ đi qua THAM SỐ SERVER `?type=` (review F-C1/F-05) — KHÔNG lọc
// client-side trên trang `pageSize` (sẽ mất HĐ khi 1 loại vượt trần trang).
import { Fragment, useRef, useState, type ChangeEvent, type DragEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useSearchParams } from 'react-router-dom'
import {
Archive, ChevronDown, ChevronRight, Download, FileText, Inbox,
PenLine, Search, ShieldCheck, Upload,
} from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/ui/PageHeader'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Textarea } from '@/components/ui/Textarea'
import { api, TOKEN_KEY } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import {
ContractPhase,
type ContractAttachment,
type ContractDetail,
type ContractListItem,
} from '@/types/contracts'
import { ContractTypeLabel } from '@/types/forms'
import type { Paged } from '@/types/master'
const PAGE_SIZE = 20
// Mirror BE `AttachmentPurpose.SealedCopy` (Domain/Contracts/ContractAttachment.cs:5-11).
// 🔴 KHÔNG dùng `ScannedSigned = 2` — nghĩa của nó là "scan có chữ ký NCC ở phase
// Đang in ký" (gotcha #71), không phải bộ cứng đã đóng dấu.
const SEALED_COPY = 3
const BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? '') + '/api'
function formatVnd(n: number | null | undefined): string {
if (n === null || n === undefined) return '—'
return n.toLocaleString('vi-VN') + ' đ'
}
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString('vi-VN', { dateStyle: 'short', timeStyle: 'short' })
}
function fmtSize(n: number): string {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / (1024 * 1024)).toFixed(1)} MB`
}
export function HardCopiesPage() {
const [searchParams] = useSearchParams()
const rawType = searchParams.get('type')
const parsedType = rawType === null ? Number.NaN : Number(rawType)
const typeFilter = Number.isFinite(parsedType) && parsedType > 0 ? parsedType : null
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [openId, setOpenId] = useState<string | null>(null)
const list = useQuery({
queryKey: ['hard-copies', { type: typeFilter, search, page }],
queryFn: async () =>
(
await api.get<Paged<ContractListItem>>('/contracts', {
params: {
// GĐ4 chỉ theo dõi HĐ ĐÃ PHÁT HÀNH (phase 9 = terminal của GĐ3).
phase: ContractPhase.DaPhatHanh,
type: typeFilter ?? undefined, // `?? undefined` ⇒ axios bỏ hẳn key
search: search.trim() || undefined,
page,
pageSize: PAGE_SIZE,
},
})
).data,
})
const items = list.data?.items ?? []
const total = list.data?.total ?? 0
const totalPages = list.data?.totalPages ?? 1
const typeLabel = typeFilter !== null ? ContractTypeLabel[typeFilter] : null
return (
<div className="space-y-5">
<PageHeader
eyebrow="Giai đoạn 4"
title={typeLabel ? `Hợp đồng cứng — ${typeLabel}` : 'Hợp đồng cứng'}
subtitle="Theo dõi bộ hợp đồng bản cứng của các HĐ đã phát hành"
icon={<Archive className="h-5 w-5" />}
accent="teal"
/>
{/* Ghi chú 1-MỐC — nói rõ ký/đóng dấu/lưu là việc NGOÀI hệ thống, tránh user
tưởng còn thiếu trạm (rủi ro §③-C của spec W7). */}
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-[13px] leading-relaxed text-amber-900">
<span className="font-semibold"> giám đc · đóng dấu · thủ tục lưu bộ gốc đưc thực hiện NGOÀI hệ thống (trên giấy).</span>{' '}
Hệ thống chỉ ghi nhận bằng chứng cuối: tải lên bản scan bộ hợp đng đã + đóng dấu.
file scan xem như <span className="font-semibold">xong</span>.
</div>
<div
className="card-accent flex items-center gap-3 px-4 py-3"
style={{ ['--accent' as string]: 'var(--color-teal-500)' }}
>
<Search className="h-4 w-4 shrink-0 text-slate-400" />
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
placeholder="Tìm mã HĐ, tên HĐ hoặc nhà cung cấp..."
className="max-w-md border-0 bg-transparent px-0 shadow-none focus-visible:ring-0"
/>
</div>
<div
className="card-accent overflow-hidden"
style={{ ['--accent' as string]: 'var(--color-teal-500)' }}
>
<table className="w-full text-sm">
<thead className="border-b border-slate-200 bg-slate-50/70">
<tr>
<th className="label-eyebrow w-8 px-4 py-2.5 text-left" />
<th className="label-eyebrow px-4 py-2.5 text-left"> </th>
<th className="label-eyebrow px-4 py-2.5 text-left">Tên hợp đng</th>
<th className="label-eyebrow px-4 py-2.5 text-left">Nhà cung cấp</th>
<th className="label-eyebrow px-4 py-2.5 text-left">Dự án</th>
<th className="label-eyebrow px-4 py-2.5 text-right">Giá trị</th>
<th className="label-eyebrow px-4 py-2.5 text-left">Bản cứng</th>
</tr>
</thead>
<tbody>
{list.isLoading && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">
Đang tải...
</td>
</tr>
)}
{!list.isLoading && items.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-slate-500">
<span
className="icon-chip mx-auto mb-2 flex"
style={{ ['--chip-bg' as string]: '#f1f5f9', ['--chip-fg' as string]: '#94a3b8' }}
aria-hidden
>
<Inbox className="h-4 w-4" />
</span>
{typeLabel
? `Chưa có ${typeLabel} nào được phát hành.`
: 'Chưa có hợp đồng nào được phát hành.'}
</td>
</tr>
)}
{items.map((c) => {
const open = openId === c.id
return (
<Fragment key={c.id}>
<tr
onClick={() => setOpenId(open ? null : c.id)}
className={cn(
'cursor-pointer border-b border-slate-100 transition',
open ? 'bg-teal-50/60' : 'hover:bg-teal-50/40',
)}
>
<td className="px-4 py-2.5 text-slate-400">
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</td>
<td className="px-4 py-2.5 font-mono text-xs text-teal-700">{c.maHopDong ?? '—'}</td>
<td className="max-w-xs truncate px-4 py-2.5 font-medium text-slate-800">
{c.tenHopDong ?? '(chưa đặt tên)'}
</td>
<td className="max-w-[12rem] truncate px-4 py-2.5 text-xs text-slate-600">{c.supplierName}</td>
<td className="max-w-[12rem] truncate px-4 py-2.5 text-xs text-slate-600">{c.projectName}</td>
<td className="px-4 py-2.5 text-right text-xs tabular-nums text-slate-700">
{formatVnd(c.giaTri)}
</td>
<td className="px-4 py-2.5">
{/* Pattern 14 — class đầy đủ dạng literal (Tailwind JIT không
thấy chuỗi ghép). Badge derive 1-mốc từ `hasSealedCopy`. */}
{c.hasSealedCopy ? (
<span className="inline-flex items-center gap-1 rounded-md border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">
<ShieldCheck className="h-3.5 w-3.5" />
Đã lưu bản cứng
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
<Upload className="h-3.5 w-3.5" />
Chưa bản cứng
</span>
)}
</td>
</tr>
{open && (
<tr className="border-b border-slate-100">
<td colSpan={7} className="bg-slate-50/70 px-4 py-4">
<HardCopyPanel contractId={c.id} />
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-slate-200 px-4 py-2.5 text-sm">
<div className="text-slate-500">
{total} hợp đng Trang {page} / {totalPages}
</div>
<div className="flex gap-1">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
Trước
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Sau
</Button>
</div>
</div>
)}
</div>
</div>
)
}
// ===== Panel mở dưới mỗi hàng — upload 1 purpose + danh sách file + chữ ký duyệt =====
function HardCopyPanel({ contractId }: { contractId: string }) {
const qc = useQueryClient()
const inputRef = useRef<HTMLInputElement>(null)
const [dragging, setDragging] = useState(false)
const [note, setNote] = useState('')
// Dùng CHÍNH queryKey `['contract', id]` của module HĐ ⇒ mọi nơi invalidate
// (ContractAttachmentsSection…) đều làm panel này tươi theo.
const detail = useQuery({
queryKey: ['contract', contractId],
queryFn: async () => (await api.get<ContractDetail>(`/contracts/${contractId}`)).data,
})
const upload = useMutation({
mutationFn: async (file: File) => {
// Endpoint CÓ SẴN: POST /api/contracts/{id}/attachments (multipart) —
// field name khớp controller `IFormFile file` + `[FromForm] purpose/note`
// (ContractsController.cs:87-92). Không đẻ endpoint mới.
const form = new FormData()
form.append('file', file)
form.append('purpose', String(SEALED_COPY))
if (note.trim()) form.append('note', note.trim())
return (
await api.post(`/contracts/${contractId}/attachments`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
).data
},
onSuccess: () => {
setNote('')
qc.invalidateQueries({ queryKey: ['contract', contractId] })
qc.invalidateQueries({ queryKey: ['hard-copies'] }) // badge cột "Bản cứng" lật
toast.success('Đã lưu bản cứng')
},
onError: (err) => toast.error(`Tải lên lỗi: ${getErrorMessage(err)}`),
})
function handleFiles(files: FileList | null) {
if (!files || files.length === 0) return
for (const f of Array.from(files)) upload.mutate(f)
}
function onDrop(e: DragEvent<HTMLDivElement>) {
e.preventDefault()
setDragging(false)
handleFiles(e.dataTransfer.files)
}
function onPick(e: ChangeEvent<HTMLInputElement>) {
handleFiles(e.target.files)
e.target.value = '' // cho phép chọn lại đúng file vừa chọn
}
async function download(att: ContractAttachment) {
const token = localStorage.getItem(TOKEN_KEY)
const res = await fetch(`${BASE_URL}/contracts/${contractId}/attachments/${att.id}/download`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) {
toast.error(`Tải xuống lỗi (HTTP ${res.status})`)
return
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = att.fileName
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
const sealed = (detail.data?.attachments ?? []).filter((a) => a.purpose === SEALED_COPY)
const opinions = detail.data?.levelOpinions ?? []
return (
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_22rem]">
<div className="space-y-3">
<div
onDragOver={(e) => {
e.preventDefault()
setDragging(true)
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
className={cn(
'cursor-pointer rounded-lg border-2 border-dashed px-4 py-6 text-center transition',
dragging ? 'border-teal-500 bg-teal-50' : 'border-slate-300 bg-white hover:bg-slate-50',
)}
>
<Upload className="mx-auto h-5 w-5 text-slate-400" />
<div className="mt-2 text-sm font-medium text-slate-600">
Kéo thả bản scan vào đây hoặc <span className="text-teal-700">chọn file</span>
</div>
<div className="mt-0.5 text-xs text-slate-400">
Bộ đã + đóng dấu · PDF / DOCX / XLSX / PNG / JPG · tối đa 20 MB
</div>
<input
ref={inputRef}
type="file"
multiple
onChange={onPick}
className="hidden"
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
/>
{upload.isPending && <div className="mt-2 text-xs text-teal-700">Đang tải lên</div>}
</div>
<div>
<label className="label-eyebrow mb-1 block">Ghi chú (tùy chọn)</label>
<Textarea
rows={2}
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Số bộ gốc · đã giao CCM/NTP · nơi lưu…"
/>
<p className="mt-1 text-[11px] text-slate-400">
Ghi chú đính theo file sắp tải lên bỏ trống cũng đưc.
</p>
</div>
<div className="rounded-lg border border-slate-200 bg-white">
<div className="flex items-center gap-2 border-b border-slate-100 px-3 py-2 text-xs font-semibold text-slate-600">
<FileText className="h-3.5 w-3.5" />
Bản scan đã lưu ({sealed.length})
</div>
{detail.isLoading && <div className="px-3 py-4 text-xs text-slate-500">Đang tải</div>}
{!detail.isLoading && sealed.length === 0 && (
<div className="px-3 py-4 text-xs text-slate-500">
Chưa bản scan nào. Tải lên bản scan bộ đã + đóng dấu đ hoàn tất.
</div>
)}
{sealed.length > 0 && (
<ul className="divide-y divide-slate-100">
{sealed.map((a) => (
<li key={a.id} className="flex items-center gap-3 px-3 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-slate-700">{a.fileName}</div>
<div className="text-[11px] text-slate-400">
{fmtSize(a.fileSize)} · {formatDateTime(a.createdAt)}
</div>
{a.note && <div className="mt-0.5 text-[11px] text-slate-500">Ghi chú: {a.note}</div>}
</div>
<button
onClick={() => download(a)}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-slate-500 transition hover:bg-slate-100 hover:text-slate-700"
title="Tải xuống"
>
<Download className="h-4 w-4" />
</button>
</li>
))}
</ul>
)}
</div>
</div>
{/* Chữ ký duyệt điện tử từ GĐ3 — CHỈ ĐỌC (khớp "Duyệt qua (nếu có)" @S161).
HĐ V1 legacy không pin workflow V2 ⇒ opinions rỗng ⇒ khối VẪN HIỆN với
câu placeholder (không ẩn — review W7 FLAG-3). */}
<div className="rounded-lg border border-slate-200 bg-white">
<div className="flex items-center gap-2 border-b border-slate-100 px-3 py-2 text-xs font-semibold text-slate-600">
<PenLine className="h-3.5 w-3.5" />
Ý kiến cấp duyệt ({opinions.length})
</div>
{opinions.length === 0 ? (
<div className="px-3 py-4 text-xs text-slate-500">
Hợp đng này không chữ duyệt điện tử.
</div>
) : (
<ul className="divide-y divide-slate-100">
{opinions.map((o) => (
<li key={o.id} className="px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-[13px] font-medium text-slate-700">
{o.approverFullName ?? '—'}
</span>
<span className="shrink-0 text-[11px] text-slate-400">{formatDateTime(o.signedAt)}</span>
</div>
<div className="text-[11px] text-slate-500">
{o.stepName}
{o.levelName ? ` · ${o.levelName}` : ` · Cấp ${o.levelOrder}`}
</div>
<div className="mt-0.5 text-[12px] text-slate-600">
{o.comment?.trim() ? o.comment : '(duyệt — không ý kiến)'}
</div>
{o.signedByUserId !== o.approverUserId && (
<div className="mt-1 inline-flex items-center rounded border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[11px] font-medium text-amber-800">
Admin duyệt thay ({o.signedByFullName ?? '—'})
</div>
)}
</li>
))}
</ul>
)}
</div>
</div>
)
}

View File

@ -94,6 +94,10 @@ export type ContractListItem = {
giaTri: number giaTri: number
slaDeadline: string | null slaDeadline: string | null
createdAt: string createdAt: string
// [W7 KHKK S161] Cờ derive 1-MỐC cho trang "Hợp đồng cứng" (GĐ4): có ≥1 attachment
// `AttachmentPurpose.SealedCopy = 3` ⇒ badge "Đã lưu bản cứng". BE append CUỐI
// `ContractListItemDto` (projection, không trả cả list attachments).
hasSealedCopy: boolean
} }
export type ContractApproval = { export type ContractApproval = {

View File

@ -20,8 +20,11 @@ public class ContractsController(IMediator mediator) : ControllerBase
[FromQuery] ContractPhase? phase = null, [FromQuery] ContractPhase? phase = null,
[FromQuery] Guid? supplierId = null, [FromQuery] Guid? supplierId = null,
[FromQuery] Guid? projectId = null, [FromQuery] Guid? projectId = null,
// [W7 S161] Lọc theo loại HĐ cho 7 leaf bản-cứng `Hdc_*`: `?type={1..7}&phase=9`.
// Nullable ⇒ bỏ trống = giữ nguyên hành vi cũ (0 breaking cho caller hiện có).
[FromQuery] ContractType? type = null,
CancellationToken ct = default) CancellationToken ct = default)
=> Ok(await mediator.Send(new ListContractsQuery(phase, supplierId, projectId) { Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct)); => Ok(await mediator.Send(new ListContractsQuery(phase, supplierId, projectId, type) { Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct));
// [S159-đợt5] Màn "Đã xóa" HĐ (chỉ-xem) — mirror PE /deleted (S155). Read-only, // [S159-đợt5] Màn "Đã xóa" HĐ (chỉ-xem) — mirror PE /deleted (S155). Read-only,
// policy root Contracts.Read (chặt hơn [Authorize] trần của class — không phá gì). // policy root Contracts.Read (chặt hơn [Authorize] trần của class — không phá gì).

View File

@ -279,10 +279,14 @@ public class AddCommentCommandHandler(
// ========== LIST contracts (admin view) ========== // ========== LIST contracts (admin view) ==========
// [W7 S161] `Type` append-CUỐI (positional) — call-site controller dựng bằng vị-trí
// `new ListContractsQuery(phase, supplierId, projectId, type)`, chèn giữa là đổi nghĩa
// 3 tham số cũ. Khuôn lấy nguyên từ `ListDeletedContractsQuery(:408)` đã có sẵn.
public record ListContractsQuery( public record ListContractsQuery(
ContractPhase? Phase = null, ContractPhase? Phase = null,
Guid? SupplierId = null, Guid? SupplierId = null,
Guid? ProjectId = null) : PagedRequest, IRequest<PagedResult<ContractListItemDto>>; Guid? ProjectId = null,
ContractType? Type = null) : PagedRequest, IRequest<PagedResult<ContractListItemDto>>;
public class ListContractsQueryHandler(IApplicationDbContext db, ICurrentUser currentUser) public class ListContractsQueryHandler(IApplicationDbContext db, ICurrentUser currentUser)
: IRequestHandler<ListContractsQuery, PagedResult<ContractListItemDto>> : IRequestHandler<ListContractsQuery, PagedResult<ContractListItemDto>>
@ -317,6 +321,10 @@ public class ListContractsQueryHandler(IApplicationDbContext db, ICurrentUser cu
if (request.Phase is not null) q = q.Where(x => x.c.Phase == request.Phase); if (request.Phase is not null) q = q.Where(x => x.c.Phase == request.Phase);
if (request.SupplierId is not null) q = q.Where(x => x.c.SupplierId == request.SupplierId); if (request.SupplierId is not null) q = q.Where(x => x.c.SupplierId == request.SupplierId);
if (request.ProjectId is not null) q = q.Where(x => x.c.ProjectId == request.ProjectId); if (request.ProjectId is not null) q = q.Where(x => x.c.ProjectId == request.ProjectId);
// [W7 S161] Lọc theo loại HĐ — server-side, ĐẶT TRONG khối filter (SAU khối IDOR
// `:298-315`) ⇒ thu hẹp tập đã-được-phép, KHÔNG mở thêm gì. FE 7 leaf `Hdc_*` gọi
// `?type={1..7}&phase=9`. Mirror `ListDeletedContractsQueryHandler:434`.
if (request.Type is not null) q = q.Where(x => x.c.Type == request.Type);
if (!string.IsNullOrWhiteSpace(request.Search)) if (!string.IsNullOrWhiteSpace(request.Search))
{ {
@ -336,7 +344,15 @@ public class ListContractsQueryHandler(IApplicationDbContext db, ICurrentUser cu
x.c.Id, x.c.MaHopDong, x.c.TenHopDong, x.c.Type, x.c.Phase, x.c.Id, x.c.MaHopDong, x.c.TenHopDong, x.c.Type, x.c.Phase,
x.c.SupplierId, x.s.Name, x.c.SupplierId, x.s.Name,
x.c.ProjectId, x.p.Name, x.c.ProjectId, x.p.Name,
x.c.GiaTri, x.c.SlaDeadline, x.c.CreatedAt)) x.c.GiaTri, x.c.SlaDeadline, x.c.CreatedAt,
// [W7 S161] Cờ "đã có bản cứng" — dịch thành EXISTS(...) trên
// `ContractAttachments`, 0 round-trip thêm.
// 🔴 CỐ Ý KHÔNG có vế `!a.IsDeleted`: `ContractAttachment : BaseEntity`
// (ContractAttachment.cs:13) — BaseEntity KHÔNG có `IsDeleted`, và
// `ContractAttachmentConfiguration` (ContractConfiguration.cs:78-93) KHÔNG
// có `HasQueryFilter`. Xoá đính kèm là HARD delete
// (`ContractAttachmentFeatures.cs:143` `.Remove(att)`) ⇒ row còn = file còn.
x.c.Attachments.Any(a => a.Purpose == AttachmentPurpose.SealedCopy)))
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResult<ContractListItemDto>(items, total, request.Page, request.PageSize); return new PagedResult<ContractListItemDto>(items, total, request.Page, request.PageSize);

View File

@ -14,7 +14,15 @@ public record ContractListItemDto(
string ProjectName, string ProjectName,
decimal GiaTri, decimal GiaTri,
DateTime? SlaDeadline, DateTime? SlaDeadline,
DateTime CreatedAt); DateTime CreatedAt,
// [W7 S161] Cờ "đã có bản cứng" — append-CUỐI + CÓ DEFAULT có chủ đích.
// 🔴 Default BẮT BUỘC: DTO này còn 2 call-site dựng bằng 12 arg
// (`ContractFeatures.cs` ListDeleted + Inbox) — bỏ default là vỡ compile 2 chỗ đó.
// Hệ quả KHAI THẲNG: cờ CHỈ được derive ở `ListContractsQueryHandler` (màn danh sách
// HĐ, nơi FE gắn badge); ở màn "Đã xóa" + "Hộp thư" nó luôn `false` vì KHÔNG derive
// — cố ý giữ scope W7 tối thiểu, KHÔNG phải bỏ sót.
// Nguồn sự-thật của cờ = có ≥1 `ContractAttachment.Purpose == SealedCopy(3)`.
bool HasSealedCopy = false);
public record ContractDetailDto( public record ContractDetailDto(
Guid Id, Guid Id,

View File

@ -0,0 +1,397 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Models;
using SolutionErp.Application.Contracts;
using SolutionErp.Application.Contracts.Dtos;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// [W7 ĐỢT-2 — S161 2026-07-29] Bản cứng `Hdc_*`: lọc theo LOẠI + PHASE, và cờ badge
// "Đã lưu bản cứng".
// Spec: `runs/2026-07-29-S160-khkk-dryrun-plan/spec-wave-w7-ban-cung-hdc-29-07-2026.md` §③-B.
// Timing = **test-after** (đợt-1 BE+FE đã land, suite 587 xanh) ⇒ 3 test này phải XANH ngay.
// Đỏ ở đây = hồi quy thật, KHÔNG được sửa test cho vừa.
//
// 3 tên PIN theo spec (`:27`) — thiếu/đổi 1 chữ là TRƯỢT acceptance:
// T1 `ListHardCopies_ReturnsOnlyContracts_InDaPhatHanhPhase`
// T2 `ListHardCopies_FiltersByContractType` (review F-C1 — chứng `type` LỌC THẬT)
// T3 `HardCopyBadge_DerivesFromSealedCopyAttachment`
//
// ── 4 quyết định hạ tầng (điều kiện để phép đo CÓ NGHĨA) ──────────────────────────
//
// (1) 🔴 Đo bằng CẢ HAI loại actor. Filter `type` nằm NGOÀI khối IDOR
// (`ContractFeatures.cs:327` vs khối `:302-319`). Ai đó dời nó VÀO TRONG nhánh
// `if (!admin)` thì bản chỉ-đo-Director vẫn xanh, bản chỉ-đo-admin vẫn xanh ở chiều
// ngược lại. Nên T1/T2 chạy phép đo trên admin (đo THUẦN cái filter, không bị IDOR
// che) rồi lặp lại trên Director non-admin (vai THẬT của trang bản cứng).
//
// (2) 🔴 Assert `Total` chứ không chỉ `Items`. `CountAsync` đứng SAU mọi `Where`
// (`ContractFeatures.cs:340`) ⇒ `Total` là dấu vân tay của lọc SERVER-SIDE. Một bản
// "lấy pageSize:100 rồi lọc phía client" (đúng thứ spec §②-2b cấm) cho `Items` y hệt
// nhưng `Total` = cả thế giới. Đây là assert phân biệt được hai cài đặt đó.
//
// (3) `TestApplicationDbContext` KHÔNG wire `AuditingInterceptor` ⇒ `Remove()` = xoá CỨNG.
// Khối xoá-mềm ở T3 set thẳng `IsDeleted = true` (bài S155: đo "xoá mềm" trên xoá cứng
// = đo nhầm cơ chế — rào thật là global filter che dòng, không phải dòng biến mất).
//
// (4) Mỗi test dựng `IdentityFixture` RIÊNG (SQLite :memory: riêng) ⇒ thế giới ĐÓNG, nên
// `BeEquivalentTo(<tập đủ>)` mới là phép đo hai chiều (thừa cũng đỏ, thiếu cũng đỏ).
//
// ⚠️ `ContractAttachment : BaseEntity` (`ContractAttachment.cs:13`) — **KHÔNG có `IsDeleted`**,
// config không `HasQueryFilter` (`ContractConfiguration.cs:78-93`), xoá đính kèm là HARD
// delete (`ContractAttachmentFeatures.cs:143`). Nên không có (và không thể có) vế
// `!a.IsDeleted` ở đâu trong file này.
public class ContractHardCopyTests
{
private static readonly DateTime Clock2026 = new(2026, 7, 29, 3, 0, 0, DateTimeKind.Utc);
// ===================================================================
// Seed helpers
// ===================================================================
private static TestCurrentUser As(User u, params string[] roles)
=> new(u.Id, u.FullName, u.Email, roles);
private static async Task<(Supplier sup, Project proj)> SeedMasterAsync(TestApplicationDbContext db)
{
var sup = new Supplier
{
Id = Guid.NewGuid(),
Code = "BTBM",
Name = "NCC Bê tông Bình Minh",
Type = SupplierType.NhaThauPhu,
};
var proj = new Project { Id = Guid.NewGuid(), Code = "FLOCK01", Name = "Dự án FLOCK 01" };
db.Suppliers.Add(sup);
db.Projects.Add(proj);
await db.SaveChangesAsync(CancellationToken.None);
return (sup, proj);
}
/// Khuôn `ContractV2PipelineTests.AddContractAsync` nhưng **nhận `ContractType`** — W7 đo
/// chính trục LOẠI HĐ nên loại không thể hardcode như W6.
private static async Task<Contract> AddContractAsync(
TestApplicationDbContext db,
Supplier sup,
Project proj,
Guid drafterId,
ContractType type,
ContractPhase phase,
string tenHopDong)
{
var c = new Contract
{
Id = Guid.NewGuid(),
Type = type,
Phase = phase,
SupplierId = sup.Id,
ProjectId = proj.Id,
DrafterUserId = drafterId,
TenHopDong = tenHopDong,
GiaTri = 100_000_000m,
};
db.Contracts.Add(c);
await db.SaveChangesAsync(CancellationToken.None);
return c;
}
/// Seed thẳng `ContractAttachments` — entity là `BaseEntity`, không interceptor, không
/// soft-delete. 3 cột `FileName/StoragePath/ContentType` là `IsRequired()`
/// (`ContractConfiguration.cs:84-86`) nên phải có giá trị thật.
private static async Task<ContractAttachment> AddAttachmentAsync(
TestApplicationDbContext db, Contract c, AttachmentPurpose purpose, string? note = null)
{
var att = new ContractAttachment
{
Id = Guid.NewGuid(),
ContractId = c.Id,
FileName = $"{purpose}-{Guid.NewGuid():N}.pdf",
StoragePath = $"uploads/contracts/{c.Id}/{purpose}.pdf",
ContentType = "application/pdf",
FileSize = 1024,
Purpose = purpose,
Note = note,
};
db.ContractAttachments.Add(att);
await db.SaveChangesAsync(CancellationToken.None);
return att;
}
// ===================================================================
// Act helpers — gọi HANDLER THẬT, không mock
// ===================================================================
/// Đúng đường FE 7 leaf `Hdc_*` gọi: `GET /api/contracts?type={1..7}&phase=9`
/// (`ContractsController.cs:25,27` dựng query bằng VỊ TRÍ `(phase, supplierId, projectId, type)`).
private static Task<PagedResult<ContractListItemDto>> ListAsync(
TestApplicationDbContext db, TestCurrentUser actor,
ContractPhase? phase = null, ContractType? type = null)
=> new ListContractsQueryHandler(db, actor)
.Handle(new ListContractsQuery(Phase: phase, Type: type) { PageSize = 200 }, CancellationToken.None);
private static Task<PagedResult<ContractListItemDto>> ListDeletedAsync(
TestApplicationDbContext db, TestCurrentUser actor)
=> new ListDeletedContractsQueryHandler(db, actor)
.Handle(new ListDeletedContractsQuery() { PageSize = 200 }, CancellationToken.None);
private static Task<List<ContractListItemDto>> InboxAsync(
TestApplicationDbContext db, TestCurrentUser actor)
=> new GetMyInboxQueryHandler(db, actor).Handle(new GetMyInboxQuery(), CancellationToken.None);
// ===================================================================
// (T1) Trang bản cứng chỉ liệt HĐ **đã phát hành**. Spec §③-B `:27` tên #1.
//
// Thế giới cố ý rải ĐỦ 8 phase trên CÙNG MỘT LOẠI HĐ, trong đó 3 hàng xóm nguy hiểm:
// • `DangDongDau = 8` — tên của nó CHÍNH LÀ khâu bản cứng b.20 ("đóng dấu"). Một cài
// đặt kiểu "phase bản-cứng" / `Phase >= 8` nuốt gọn nó. Không seed = lỗi vô hình.
// • `TraLai = 98` + `TuChoi = 99` — số LỚN HƠN 9, nên mọi so sánh `>=` sai đều lộ.
// • `ChoDuyet = 10` — cũng lớn hơn 9, và là phase đông dòng nhất trong prod.
//
// 2 HĐ `DaPhatHanh` (không phải 1) ⇒ loại ca "trả đúng một dòng do may".
//
// Actor chính là ADMIN có chủ đích: admin bỏ qua khối IDOR (`ContractFeatures.cs:302`)
// nên thứ DUY NHẤT thu hẹp tập là param `phase` — đo đúng cái filter, không phải đo
// gián tiếp qua quyền. Vế vai THẬT (Director) đo riêng ở khối 3.
// ===================================================================
[Fact]
public async Task ListHardCopies_ReturnsOnlyContracts_InDaPhatHanhPhase()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (sup, proj) = await SeedMasterAsync(db);
var drafter = await fix.CreateUserAsync("w7t1-drafter@test.local", "BCH soạn HĐ", null, []);
var adminUser = await fix.CreateUserAsync("w7t1-admin@test.local", "Quản trị hệ thống", null, [AppRoles.Admin]);
var directorUser = await fix.CreateUserAsync("w7t1-dir@test.local", "Giám đốc ký", null, [AppRoles.Director]);
const ContractType type = ContractType.HopDongThauPhu;
var published1 = await AddContractAsync(db, sup, proj, drafter.Id, type, ContractPhase.DaPhatHanh, "HĐ đã phát hành 1");
var published2 = await AddContractAsync(db, sup, proj, drafter.Id, type, ContractPhase.DaPhatHanh, "HĐ đã phát hành 2");
var dongDau = await AddContractAsync(db, sup, proj, drafter.Id, type, ContractPhase.DangDongDau, "HĐ đang đóng dấu (legacy 8)");
var trinhKy = await AddContractAsync(db, sup, proj, drafter.Id, type, ContractPhase.DangTrinhKy, "HĐ đang trình ký (legacy 7)");
var choDuyet = await AddContractAsync(db, sup, proj, drafter.Id, type, ContractPhase.ChoDuyet, "HĐ chờ duyệt");
var draft = await AddContractAsync(db, sup, proj, drafter.Id, type, ContractPhase.DangSoanThao, "HĐ còn Nháp");
var traLai = await AddContractAsync(db, sup, proj, drafter.Id, type, ContractPhase.TraLai, "HĐ bị trả lại");
var tuChoi = await AddContractAsync(db, sup, proj, drafter.Id, type, ContractPhase.TuChoi, "HĐ bị từ chối");
var admin = As(adminUser, AppRoles.Admin);
// ---- CHỨNG NHÂN DƯƠNG trước: không lọc ⇒ thấy ĐỦ 8. Thiếu vế này thì mọi assert
// "chỉ có phase 9" ở dưới vẫn xanh khi handler hỏng-luôn và trả rỗng/thiếu.
var all = await ListAsync(db, admin);
all.Items.Select(i => i.Id).Should().BeEquivalentTo(
new[] { published1.Id, published2.Id, dongDau.Id, trinhKy.Id, choDuyet.Id, draft.Id, traLai.Id, tuChoi.Id },
"admin không lọc phải thấy toàn bộ thế giới 8 HĐ — đây là mốc để biết việc thu hẹp "
+ "ở dưới đến TỪ PARAM chứ không phải từ một handler đang hỏng");
all.Total.Should().Be(8);
// ---- Vế chính: `?phase=9` ⇒ ĐÚNG tập 2 HĐ đã phát hành, không hơn không kém.
var hardCopies = await ListAsync(db, admin, phase: ContractPhase.DaPhatHanh);
hardCopies.Items.Select(i => i.Id).Should().BeEquivalentTo(new[] { published1.Id, published2.Id },
"trang bản cứng (GĐ4) chỉ theo dõi HĐ ĐÃ PHÁT HÀNH; lọt `DangDongDau` (phase 8 — "
+ "tên đúng là 'đóng dấu', hàng xóm dễ nhầm nhất) hoặc `ChoDuyet/TraLai/TuChoi` "
+ "(đều là số LỚN HƠN 9) nghĩa là điều kiện phase đang so sánh kiểu `>=` chứ không phải `==`");
hardCopies.Total.Should().Be(2, "Total tính SAU filter — bằng 8 nghĩa là đang lọc phía client sau khi lấy cả trang");
// Bất biến ở mức TỪNG DÒNG (acceptance §③-B `:30` 'mọi item phase==9').
hardCopies.Items.Should().OnlyContain(i => i.Phase == ContractPhase.DaPhatHanh);
hardCopies.Items.Should().HaveCount(2);
// ---- Khối 3: VAI THẬT (acceptance §③-B `:31`) — Director non-admin, KHÔNG rỗng.
var director = As(directorUser, AppRoles.Director);
director.Roles.Should().NotContain(AppRoles.Admin, "vai thật của trang bản cứng KHÔNG phải admin");
// Cặp 1-biến trên CHÍNH actor đó: bỏ `phase` ⇒ thấy thêm HĐ `DangTrinhKy` (vế role
// legacy `GetEligiblePhases(Director)`); thêm `phase=9` ⇒ đúng HĐ đó rụng đi. Nếu
// hai lần gọi ra cùng một tập thì param `phase` chẳng làm gì cả với vai này.
var dirNoFilter = await ListAsync(db, director);
dirNoFilter.Items.Select(i => i.Id).Should().Contain(trinhKy.Id,
"Director thấy HĐ `DangTrinhKy` theo vế role legacy — mốc đối chứng cho lần lọc dưới");
var dirHardCopies = await ListAsync(db, director, phase: ContractPhase.DaPhatHanh, type: type);
dirHardCopies.Items.Should().NotBeEmpty(
"vai bản-cứng THẬT phải thấy được danh sách (acceptance đòi 'không rỗng/không 403')");
dirHardCopies.Items.Select(i => i.Id).Should().BeEquivalentTo(new[] { published1.Id, published2.Id });
dirHardCopies.Items.Select(i => i.Id).Should().NotContain(trinhKy.Id,
"cùng actor, chỉ thêm ĐÚNG MỘT biến `phase=9`, HĐ đang trình ký phải rụng — chứng "
+ "việc thu hẹp là của param, không phải của quyền");
dirHardCopies.Total.Should().Be(2);
}
// ===================================================================
// (T2) Param `type` LỌC THẬT — 2 loại ra 2 danh sách KHÁC NHAU. Spec §③-B `:27` tên #2,
// acceptance `:30` (review F-C1).
//
// Lỗi mà test này tồn tại để bắt: 7 leaf `Hdc_*` hiện y hệt nhau vì BE bỏ qua `type`
// (trước W7 `GET /api/contracts` KHÔNG CÓ param này) — nhìn trên UI thì mỗi trang vẫn
// "có dữ liệu", trông như chạy đúng.
//
// Thế giới trộn 3 loại × 2 phase để 2 filter phải AND với nhau chứ không đè nhau.
// ===================================================================
[Fact]
public async Task ListHardCopies_FiltersByContractType()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (sup, proj) = await SeedMasterAsync(db);
var drafter = await fix.CreateUserAsync("w7t2-drafter@test.local", "BCH soạn HĐ", null, []);
var adminUser = await fix.CreateUserAsync("w7t2-admin@test.local", "Quản trị hệ thống", null, [AppRoles.Admin]);
var hrUser = await fix.CreateUserAsync("w7t2-hra@test.local", "HC-NS đóng dấu", null, [AppRoles.HrAdmin]);
var t1a = await AddContractAsync(db, sup, proj, drafter.Id, ContractType.HopDongThauPhu, ContractPhase.DaPhatHanh, "Thầu phụ A");
var t1b = await AddContractAsync(db, sup, proj, drafter.Id, ContractType.HopDongThauPhu, ContractPhase.DaPhatHanh, "Thầu phụ B");
var t2 = await AddContractAsync(db, sup, proj, drafter.Id, ContractType.HopDongGiaoKhoan, ContractPhase.DaPhatHanh, "Giao khoán A");
var t5 = await AddContractAsync(db, sup, proj, drafter.Id, ContractType.HopDongMuaBan, ContractPhase.DaPhatHanh, "Mua bán A");
var t1Pending = await AddContractAsync(db, sup, proj, drafter.Id, ContractType.HopDongThauPhu, ContractPhase.ChoDuyet, "Thầu phụ chờ duyệt");
var admin = As(adminUser, AppRoles.Admin);
// ---- CHỨNG NHÂN DƯƠNG: `?phase=9` KHÔNG kèm `type` ⇒ đủ 4 HĐ của 3 loại.
var allPublished = await ListAsync(db, admin, phase: ContractPhase.DaPhatHanh);
allPublished.Items.Select(i => i.Id).Should().BeEquivalentTo(new[] { t1a.Id, t1b.Id, t2.Id, t5.Id },
"bỏ `type` thì trang bản cứng thấy cả 3 loại — mốc để chứng việc tách nhỏ ở dưới là do PARAM");
allPublished.Total.Should().Be(4);
// ---- Vế chính: 2 loại ⇒ 2 danh sách KHÁC NHAU (literal acceptance `:30`).
var listType1 = await ListAsync(db, admin, phase: ContractPhase.DaPhatHanh, type: ContractType.HopDongThauPhu);
var listType2 = await ListAsync(db, admin, phase: ContractPhase.DaPhatHanh, type: ContractType.HopDongGiaoKhoan);
listType1.Items.Select(i => i.Id).Should().BeEquivalentTo(new[] { t1a.Id, t1b.Id },
"leaf 'HĐ Thầu phụ - Bảng cứng' chỉ được thấy HĐ Thầu phụ; thấy đủ 4 nghĩa là "
+ "`type` bị bỏ qua và 7 leaf `Hdc_*` sẽ hiện TRÙNG NHAU (đúng lỗi F-C1)");
listType2.Items.Select(i => i.Id).Should().BeEquivalentTo(new[] { t2.Id });
listType1.Items.Select(i => i.Id).Should().NotIntersectWith(listType2.Items.Select(i => i.Id),
"acceptance đòi ĐÚNG chữ '2 loại KHÁC NHAU ra 2 danh sách KHÁC NHAU'");
// Bất biến từng dòng — chặn ca "lọt 1 dòng loại khác ở trang sau".
listType1.Items.Should().OnlyContain(i => i.Type == ContractType.HopDongThauPhu);
listType2.Items.Should().OnlyContain(i => i.Type == ContractType.HopDongGiaoKhoan);
// ---- 🔴 `Total` = dấu vân tay của lọc SERVER-SIDE (xem ghi chú hạ tầng (2)).
listType1.Total.Should().Be(2,
"`Total` đếm SAU `Where` (`ContractFeatures.cs:340`) ⇒ phải là 2 chứ không phải 4/5. "
+ "Bằng 4 nghĩa là dòng đã lấy về rồi mới lọc — đúng cách làm mà spec §②-2b cấm "
+ "('KHÔNG lọc client-side pageSize:100', trần 100 dòng/loại)");
listType2.Total.Should().Be(1);
// ---- `type` và `phase` AND độc lập: `type=1` không kèm phase ⇒ có cả HĐ ChoDuyet.
var type1AnyPhase = await ListAsync(db, admin, type: ContractType.HopDongThauPhu);
type1AnyPhase.Items.Select(i => i.Id).Should().BeEquivalentTo(new[] { t1a.Id, t1b.Id, t1Pending.Id },
"`type` chỉ được lọc LOẠI; nó mà tự kéo theo phase thì hai filter đang dính nhau");
// ---- Lặp phép đo với VAI THẬT non-admin (xem ghi chú hạ tầng (1)): nếu ai đó dời
// dòng filter `type` vào trong khối `if (!admin)` thì khối admin ở trên đỏ; dời
// ra chỗ chỉ chạy cho admin thì khối này đỏ. Hai khối khoá cả hai chiều.
var hra = As(hrUser, AppRoles.HrAdmin);
hra.Roles.Should().NotContain(AppRoles.Admin);
var hraType2 = await ListAsync(db, hra, phase: ContractPhase.DaPhatHanh, type: ContractType.HopDongGiaoKhoan);
hraType2.Items.Select(i => i.Id).Should().BeEquivalentTo(new[] { t2.Id },
"vai bản-cứng HrAdmin (b.20 đóng dấu) cũng phải nhận đúng 1 loại — filter `type` "
+ "sống ở CẢ nhánh non-admin");
hraType2.Total.Should().Be(1);
// ---- Ca ÂM acceptance `:32`: loại chưa có HĐ phát hành ⇒ rỗng, KHÔNG văng.
var emptyLeaf = await ListAsync(db, admin, phase: ContractPhase.DaPhatHanh,
type: ContractType.HopDongNguyenTacDichVu);
emptyLeaf.Items.Should().BeEmpty("leaf loại chưa có HĐ nào phát hành ⇒ empty-state, không phải lỗi");
emptyLeaf.Total.Should().Be(0, "và Total phải là 0 để FE phân trang không vẽ trang ma");
}
// ===================================================================
// (T3) Badge "Đã lưu bản cứng" derive từ attachment `SealedCopy`. Spec §③-B `:27` tên #3.
//
// 🔴 Ca âm đắt nhất là `ScannedSigned = 2`: spec `:18` + gotcha #71 CẤM derive từ nó
// (=2 nghĩa "scan HĐ có chữ ký NCC ở phase `DangInKy`" — chữ ký NCC, chưa đóng dấu,
// chưa phải bộ cứng đã lưu). Cài `Purpose >= ScannedSigned` hay `Attachments.Any()`
// (hai cách viết tự nhiên nhất) cho T3 xanh ở mọi ca KHÁC và chỉ vỡ ở đây.
//
// Cả 3 khối đều xoay quanh nguyên tắc: mọi `false` phải có chứng nhân là seed CÓ THẬT.
// ===================================================================
[Fact]
public async Task HardCopyBadge_DerivesFromSealedCopyAttachment()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (sup, proj) = await SeedMasterAsync(db);
var drafter = await fix.CreateUserAsync("w7t3-drafter@test.local", "BCH soạn HĐ", null, []);
var adminUser = await fix.CreateUserAsync("w7t3-admin@test.local", "Quản trị hệ thống", null, [AppRoles.Admin]);
var admin = As(adminUser, AppRoles.Admin);
const ContractType type = ContractType.HopDongThauPhu;
var cSealed = await AddContractAsync(db, sup, proj, drafter.Id, type, ContractPhase.DaPhatHanh, "HĐ đã lưu bản cứng");
var cOther = await AddContractAsync(db, sup, proj, drafter.Id, type, ContractPhase.DaPhatHanh, "HĐ chỉ có file khác");
var cNone = await AddContractAsync(db, sup, proj, drafter.Id, type, ContractPhase.DaPhatHanh, "HĐ chưa có file nào");
await AddAttachmentAsync(db, cSealed, AttachmentPurpose.SealedCopy, note: "2 bộ gốc, lưu tủ P.HC");
await AddAttachmentAsync(db, cOther, AttachmentPurpose.DraftExport);
await AddAttachmentAsync(db, cOther, AttachmentPurpose.ScannedSigned); // 🔴 gotcha #71
await AddAttachmentAsync(db, cOther, AttachmentPurpose.Other);
// ---- Sanity chống vacuous: `false` của `cOther` phải là "lọc đúng", KHÔNG phải
// "seed hỏng nên chẳng có gì". Đếm từ DB, không tin lời gọi seed.
(await db.ContractAttachments.CountAsync(a => a.ContractId == cOther.Id))
.Should().Be(3, "ca âm chỉ có nghĩa khi HĐ đó THỰC SỰ có 3 đính kèm (chỉ thiếu đúng purpose=3)");
(await db.ContractAttachments.CountAsync(a => a.ContractId == cNone.Id)).Should().Be(0);
// ---- Vế chính.
var badges = (await ListAsync(db, admin, phase: ContractPhase.DaPhatHanh))
.Items.ToDictionary(i => i.Id, i => i.HasSealedCopy);
badges.Should().HaveCount(3, "cả 3 HĐ phải nằm trong danh sách thì mới đọc được cờ của chúng");
badges[cSealed.Id].Should().BeTrue(
"có đính kèm `SealedCopy` (=3) ⇒ badge 'Đã lưu bản cứng' phải bật — đây là mốc DUY NHẤT "
+ "của mô hình 1-mốc (@S161): upload scan bộ cứng xong là xem như xong");
badges[cOther.Id].Should().BeFalse(
"HĐ này có `DraftExport(1)` + `ScannedSigned(2)` + `Other(99)` nhưng KHÔNG có `SealedCopy(3)`. "
+ "Bật cờ ở đây = đang derive từ `ScannedSigned` (scan chữ ký NCC ở `DangInKy` — gotcha #71, "
+ "spec `:18` cấm) hoặc từ 'có đính kèm bất kỳ'; cả hai đều báo 'đã lưu bản cứng' cho HĐ "
+ "chưa hề có bộ cứng nào");
badges[cNone.Id].Should().BeFalse("không có đính kèm nào thì không thể có bản cứng");
// ---- LẬT 1 BIẾN: thêm đúng 1 đính kèm purpose=3 cho `cOther` ⇒ cờ phải bật.
// Đây là chứng nhân dương cho toàn bộ các `BeFalse` ở trên: nếu cờ hỏng-luôn
// (luôn false) thì mọi assert âm vẫn xanh, và chỉ ca này bắt được.
await AddAttachmentAsync(db, cOther, AttachmentPurpose.SealedCopy);
var afterUpload = (await ListAsync(db, admin, phase: ContractPhase.DaPhatHanh))
.Items.Single(i => i.Id == cOther.Id);
afterUpload.HasSealedCopy.Should().BeTrue(
"cùng HĐ, cùng actor, đổi ĐÚNG MỘT biến (upload thêm 1 file purpose=3) ⇒ badge lật true. "
+ "Nếu vẫn false thì mấy assert âm ở trên xanh vì cờ chết cứng, không phải vì lọc đúng");
// ---- Khối B — GHIM CAVEAT 1 (lane BE `sub-implementer-backend-0.md` §8 điểm 2):
// cờ KHÔNG xét phase. HĐ `ChoDuyet` có purpose=3 vẫn `true`. Đây là hành vi CỐ Ý
// hiện tại (FE tự lọc `phase=9`), không phải bug — ghim để lần sau ai đổi thì
// biết mình đang ĐỔI HỢP ĐỒNG chứ không phải sửa vặt.
var cPending = await AddContractAsync(db, sup, proj, drafter.Id, type, ContractPhase.ChoDuyet, "HĐ chờ duyệt mà đã có scan");
await AddAttachmentAsync(db, cPending, AttachmentPurpose.SealedCopy);
(await ListAsync(db, admin)).Items.Single(i => i.Id == cPending.Id).HasSealedCopy.Should().BeTrue(
"cờ derive THUẦN theo purpose, không kèm điều kiện phase (`ContractFeatures.cs:355`) ⇒ "
+ "trách nhiệm lọc `phase=9` nằm ở phía gọi (7 leaf `Hdc_*`)");
// ---- Khối C — GHIM CAVEAT 2 (§8 điểm 1 + `ContractDtos.cs:18-25`): cờ CHỈ được
// derive ở màn danh sách. Màn "Đã xóa" và "Hộp thư" dựng DTO bằng 12 arg nên
// nhận default `false` — dù HĐ ĐANG CÓ đính kèm purpose=3. Test theo CODE.
cSealed.IsDeleted = true; // KHÔNG `Remove()`: fixture không wire interceptor ⇒ Remove = xoá CỨNG (S155)
cSealed.DeletedAt = Clock2026;
await db.SaveChangesAsync(CancellationToken.None);
(await db.ContractAttachments.CountAsync(a => a.ContractId == cSealed.Id)).Should().Be(1,
"đính kèm vẫn còn nguyên (xoá mềm HĐ không đụng bảng đính kèm) — nếu nó biến mất thì "
+ "assert dưới xanh vì lý do khác hẳn");
(await ListDeletedAsync(db, admin)).Items.Single(i => i.Id == cSealed.Id).HasSealedCopy
.Should().BeFalse(
"màn 'Đã xóa' CỐ Ý không derive cờ (DTO default `= false`, `ContractDtos.cs:25`) dù HĐ "
+ "vẫn còn đính kèm purpose=3. Đây là ranh giới scope W7 đang được ghim, không phải "
+ "phép đo nói rằng HĐ đó không có bản cứng");
(await InboxAsync(db, admin)).Single(i => i.Id == cPending.Id).HasSealedCopy.Should().BeFalse(
"cùng lý do ở 'Hộp thư' — HĐ `cPending` vừa được chứng minh là `true` ở màn danh sách, "
+ "nên `false` ở đây đúng là do KHÔNG derive, không phải do HĐ khác");
}
}