[CLAUDE] Contract: YC-029 W5 — đường xoá đồng-khuôn KHKK (owner-check + phase allow-list + changelog, test-before 13 ca)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -3,6 +3,7 @@
|
|||||||
> **Update rule:** trước khi bắt đầu 1 task → ghi row `🔥 In Progress`. Xong → `✅ Recently Done`.
|
> **Update rule:** trước khi bắt đầu 1 task → ghi row `🔥 In Progress`. Xong → `✅ Recently Done`.
|
||||||
> **Tiering rule (S40):** chỉ giữ **state hiện tại + 3 session gần nhất** ở file này. Session cũ hơn → `docs/changelog/sessions/`. Full history pre-S40 → `docs/_archive/STATUS-preS40-fullhistory.md`. (Tránh over-context — xóa double, không cắt nội dung.)
|
> **Tiering rule (S40):** chỉ giữ **state hiện tại + 3 session gần nhất** ở file này. Session cũ hơn → `docs/changelog/sessions/`. Full history pre-S40 → `docs/_archive/STATUS-preS40-fullhistory.md`. (Tránh over-context — xóa double, không cắt nội dung.)
|
||||||
|
|
||||||
|
> 🔄 **S190 IN-PROGRESS (2026-08-12, phiên-LOGIC L15 window 2)** — YC-029 "SOLTUON-12-08-2026" 5 wave B6: W4 `1cc16a2d` + W1 `09ebd20e` + W2 `7b291014` + W3 `ed3b39ff` + W5 (commit này) · test **680→693** (+13 `ContractDeleteGuardTests` T-matrix, test-before RED→GREEN vòng-1) · 0-mig · PUSH GOM CHỜ (CI runner P0 — cửa ngoài-giờ) ⇒ row canonical Tests `:476` = DEFERRED-TO-DEPLOY, chỉ chép từ cicd-verify. Mạch sống → `.claude/WAL.md`.
|
||||||
> ✅ **S187→S188 ĐÓNG (2026-08-10, phiên-LOGIC L14 window 2-3)** — **module Duyệt Hợp đồng (GĐ3) từ KHKK SHIP PROD 2 đợt** (YC-023 mở việc · YC-024 9 đáp án (80)-(88) · YC-025 *"làm tiếp hết, review 1 lần"*). Đợt-1 `0149f106` (W1 gỡ 2 bom per-type · W2 BE-DTO nguồn-gốc+cây workflow · W7 server-filter) + cụm `b275893c` (W3 seed **7 quy trình `HD-*`** sao khuôn KHKK + **7 key `AwV2_Hd*`** · W4 guard 4-cửa khoá-cứng-theo-loại · W6-BE Đ2 đúng-trạm · W5 card Nguồn-gốc · W6-FE panel **2 nút** + Designer type-3 slice). **0 migration** · test **668→680** · Menu keys 113→**120** · Policies 452→**480** · cicd **4/4 + 5/5** (seeder prod 7 workflow + 7 menu-row, KHKK-8 control nguyên) · **W10 dry-run Dev PASS 5/5** toàn trình 2 nhánh tới `DaPhatHanh`. 🔴 W8 authz **HOÃN** theo (82) · W9 **HỦY** theo (85). #53 garble ~90% return, **đĩa cứu 100%**. Log: `docs/changelog/sessions/2026-08-10-2000-S187-S188-duyet-hop-dong-gd3.md` · run-trace `runs/2026-08-10-S187-duyet-hd-tu-khkk/` (19 file + `implement-synthesis.md`).
|
> ✅ **S187→S188 ĐÓNG (2026-08-10, phiên-LOGIC L14 window 2-3)** — **module Duyệt Hợp đồng (GĐ3) từ KHKK SHIP PROD 2 đợt** (YC-023 mở việc · YC-024 9 đáp án (80)-(88) · YC-025 *"làm tiếp hết, review 1 lần"*). Đợt-1 `0149f106` (W1 gỡ 2 bom per-type · W2 BE-DTO nguồn-gốc+cây workflow · W7 server-filter) + cụm `b275893c` (W3 seed **7 quy trình `HD-*`** sao khuôn KHKK + **7 key `AwV2_Hd*`** · W4 guard 4-cửa khoá-cứng-theo-loại · W6-BE Đ2 đúng-trạm · W5 card Nguồn-gốc · W6-FE panel **2 nút** + Designer type-3 slice). **0 migration** · test **668→680** · Menu keys 113→**120** · Policies 452→**480** · cicd **4/4 + 5/5** (seeder prod 7 workflow + 7 menu-row, KHKK-8 control nguyên) · **W10 dry-run Dev PASS 5/5** toàn trình 2 nhánh tới `DaPhatHanh`. 🔴 W8 authz **HOÃN** theo (82) · W9 **HỦY** theo (85). #53 garble ~90% return, **đĩa cứu 100%**. Log: `docs/changelog/sessions/2026-08-10-2000-S187-S188-duyet-hop-dong-gd3.md` · run-trace `runs/2026-08-10-S187-duyet-hd-tu-khkk/` (19 file + `implement-synthesis.md`).
|
||||||
> ✅ **S185 ĐÓNG (2026-08-10, phiên-LOGIC L13 window 1-4)** — closeout ONE-SHOT lượt đầu (YC-018). **10 mã YC đóng** (YC-013→022): KHKK 8-panel + guard tay-cầm-phiếu SHIP+verify+acceptance · pipeline email/adap **0/39 nợ per-id** · bookend @close **9 vai một mạch** · ring5-audit (roster 29) + `/day` day-wake · deep_every 15→1 · bảng thẩm-quyền owner chính thức. Tests **668** · counter **59** · over-cap 3. Log: `docs/changelog/sessions/2026-08-10-0400-S185-bookend-oneshot-yc017-022.md`.
|
> ✅ **S185 ĐÓNG (2026-08-10, phiên-LOGIC L13 window 1-4)** — closeout ONE-SHOT lượt đầu (YC-018). **10 mã YC đóng** (YC-013→022): KHKK 8-panel + guard tay-cầm-phiếu SHIP+verify+acceptance · pipeline email/adap **0/39 nợ per-id** · bookend @close **9 vai một mạch** · ring5-audit (roster 29) + `/day` day-wake · deep_every 15→1 · bảng thẩm-quyền owner chính thức. Tests **668** · counter **59** · over-cap 3. Log: `docs/changelog/sessions/2026-08-10-0400-S185-bookend-oneshot-yc017-022.md`.
|
||||||
> 🔴 **mốc SỐNG = S185, phiên-LOGIC L13, window 4** (2026-08-10) — YC-013 KHKK 8-panel + YC-016 guard tay-cầm-phiếu SHIP+verify prod · YC-017 pipeline email/adap trọn · YC-018 ONE-SHOT bookend land · bookend @close MỘT MẠCH 9 vai (chi tiết `docs/HANDOFF.md` segment mới + `runs/2026-08-09-S185-bookend-close/`). 🧊 mốc cũ S179/L11/window-7 (chuỗi UAT KHKK 8 đợt + đội STYLE) → xem segment Recently-Done. **Dòng CURRENT ngay dưới đây advance @closeout nên tụt sau — giữa phiên đọc `.claude/WAL.md`.**
|
> 🔴 **mốc SỐNG = S185, phiên-LOGIC L13, window 4** (2026-08-10) — YC-013 KHKK 8-panel + YC-016 guard tay-cầm-phiếu SHIP+verify prod · YC-017 pipeline email/adap trọn · YC-018 ONE-SHOT bookend land · bookend @close MỘT MẠCH 9 vai (chi tiết `docs/HANDOFF.md` segment mới + `runs/2026-08-09-S185-bookend-close/`). 🧊 mốc cũ S179/L11/window-7 (chuỗi UAT KHKK 8 đợt + đội STYLE) → xem segment Recently-Done. **Dòng CURRENT ngay dưới đây advance @closeout nên tụt sau — giữa phiên đọc `.claude/WAL.md`.**
|
||||||
|
|||||||
@ -2,10 +2,11 @@
|
|||||||
// in MyContractsPage 3-panel layout (Panel 2). Renders header (title + phase
|
// in MyContractsPage 3-panel layout (Panel 2). Renders header (title + phase
|
||||||
// + actions) + Info + Comments + Attachments + transition Dialog. Workflow +
|
// + actions) + Info + Comments + Attachments + transition Dialog. Workflow +
|
||||||
// approval history live separately in WorkflowHistoryPanel (Panel 3).
|
// approval history live separately in WorkflowHistoryPanel (Panel 3).
|
||||||
|
// File MIRROR SHA256 identical với fe-admin counterpart.
|
||||||
import { useState, type FormEvent } from 'react'
|
import { useState, type FormEvent } from 'react'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { ArrowLeft, CheckCircle2, MessageSquare, XCircle, ListChecks, GitBranch } from 'lucide-react'
|
import { ArrowLeft, CheckCircle2, MessageSquare, Trash2, XCircle, ListChecks, GitBranch } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||||
import { SlaTimer } from '@/components/SlaTimer'
|
import { SlaTimer } from '@/components/SlaTimer'
|
||||||
@ -18,6 +19,7 @@ import { Button } from '@/components/ui/Button'
|
|||||||
import { Select } from '@/components/ui/Select'
|
import { Select } from '@/components/ui/Select'
|
||||||
import { Textarea } from '@/components/ui/Textarea'
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
import { Dialog } from '@/components/ui/Dialog'
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { getErrorMessage } from '@/lib/apiError'
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
import {
|
import {
|
||||||
@ -38,12 +40,21 @@ const fmtMoney = (v: number) => v.toLocaleString('vi-VN') + ' VND'
|
|||||||
export function ContractDetailContent({
|
export function ContractDetailContent({
|
||||||
contract: c,
|
contract: c,
|
||||||
onBack,
|
onBack,
|
||||||
|
onDeleted,
|
||||||
}: {
|
}: {
|
||||||
contract: ContractDetail
|
contract: ContractDetail
|
||||||
/** Optional back handler — shown as arrow button next to title. Pass `navigate(-1)` for fullpage; omit for embedded panel. */
|
/** Optional back handler — shown as arrow button next to title. Pass `navigate(-1)` for fullpage; omit for embedded panel. */
|
||||||
onBack?: () => void
|
onBack?: () => void
|
||||||
|
/**
|
||||||
|
* [W5c S190] Gọi SAU khi xoá HĐ ở thanh nút đáy — host bỏ chọn / quay về danh sách.
|
||||||
|
* KHÔNG truyền ⇒ chỉ có 3 `invalidateQueries` chạy: danh sách tự rụng dòng, còn màn chi
|
||||||
|
* tiết đang mở refetch ra 404 và host rơi về nhánh "không tìm thấy / chưa chọn HĐ" của nó.
|
||||||
|
* (Tính đến wave này chưa host nào truyền — xem `sub-b6-w5c-fe.md`.)
|
||||||
|
*/
|
||||||
|
onDeleted?: () => void
|
||||||
}) {
|
}) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
const { user: currentUser } = useAuth()
|
||||||
const [actionOpen, setActionOpen] = useState(false)
|
const [actionOpen, setActionOpen] = useState(false)
|
||||||
const [targetPhase, setTargetPhase] = useState<number>(0)
|
const [targetPhase, setTargetPhase] = useState<number>(0)
|
||||||
const [decision, setDecision] = useState<number>(ApprovalDecision.Approve)
|
const [decision, setDecision] = useState<number>(ApprovalDecision.Approve)
|
||||||
@ -77,6 +88,44 @@ export function ContractDetailContent({
|
|||||||
onError: err => toast.error(getErrorMessage(err)),
|
onError: err => toast.error(getErrorMessage(err)),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ===== [W5c S190 · YC-029] XOÁ HỢP ĐỒNG ================================================
|
||||||
|
// Đường xoá MỀM (BE `ContractFeatures.cs` khối DELETE, W5b): HĐ rời danh sách thường chứ
|
||||||
|
// không mất khỏi DB. Mọi rào ở đây là BẢN SAO HIỂN THỊ của guard BE, KHÔNG thay nó — chỗ
|
||||||
|
// chặn thật nằm trong handler (`ContractsController.cs` DELETE còn `[Authorize]` trần vì
|
||||||
|
// quyền per-action là W8, owner đã HOÃN).
|
||||||
|
const deleteContract = useMutation({
|
||||||
|
// `reason` đi QUERY-STRING vì verb DELETE không mang body — đúng hình endpoint W5b
|
||||||
|
// (`[FromQuery] string? reason`). Màn này CHƯA thu lý do: khuôn KHKK
|
||||||
|
// (`KhkkDetailContent.tsx:1090`) cũng chỉ `window.confirm`, và tự đẻ `window.prompt` ở
|
||||||
|
// đây là quyết-định UX chưa ai chốt. Gọi không truyền ⇒ axios bỏ hẳn key ⇒ request y hệt
|
||||||
|
// `DELETE /contracts/{id}`; chỗ cắm để sẵn cho lúc owner chốt có hỏi lý do hay không.
|
||||||
|
mutationFn: async (reason?: string) =>
|
||||||
|
api.delete(`/contracts/${c.id}`, { params: reason?.trim() ? { reason: reason.trim() } : undefined }),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Cùng bộ key với `transition` ở trên (:57-61) — HĐ vừa xoá phải rụng khỏi CẢ danh sách
|
||||||
|
// của tôi LẪN hộp chờ duyệt, không riêng màn đang đứng.
|
||||||
|
qc.invalidateQueries({ queryKey: ['contract', c.id] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['my-contracts'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['inbox'] })
|
||||||
|
toast.success('Đã xóa hợp đồng.')
|
||||||
|
onDeleted?.()
|
||||||
|
},
|
||||||
|
onError: err => toast.error(getErrorMessage(err)),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Rào HIỂN THỊ mirror guard BE — 2 vế:
|
||||||
|
// • phase ∈ allow-list `< DangInKy(5)` ∪ {TraLai(98), TuChoi(99)}. ChoDuyet(10) KHÔNG có
|
||||||
|
// kể cả với người soạn (đang chờ người khác duyệt); DaPhatHanh(9) đã sinh mã ⇒ chặn mọi
|
||||||
|
// đường. Viết `<` chứ không liệt 1/2/3/4 vì allow-list HĐ gồm cả phase legacy.
|
||||||
|
// • người soạn HOẶC Admin. `isDrafter` theo khuôn null-safe `PeDetailTabs.tsx:141`:
|
||||||
|
// `currentUser?.id != null` phải ĐỨNG TRƯỚC vì `drafterUserId` là `string | null`
|
||||||
|
// (`types/contracts.ts:154`) — thiếu vế đó thì `null === null` cho HĐ vô chủ hiện nút.
|
||||||
|
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
||||||
|
const isDrafter = currentUser?.id != null && c.drafterUserId === currentUser.id
|
||||||
|
const canDelete =
|
||||||
|
(c.phase < ContractPhase.DangInKy || c.phase === ContractPhase.TraLai || c.phase === ContractPhase.TuChoi)
|
||||||
|
&& (isDrafter || isAdmin)
|
||||||
|
|
||||||
const availableTargets = c.workflow?.nextPhases ?? []
|
const availableTargets = c.workflow?.nextPhases ?? []
|
||||||
|
|
||||||
// ===== [W6 S187 · YC-023 GĐ3] DUAL-RENDER V2-trạm ⟂ V1-legacy ==========================
|
// ===== [W6 S187 · YC-023 GĐ3] DUAL-RENDER V2-trạm ⟂ V1-legacy ==========================
|
||||||
@ -397,6 +446,37 @@ export function ContractDetailContent({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* [W5c S190 · YC-029] THANH NÚT ĐÁY — khuôn `KhkkDetailContent.tsx:1076-1097`
|
||||||
|
(🔴 KHÔNG lấy `KhkkWorkflowPanel`: nhánh xoá trong đó là code chết từ S175).
|
||||||
|
🔴 `sticky bottom-0`: trang HĐ dài (thông tin + nguồn KHKK + góp ý + file + chi tiết
|
||||||
|
+ ý kiến duyệt) — nút đứng cuối luồng thì phải cuộn hết mới với tới.
|
||||||
|
🔸 Hỏi bằng `window.confirm` chứ KHÔNG mở hộp thoại `Dialog` thứ hai: hộp thoại duy
|
||||||
|
nhất của file này đang giữ ĐÚNG MỘT nghĩa — chuyển phase (chọn phase + ghi chú). Xoá
|
||||||
|
không có gì để nhập (lý do chưa thu, xem mutation ở trên) nên hộp thứ hai chỉ là ô
|
||||||
|
trống 2 nút — và nó cũng làm hỏng chính ô đo C-12 vế 3 (đếm token mở hộp thoại theo
|
||||||
|
DÒNG: bình luận nhắc tên token cũng bị tính, bẫy tự-trích-dẫn S188). Câu hỏi
|
||||||
|
mang MÃ HĐ + ĐÍCH đi tới ("mục Đã xóa") nói đủ hậu quả — mã để bấm nhầm HĐ khác thì
|
||||||
|
nhìn ra, đích để không tưởng là xoá vĩnh viễn. */}
|
||||||
|
{canDelete && (
|
||||||
|
<div className="sticky bottom-0 z-10 flex flex-wrap items-center gap-3 border-t border-slate-200 bg-white/95 px-5 py-3 backdrop-blur">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Xóa hợp đồng ${c.maHopDong ?? c.tenHopDong ?? '(chưa có mã)'}? Hợp đồng chuyển sang mục "Đã xóa".`)) deleteContract.mutate(undefined)
|
||||||
|
}}
|
||||||
|
disabled={deleteContract.isPending}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md bg-red-600 px-3 py-2 text-xs font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
{deleteContract.isPending ? 'Đang xóa…' : 'Xóa hợp đồng'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="min-w-0 flex-1 text-[12px] text-slate-500">
|
||||||
|
Xoá mềm — dữ liệu không mất khỏi hệ thống.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,10 +2,11 @@
|
|||||||
// in MyContractsPage 3-panel layout (Panel 2). Renders header (title + phase
|
// in MyContractsPage 3-panel layout (Panel 2). Renders header (title + phase
|
||||||
// + actions) + Info + Comments + Attachments + transition Dialog. Workflow +
|
// + actions) + Info + Comments + Attachments + transition Dialog. Workflow +
|
||||||
// approval history live separately in WorkflowHistoryPanel (Panel 3).
|
// approval history live separately in WorkflowHistoryPanel (Panel 3).
|
||||||
|
// File MIRROR SHA256 identical với fe-admin counterpart.
|
||||||
import { useState, type FormEvent } from 'react'
|
import { useState, type FormEvent } from 'react'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { ArrowLeft, CheckCircle2, MessageSquare, XCircle, ListChecks, GitBranch } from 'lucide-react'
|
import { ArrowLeft, CheckCircle2, MessageSquare, Trash2, XCircle, ListChecks, GitBranch } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||||
import { SlaTimer } from '@/components/SlaTimer'
|
import { SlaTimer } from '@/components/SlaTimer'
|
||||||
@ -18,6 +19,7 @@ import { Button } from '@/components/ui/Button'
|
|||||||
import { Select } from '@/components/ui/Select'
|
import { Select } from '@/components/ui/Select'
|
||||||
import { Textarea } from '@/components/ui/Textarea'
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
import { Dialog } from '@/components/ui/Dialog'
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { getErrorMessage } from '@/lib/apiError'
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
import {
|
import {
|
||||||
@ -38,12 +40,21 @@ const fmtMoney = (v: number) => v.toLocaleString('vi-VN') + ' VND'
|
|||||||
export function ContractDetailContent({
|
export function ContractDetailContent({
|
||||||
contract: c,
|
contract: c,
|
||||||
onBack,
|
onBack,
|
||||||
|
onDeleted,
|
||||||
}: {
|
}: {
|
||||||
contract: ContractDetail
|
contract: ContractDetail
|
||||||
/** Optional back handler — shown as arrow button next to title. Pass `navigate(-1)` for fullpage; omit for embedded panel. */
|
/** Optional back handler — shown as arrow button next to title. Pass `navigate(-1)` for fullpage; omit for embedded panel. */
|
||||||
onBack?: () => void
|
onBack?: () => void
|
||||||
|
/**
|
||||||
|
* [W5c S190] Gọi SAU khi xoá HĐ ở thanh nút đáy — host bỏ chọn / quay về danh sách.
|
||||||
|
* KHÔNG truyền ⇒ chỉ có 3 `invalidateQueries` chạy: danh sách tự rụng dòng, còn màn chi
|
||||||
|
* tiết đang mở refetch ra 404 và host rơi về nhánh "không tìm thấy / chưa chọn HĐ" của nó.
|
||||||
|
* (Tính đến wave này chưa host nào truyền — xem `sub-b6-w5c-fe.md`.)
|
||||||
|
*/
|
||||||
|
onDeleted?: () => void
|
||||||
}) {
|
}) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
const { user: currentUser } = useAuth()
|
||||||
const [actionOpen, setActionOpen] = useState(false)
|
const [actionOpen, setActionOpen] = useState(false)
|
||||||
const [targetPhase, setTargetPhase] = useState<number>(0)
|
const [targetPhase, setTargetPhase] = useState<number>(0)
|
||||||
const [decision, setDecision] = useState<number>(ApprovalDecision.Approve)
|
const [decision, setDecision] = useState<number>(ApprovalDecision.Approve)
|
||||||
@ -77,6 +88,44 @@ export function ContractDetailContent({
|
|||||||
onError: err => toast.error(getErrorMessage(err)),
|
onError: err => toast.error(getErrorMessage(err)),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ===== [W5c S190 · YC-029] XOÁ HỢP ĐỒNG ================================================
|
||||||
|
// Đường xoá MỀM (BE `ContractFeatures.cs` khối DELETE, W5b): HĐ rời danh sách thường chứ
|
||||||
|
// không mất khỏi DB. Mọi rào ở đây là BẢN SAO HIỂN THỊ của guard BE, KHÔNG thay nó — chỗ
|
||||||
|
// chặn thật nằm trong handler (`ContractsController.cs` DELETE còn `[Authorize]` trần vì
|
||||||
|
// quyền per-action là W8, owner đã HOÃN).
|
||||||
|
const deleteContract = useMutation({
|
||||||
|
// `reason` đi QUERY-STRING vì verb DELETE không mang body — đúng hình endpoint W5b
|
||||||
|
// (`[FromQuery] string? reason`). Màn này CHƯA thu lý do: khuôn KHKK
|
||||||
|
// (`KhkkDetailContent.tsx:1090`) cũng chỉ `window.confirm`, và tự đẻ `window.prompt` ở
|
||||||
|
// đây là quyết-định UX chưa ai chốt. Gọi không truyền ⇒ axios bỏ hẳn key ⇒ request y hệt
|
||||||
|
// `DELETE /contracts/{id}`; chỗ cắm để sẵn cho lúc owner chốt có hỏi lý do hay không.
|
||||||
|
mutationFn: async (reason?: string) =>
|
||||||
|
api.delete(`/contracts/${c.id}`, { params: reason?.trim() ? { reason: reason.trim() } : undefined }),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Cùng bộ key với `transition` ở trên (:57-61) — HĐ vừa xoá phải rụng khỏi CẢ danh sách
|
||||||
|
// của tôi LẪN hộp chờ duyệt, không riêng màn đang đứng.
|
||||||
|
qc.invalidateQueries({ queryKey: ['contract', c.id] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['my-contracts'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['inbox'] })
|
||||||
|
toast.success('Đã xóa hợp đồng.')
|
||||||
|
onDeleted?.()
|
||||||
|
},
|
||||||
|
onError: err => toast.error(getErrorMessage(err)),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Rào HIỂN THỊ mirror guard BE — 2 vế:
|
||||||
|
// • phase ∈ allow-list `< DangInKy(5)` ∪ {TraLai(98), TuChoi(99)}. ChoDuyet(10) KHÔNG có
|
||||||
|
// kể cả với người soạn (đang chờ người khác duyệt); DaPhatHanh(9) đã sinh mã ⇒ chặn mọi
|
||||||
|
// đường. Viết `<` chứ không liệt 1/2/3/4 vì allow-list HĐ gồm cả phase legacy.
|
||||||
|
// • người soạn HOẶC Admin. `isDrafter` theo khuôn null-safe `PeDetailTabs.tsx:141`:
|
||||||
|
// `currentUser?.id != null` phải ĐỨNG TRƯỚC vì `drafterUserId` là `string | null`
|
||||||
|
// (`types/contracts.ts:154`) — thiếu vế đó thì `null === null` cho HĐ vô chủ hiện nút.
|
||||||
|
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
||||||
|
const isDrafter = currentUser?.id != null && c.drafterUserId === currentUser.id
|
||||||
|
const canDelete =
|
||||||
|
(c.phase < ContractPhase.DangInKy || c.phase === ContractPhase.TraLai || c.phase === ContractPhase.TuChoi)
|
||||||
|
&& (isDrafter || isAdmin)
|
||||||
|
|
||||||
const availableTargets = c.workflow?.nextPhases ?? []
|
const availableTargets = c.workflow?.nextPhases ?? []
|
||||||
|
|
||||||
// ===== [W6 S187 · YC-023 GĐ3] DUAL-RENDER V2-trạm ⟂ V1-legacy ==========================
|
// ===== [W6 S187 · YC-023 GĐ3] DUAL-RENDER V2-trạm ⟂ V1-legacy ==========================
|
||||||
@ -397,6 +446,37 @@ export function ContractDetailContent({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* [W5c S190 · YC-029] THANH NÚT ĐÁY — khuôn `KhkkDetailContent.tsx:1076-1097`
|
||||||
|
(🔴 KHÔNG lấy `KhkkWorkflowPanel`: nhánh xoá trong đó là code chết từ S175).
|
||||||
|
🔴 `sticky bottom-0`: trang HĐ dài (thông tin + nguồn KHKK + góp ý + file + chi tiết
|
||||||
|
+ ý kiến duyệt) — nút đứng cuối luồng thì phải cuộn hết mới với tới.
|
||||||
|
🔸 Hỏi bằng `window.confirm` chứ KHÔNG mở hộp thoại `Dialog` thứ hai: hộp thoại duy
|
||||||
|
nhất của file này đang giữ ĐÚNG MỘT nghĩa — chuyển phase (chọn phase + ghi chú). Xoá
|
||||||
|
không có gì để nhập (lý do chưa thu, xem mutation ở trên) nên hộp thứ hai chỉ là ô
|
||||||
|
trống 2 nút — và nó cũng làm hỏng chính ô đo C-12 vế 3 (đếm token mở hộp thoại theo
|
||||||
|
DÒNG: bình luận nhắc tên token cũng bị tính, bẫy tự-trích-dẫn S188). Câu hỏi
|
||||||
|
mang MÃ HĐ + ĐÍCH đi tới ("mục Đã xóa") nói đủ hậu quả — mã để bấm nhầm HĐ khác thì
|
||||||
|
nhìn ra, đích để không tưởng là xoá vĩnh viễn. */}
|
||||||
|
{canDelete && (
|
||||||
|
<div className="sticky bottom-0 z-10 flex flex-wrap items-center gap-3 border-t border-slate-200 bg-white/95 px-5 py-3 backdrop-blur">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Xóa hợp đồng ${c.maHopDong ?? c.tenHopDong ?? '(chưa có mã)'}? Hợp đồng chuyển sang mục "Đã xóa".`)) deleteContract.mutate(undefined)
|
||||||
|
}}
|
||||||
|
disabled={deleteContract.isPending}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md bg-red-600 px-3 py-2 text-xs font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
{deleteContract.isPending ? 'Đang xóa…' : 'Xóa hợp đồng'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="min-w-0 flex-1 text-[12px] text-slate-500">
|
||||||
|
Xoá mềm — dữ liệu không mất khỏi hệ thống.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -75,10 +75,22 @@ public class ContractsController(IMediator mediator) : ControllerBase
|
|||||||
return Ok(new { id = commentId });
|
return Ok(new { id = commentId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [W5 S190 · YC-029] Xoá HĐ (mềm) + lý do. `reason` đi qua query-string vì verb DELETE
|
||||||
|
// không mang body — khuôn KHKK `ContractSigningPlansController.Delete`. Optional ⇒ 0 breaking
|
||||||
|
// cho caller cũ đang gọi `DELETE /api/contracts/{id}` không tham số.
|
||||||
|
//
|
||||||
|
// 🔴 CỐ Ý CHƯA gắn policy per-action cho verb này (giữ `[Authorize]` trần ở class). Đo LocalDB
|
||||||
|
// Dev: khoá menu `Contracts` có 12/13 role `CanDelete = 0` — trong đó có chính `Drafter` ⇒ gắn
|
||||||
|
// vào là khoá oan, người soạn mất nút xoá HĐ nháp của mình (đúng bài PE `PeSoftDeleteFeatures.cs:25-29`).
|
||||||
|
// Rào quyền THẬT nằm ở handler (chủ HĐ hoặc Admin, xem `ContractFeatures.cs` khối DELETE) —
|
||||||
|
// đây là chỗ duy nhất chặn được, không phải chỗ này. Mở quyền diện rộng là W8, owner đã HOÃN
|
||||||
|
// (chốt (82)); điều kiện mở: policy + grant `Drafter` land CÙNG một commit rồi đo lại trên prod.
|
||||||
|
// [carry:w8-delete-policy]
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(
|
||||||
|
Guid id, [FromQuery] string? reason = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await mediator.Send(new DeleteContractCommand(id), ct);
|
await mediator.Send(new DeleteContractCommand(id, reason), ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -883,20 +883,107 @@ public class GetContractQueryHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ========== DELETE (soft) ==========
|
// ========== DELETE (soft) ==========
|
||||||
|
//
|
||||||
|
// [W5 S190 · YC-029] Đường xoá HĐ dựng lại đồng-khuôn KHKK (`ContractSigningPlanFeatures.cs`
|
||||||
|
// Region 7 `:1737-1807`). 5 nấc, THỨ TỰ là một phần của đặc tả — không phải sắp cho đẹp:
|
||||||
|
// (0) chưa đăng nhập → 401
|
||||||
|
// (1) không tìm thấy → 404
|
||||||
|
// (2) không phải chủ/Admin → 403
|
||||||
|
// (3) phase ngoài allow-list→ 409
|
||||||
|
// (4) xoá mềm + ghi 1 vết nhật ký mang LÝ DO
|
||||||
|
// Nấc (1) đứng TRƯỚC (2) để 403/404 không thành kênh dò "id này có tồn tại không"; nấc (2)
|
||||||
|
// đứng TRƯỚC (3) để người ngoài không đọc được HĐ đang ở bước nào.
|
||||||
|
//
|
||||||
|
// 🔴 3 bẫy đã né — ghi lại vì cả ba đều trông như "chỗ có thể gọn hơn":
|
||||||
|
//
|
||||||
|
// a. NẤC-0 KHÔNG CÓ trong khuôn KHKK (`:1763` so thẳng người soạn với actor). HĐ có
|
||||||
|
// `DrafterUserId` NULL (data legacy) gặp actor chưa đăng nhập (UserId null) ⇒ `null == null`
|
||||||
|
// ⇒ hoá "chủ sở hữu" ⇒ xoá LỌT. Nấc-0 lấy từ `PeSoftDeleteFeatures.cs:56-58`. Ai thấy hai
|
||||||
|
// khuôn "lệch nhau" thì sửa KHKK cho có nấc-0, ĐỪNG xoá nấc-0 ở đây cho giống.
|
||||||
|
//
|
||||||
|
// b. ALLOW-LIST PHASE CỦA HĐ RỘNG HƠN KHKK. KHKK là bộ 3 {Nháp, Từ chối, Trả lại}; HĐ là
|
||||||
|
// `Phase < DangInKy(5)` ∪ {TraLai(98), TuChoi(99)} — gồm cả legacy 1/3/4 (Dev còn HĐ sống
|
||||||
|
// ở `DangGopY`). Chép nguyên hình `is not (…)` của KHKK = 409 oan đúng 3 phase đó.
|
||||||
|
//
|
||||||
|
// c. THỨ TỰ `Remove` / `Add(changelog)` — chú thích tại chỗ (bug T26 S155).
|
||||||
|
//
|
||||||
|
// 🔶 LÝ DO xoá chỉ sống ở changelog: `IsDeleted/DeletedAt/DeletedBy` (do `AuditingInterceptor`
|
||||||
|
// đặt khi `Remove`) chỉ trả lời "ai + lúc nào".
|
||||||
|
// 🔶 Endpoint DELETE CỐ Ý chưa gắn policy per-action — lý do đo được ghi tại
|
||||||
|
// `ContractsController.Delete` [carry:w8-delete-policy].
|
||||||
|
// Test canh: `tests/SolutionErp.Infrastructure.Tests/Application/ContractDeleteGuardTests.cs`
|
||||||
|
// (13 ca T-1..T-13, viết TRƯỚC mã này).
|
||||||
|
|
||||||
public record DeleteContractCommand(Guid Id) : IRequest;
|
/// <param name="Reason">Lý do xoá (tuỳ chọn) — chảy vào `ContractChangelog.ContextNote`.</param>
|
||||||
|
public record DeleteContractCommand(Guid Id, string? Reason = null) : IRequest;
|
||||||
|
|
||||||
public class DeleteContractCommandHandler(IApplicationDbContext db) : IRequestHandler<DeleteContractCommand>
|
public class DeleteContractCommandValidator : AbstractValidator<DeleteContractCommand>
|
||||||
|
{
|
||||||
|
public DeleteContractCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id).NotEmpty();
|
||||||
|
// Trần MATCH EF, không lấy theo spec: `ContractChangelogConfiguration.cs:20`
|
||||||
|
// `ContextNote` HasMaxLength(2000). Lệch số ở đây = EF nổ `DbUpdateException` (500)
|
||||||
|
// thay vì 400 có thông báo đọc được.
|
||||||
|
RuleFor(x => x.Reason).MaximumLength(2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DeleteContractCommandHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
ICurrentUser currentUser) : IRequestHandler<DeleteContractCommand>
|
||||||
{
|
{
|
||||||
public async Task Handle(DeleteContractCommand request, CancellationToken ct)
|
public async Task Handle(DeleteContractCommand request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
// (0) Chặn actor vô danh TRƯỚC mọi phép so người — bẫy (a) ở đầu khối.
|
||||||
|
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
|
||||||
var entity = await db.Contracts.FirstOrDefaultAsync(c => c.Id == request.Id, ct)
|
var entity = await db.Contracts.FirstOrDefaultAsync(c => c.Id == request.Id, ct)
|
||||||
?? throw new NotFoundException("Contract", request.Id);
|
?? throw new NotFoundException("Contract", request.Id);
|
||||||
|
|
||||||
if (entity.Phase >= ContractPhase.DangInKy)
|
// (2) Chủ HĐ hoặc Admin. Trước W5 khối này KHÔNG hỏi actor là ai ⇒ mọi user đăng nhập
|
||||||
throw new ConflictException("Không được xóa HĐ đã qua phase 'Đang in ký'.");
|
// xoá được HĐ của người khác (lỗ #82 — rào hiển thị FE và rào API là 2 tầng độc lập;
|
||||||
|
// FE ẩn nút không phải là rào). An toàn khi so vì nấc (0) đã loại UserId null.
|
||||||
|
var isOwner = entity.DrafterUserId == currentUser.UserId;
|
||||||
|
var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
|
||||||
|
if (!isOwner && !isAdmin)
|
||||||
|
throw new ForbiddenException("Chỉ người soạn hợp đồng hoặc Admin được xoá hợp đồng này.");
|
||||||
|
|
||||||
|
// (3) Phase — bẫy (b). `ChoDuyet(10)` cấm KỂ CẢ chủ HĐ (rút êm là cướp lượt của người
|
||||||
|
// đang duyệt; đường xoá-khi-đang-duyệt dành cho approver là việc khác, chưa mở).
|
||||||
|
// Mọi phase ≥ `DangInKy(5)` cấm mọi đường, kể cả Admin — tới đó HĐ đã mang mã chính thức.
|
||||||
|
var phaseAllowed = entity.Phase < ContractPhase.DangInKy
|
||||||
|
|| entity.Phase == ContractPhase.TraLai
|
||||||
|
|| entity.Phase == ContractPhase.TuChoi;
|
||||||
|
if (!phaseAllowed)
|
||||||
|
throw new ConflictException(
|
||||||
|
"Chỉ xoá được hợp đồng ở trạng thái soạn thảo, Trả lại hoặc Từ chối. " +
|
||||||
|
"Hợp đồng Đã gửi duyệt phải Trả lại/Từ chối trước; hợp đồng từ 'Đang in ký' " +
|
||||||
|
"trở đi (trình ký, đóng dấu, đã phát hành) thì không xoá được.");
|
||||||
|
|
||||||
|
var reason = string.IsNullOrWhiteSpace(request.Reason) ? null : request.Reason.Trim();
|
||||||
|
var phaseAtChange = entity.Phase;
|
||||||
|
|
||||||
|
// 🔴 THỨ TỰ: `Remove()` TRƯỚC, `Add(changelog)` SAU (bug T26 S155). EF chạy cascade
|
||||||
|
// client-side NGAY tại `Remove()` (`CascadeDeleteTiming.Immediate`); dòng nhật ký đã ở
|
||||||
|
// state `Added` trước đó sẽ bị DETACH ⇒ lưu xong 0 row, im lặng, không lỗi. Thêm SAU thì
|
||||||
|
// lượt cascade đã chạy xong lúc chưa có con nào để gỡ ⇒ vết sống.
|
||||||
db.Contracts.Remove(entity);
|
db.Contracts.Remove(entity);
|
||||||
|
|
||||||
|
db.ContractChangelogs.Add(new ContractChangelog
|
||||||
|
{
|
||||||
|
ContractId = entity.Id,
|
||||||
|
EntityType = ChangelogEntityType.Contract,
|
||||||
|
EntityId = null, // header thì để trống (khuôn 2 site ghi tay)
|
||||||
|
Action = ChangelogAction.Delete,
|
||||||
|
PhaseAtChange = phaseAtChange,
|
||||||
|
UserId = currentUser.UserId,
|
||||||
|
UserName = currentUser.FullName ?? currentUser.Email,
|
||||||
|
Summary = $"Xoá hợp đồng {entity.MaHopDong ?? entity.TenHopDong ?? "(chưa có mã)"}",
|
||||||
|
ContextNote = reason,
|
||||||
|
});
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,19 @@ namespace SolutionErp.Domain.Contracts;
|
|||||||
// log workflow transitions, dùng cho guard logic) — Changelog là VIEW LAYER
|
// log workflow transitions, dùng cho guard logic) — Changelog là VIEW LAYER
|
||||||
// cho user đọc lịch sử thao tác.
|
// cho user đọc lịch sử thao tác.
|
||||||
//
|
//
|
||||||
// Populate qua MediatR `AuditBehavior` interceptor — auto từ Insert/Update/
|
// 🔴 [S190 W5] ĐÍNH CHÍNH — câu cũ ở đây ghi "populate qua MediatR `AuditBehavior` interceptor
|
||||||
// Delete commands trên Contract + Details (xem Application/Common/Behaviors/
|
// (xem Application/Common/Behaviors/AuditBehavior.cs)". **File đó CHƯA BAO GIỜ tồn tại**
|
||||||
// AuditBehavior.cs).
|
// (`Common/Behaviors/` chỉ có `ValidationBehavior.cs`); nó nằm ở mục "Tier 4+ còn thiếu" của
|
||||||
|
// skill `contract-workflow`. Câu cũ nguy hiểm hơn thiếu chú thích: người đọc tưởng nhật ký tự
|
||||||
|
// ghi rồi bỏ qua ⇒ thao tác mới lặng lẽ không để lại vết.
|
||||||
|
//
|
||||||
|
// SỰ THẬT: changelog ghi TAY, 2 đường:
|
||||||
|
// (1) `Infrastructure/Services/ChangelogService.cs` (5 site — transition/comment/attachment…)
|
||||||
|
// (2) `Add(new ContractChangelog{…})` thẳng trong handler — 3 site:
|
||||||
|
// · `ContractSigningPlans/CreateContractFromSigningPlanFeatures.cs:247` (Insert, bridge KHKK→HĐ)
|
||||||
|
// · `PurchaseEvaluations/CreateContractFromEvaluationFeatures.cs:121` (Insert, bridge PE→HĐ)
|
||||||
|
// · `Contracts/ContractFeatures.cs` khối DELETE (Delete, S190 W5)
|
||||||
|
// Thêm thao tác mới mà muốn có vết thì phải TỰ ghi — không có ai ghi hộ.
|
||||||
public class ContractChangelog : BaseEntity
|
public class ContractChangelog : BaseEntity
|
||||||
{
|
{
|
||||||
public Guid ContractId { get; set; }
|
public Guid ContractId { get; set; }
|
||||||
|
|||||||
@ -0,0 +1,500 @@
|
|||||||
|
using FluentValidation; // IValidator<T> — kiểu tham số của ValidationBehavior
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Behaviors; // ValidationBehavior — đường mà 2001 ký tự đi qua trong prod
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
// Cùng alias với chính `ValidationBehavior.cs`: `ValidationException` tồn tại ở CẢ FluentValidation
|
||||||
|
// lẫn Application.Common.Exceptions. T-12 phải bắt ĐÚNG cái của app (cái map ra HTTP 400).
|
||||||
|
using ValidationException = SolutionErp.Application.Common.Exceptions.ValidationException;
|
||||||
|
using SolutionErp.Application.Common.Interfaces; // ICurrentUser — bơm cho AuditingInterceptor
|
||||||
|
using SolutionErp.Application.Contracts;
|
||||||
|
using SolutionErp.Domain.Contracts;
|
||||||
|
using SolutionErp.Domain.Identity; // AppRoles
|
||||||
|
using SolutionErp.Infrastructure.Persistence;
|
||||||
|
using SolutionErp.Infrastructure.Persistence.Interceptors;
|
||||||
|
using SolutionErp.Infrastructure.Tests.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||||
|
|
||||||
|
// [W5a YC-029 — S190 2026-08-12] Đường XOÁ Hợp đồng đồng-khuôn KHKK — **TEST-BEFORE**.
|
||||||
|
//
|
||||||
|
// Nguồn ca DUY NHẤT: `runs/2026-08-12-S190-soltuon-dieu-chinh-hd/style-gate-v2.md` §C T-MATRIX
|
||||||
|
// (13 ca T-1..T-13). Chi tiết bẫy per-ca: `sub-b5-plan-final.md` §W5 việc 1.
|
||||||
|
//
|
||||||
|
// ── LOẠI ĐỎ MONG ĐỢI (đọc trước khi hoảng) ───────────────────────────────────────
|
||||||
|
// File này viết theo HÀNH VI ĐÍCH sau W5, trong khi mã production hiện tại
|
||||||
|
// (`ContractFeatures.cs:885-902`) mới chỉ có:
|
||||||
|
// record DeleteContractCommand(Guid Id) // CHƯA có Reason
|
||||||
|
// DeleteContractCommandHandler(IApplicationDbContext db) // CHƯA nhận ICurrentUser
|
||||||
|
// (KHÔNG có DeleteContractCommandValidator)
|
||||||
|
// ⇒ đỏ ở đây là **ĐỎ COMPILE**, không phải đỏ assert. Đúng bản chất test-before: hợp đồng API
|
||||||
|
// của handler được CHỐT bằng test trước khi ai gõ dòng production nào. BE worker làm xanh bằng
|
||||||
|
// cách nở đúng 3 thứ trên — **KHÔNG được sửa test cho vừa mã** (Đ-3).
|
||||||
|
//
|
||||||
|
// 🔴 3 TÊN BỊ PIN bởi file này (BE phải dùng ĐÚNG, sai tên = vẫn đỏ compile):
|
||||||
|
// (1) `DeleteContractCommand(Guid Id, string? Reason = null)`
|
||||||
|
// (2) `DeleteContractCommandHandler(IApplicationDbContext db, ICurrentUser currentUser)`
|
||||||
|
// (3) `DeleteContractCommandValidator : AbstractValidator<DeleteContractCommand>`
|
||||||
|
// — khuôn `DeleteContractSigningPlanCommandValidator` (`ContractSigningPlanFeatures.cs:1743`).
|
||||||
|
//
|
||||||
|
// ── 2 quyết định hạ tầng, cả hai là ĐIỀU KIỆN để phép đo có nghĩa ────────────────
|
||||||
|
//
|
||||||
|
// (1) Fixture RIÊNG có `AuditingInterceptor` (KHÔNG dùng `SqliteDbFixture`). `TestApplicationDbContext`
|
||||||
|
// mặc định KHÔNG wire interceptor ⇒ `Remove()` là xoá **CỨNG** (bài S155). Đo "xoá mềm +
|
||||||
|
// DeletedBy + row còn trong DB" trên xoá-cứng là đo NHẦM cơ chế: mọi assert dạng
|
||||||
|
// `IgnoreQueryFilters().Single(...)` sẽ nổ vì row bốc hơi, còn assert `Count()==0` thì XANH
|
||||||
|
// kể cả khi soft-delete chưa từng tồn tại. Nếp fixture private-per-file theo họ Contract
|
||||||
|
// (`ContractSigningPlanCrudTests.cs:50` `KhkkFixture` · `ContractCatalogTests.cs:83`).
|
||||||
|
//
|
||||||
|
// (2) MỖI lệnh chạy trên MỘT DbContext MỚI (`NewDb()`), dùng chung 1 connection — mô phỏng
|
||||||
|
// scoped-per-request của prod. Tái dùng 1 context cho cả seed lẫn lệnh thì các con
|
||||||
|
// (Changelogs) đang được TRACK sẽ bị EF cascade client-side ngay tại `Remove()` — đường đi
|
||||||
|
// KHÔNG xảy ra trong prod (ở đó context mới chỉ nạp mỗi header). Chính sai lệch này đẻ ra
|
||||||
|
// bug T26 (S155) mà T-1 dưới đây canh.
|
||||||
|
//
|
||||||
|
// ── Ghi chú SPEC vs CODE ─────────────────────────────────────────────────────────
|
||||||
|
// Allow-list phase của HĐ **RỘNG HƠN** KHKK: `Phase < DangInKy(5)` ∪ {TraLai(98), TuChoi(99)}
|
||||||
|
// — tức gồm cả legacy 1/3/4, KHÔNG phải bộ 3 {DangSoanThao, TuChoi, TraLai} của KHKK. T-4 là ca
|
||||||
|
// canh đúng chỗ này (chép nguyên hình pattern KHKK = 409 oan cho HĐ legacy).
|
||||||
|
// T-14 (phản-ca) CỐ Ý KHÔNG có mặt: không viết test reflection-authz cho DELETE — chiều khẳng
|
||||||
|
// định sai với shape hiện tại (`[Authorize]` trần), chiều phủ định PIN LỖ và sẽ đỏ oan khi W8 mở.
|
||||||
|
public class ContractDeleteGuardTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime Clock2026 = new(2026, 8, 12, 3, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// Fixture: SQLite in-memory + AuditingInterceptor (xoá mềm THẬT).
|
||||||
|
// ===================================================================
|
||||||
|
private sealed class ContractDeleteFixture : IDisposable
|
||||||
|
{
|
||||||
|
private readonly SqliteConnection _conn;
|
||||||
|
public FixedDateTime Clock { get; } = new(Clock2026);
|
||||||
|
|
||||||
|
/// Người soạn HĐ mặc định (chủ phiếu) — đổi actor giữa chừng để đóng vai người khác.
|
||||||
|
public TestCurrentUser Owner { get; } = new(Guid.NewGuid(), "Nguyễn Văn Soạn", "soan@test.local");
|
||||||
|
|
||||||
|
public ContractDeleteFixture()
|
||||||
|
{
|
||||||
|
_conn = new SqliteConnection("DataSource=:memory:");
|
||||||
|
_conn.Open();
|
||||||
|
using var boot = NewDb();
|
||||||
|
boot.Database.EnsureCreated();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Context MỚI cho mỗi "request" (xem ghi chú hạ tầng (2)).
|
||||||
|
/// 🔴 `actor` phải là ĐÚNG cái `ICurrentUser` mà handler nhận: trong prod cả interceptor
|
||||||
|
/// lẫn handler đều resolve CÙNG một `ICurrentUser` scoped. Để interceptor dính cứng một
|
||||||
|
/// người còn handler chạy bằng người khác thì `DeletedBy` đo ra người sai — và sai theo
|
||||||
|
/// hướng TRÔNG NHƯ ĐÚNG (vẫn là một Guid user thật).
|
||||||
|
public TestApplicationDbContext NewDb(ICurrentUser? actor = null)
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseSqlite(_conn)
|
||||||
|
.EnableSensitiveDataLogging()
|
||||||
|
.AddInterceptors(new AuditingInterceptor(actor ?? Owner, Clock))
|
||||||
|
.Options;
|
||||||
|
return new TestApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _conn.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// Seed: 1 HĐ + 1 dòng nhật ký CÓ SẴN.
|
||||||
|
//
|
||||||
|
// `Contract.SupplierId/ProjectId` là Guid LOOSE (không FK vật lý —
|
||||||
|
// `ContractConfiguration.cs:29-31` chỉ có index) ⇒ không cần seed master.
|
||||||
|
//
|
||||||
|
// Dòng nhật ký `Insert` có sẵn KHÔNG phải trang trí: nó biến assert changelog thành phép đo
|
||||||
|
// PHÂN BIỆT — "đúng 1 dòng Delete" trên nền đã có 1 dòng khác chứng minh test đọc theo
|
||||||
|
// Action chứ không đếm bừa, đồng thời canh vết cũ không bị lượt xoá cuốn theo.
|
||||||
|
// ===================================================================
|
||||||
|
private static async Task<Guid> SeedContractAsync(
|
||||||
|
ContractDeleteFixture f,
|
||||||
|
ContractPhase phase,
|
||||||
|
Guid? drafterUserId,
|
||||||
|
string? maHopDong = null)
|
||||||
|
{
|
||||||
|
await using var db = f.NewDb();
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
|
||||||
|
db.Contracts.Add(new Contract
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Type = ContractType.HopDongThauPhu,
|
||||||
|
Phase = phase,
|
||||||
|
SupplierId = Guid.NewGuid(),
|
||||||
|
ProjectId = Guid.NewGuid(),
|
||||||
|
DrafterUserId = drafterUserId,
|
||||||
|
GiaTri = 1_500_000_000m,
|
||||||
|
TenHopDong = "HĐ thầu phụ phần thô",
|
||||||
|
MaHopDong = maHopDong,
|
||||||
|
});
|
||||||
|
|
||||||
|
db.ContractChangelogs.Add(new ContractChangelog
|
||||||
|
{
|
||||||
|
ContractId = id,
|
||||||
|
EntityType = ChangelogEntityType.Contract,
|
||||||
|
Action = ChangelogAction.Insert,
|
||||||
|
PhaseAtChange = ContractPhase.DangSoanThao,
|
||||||
|
UserId = drafterUserId,
|
||||||
|
UserName = "Nguyễn Văn Soạn",
|
||||||
|
Summary = "Tạo hợp đồng",
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chạy lệnh xoá qua handler THẬT, 1 context mới, interceptor + handler CÙNG actor (như prod).
|
||||||
|
private static async Task DeleteAsync(
|
||||||
|
ContractDeleteFixture f, Guid contractId, string? reason, TestCurrentUser actor)
|
||||||
|
{
|
||||||
|
await using var db = f.NewDb(actor);
|
||||||
|
var handler = new DeleteContractCommandHandler(db, actor);
|
||||||
|
await handler.Handle(new DeleteContractCommand(contractId, reason), CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TestCurrentUser Stranger()
|
||||||
|
=> new(Guid.NewGuid(), "Người lạ", "la@test.local");
|
||||||
|
|
||||||
|
private static TestCurrentUser Admin()
|
||||||
|
=> new(Guid.NewGuid(), "Quản trị", "admin@test.local", AppRoles.Admin);
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// T-1 — ca gốc: 3 phép đo trong 1 lượt, mỗi phép canh một tầng khác nhau.
|
||||||
|
// (a) khuất khỏi query thường → global filter còn hiệu lực
|
||||||
|
// (b) IgnoreQueryFilters → row VẪN NẰM trong DB, IsDeleted/DeletedBy đúng người
|
||||||
|
// (c) changelog đúng 1 dòng → thứ tự Remove-trước/Add-sau (bug T26 S155)
|
||||||
|
// Thiếu (b) thì xoá cứng cũng xanh; thiếu (c) thì lý do xoá bốc hơi im lặng, không lỗi.
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
// T-1: HĐ nháp của chính mình → xoá MỀM + để lại đúng 1 vết nhật ký có lý do.
|
||||||
|
public async Task DeleteContract_DraftByOwner_SoftDeletes_AndWritesOneChangelogRow()
|
||||||
|
{
|
||||||
|
using var f = new ContractDeleteFixture();
|
||||||
|
var id = await SeedContractAsync(f, ContractPhase.DangSoanThao, f.Owner.UserId);
|
||||||
|
|
||||||
|
await DeleteAsync(f, id, "Lập nhầm nhà cung cấp", f.Owner);
|
||||||
|
|
||||||
|
await using var db = f.NewDb();
|
||||||
|
|
||||||
|
// (a) khuất khỏi mọi màn hình thường.
|
||||||
|
db.Contracts.Count(c => c.Id == id)
|
||||||
|
.Should().Be(0, "HĐ đã xoá không được lọt vào danh sách/đếm của màn hình thường");
|
||||||
|
|
||||||
|
// (b) nhưng row vẫn còn để truy — xoá MỀM, không phải bốc hơi.
|
||||||
|
var raw = db.Contracts.IgnoreQueryFilters().AsNoTracking().Single(c => c.Id == id);
|
||||||
|
raw.IsDeleted.Should().BeTrue("xoá mềm — vết phải còn để truy");
|
||||||
|
raw.DeletedBy.Should().Be(f.Owner.UserId, "người xoá thật phải được ghi lại");
|
||||||
|
|
||||||
|
// (c) nhật ký: dòng cũ còn sống + đúng 1 dòng Delete mang đủ ngữ cảnh.
|
||||||
|
var logs = db.ContractChangelogs.AsNoTracking().Where(l => l.ContractId == id).ToList();
|
||||||
|
logs.Should().HaveCount(2, "1 dòng lúc tạo + 1 dòng lúc xoá");
|
||||||
|
|
||||||
|
var del = logs.Single(l => l.Action == ChangelogAction.Delete);
|
||||||
|
del.EntityType.Should().Be(ChangelogEntityType.Contract, "xoá HEADER, không phải con");
|
||||||
|
del.EntityId.Should().BeNull("header thì EntityId để trống (khuôn 2 site ghi tay)");
|
||||||
|
del.PhaseAtChange.Should().Be(ContractPhase.DangSoanThao, "snapshot phase lúc xoá");
|
||||||
|
del.UserId.Should().Be(f.Owner.UserId);
|
||||||
|
del.UserName.Should().Be(f.Owner.FullName);
|
||||||
|
del.ContextNote.Should().Be("Lập nhầm nhà cung cấp",
|
||||||
|
"LÝ DO chỉ sống ở changelog — DeletedBy/DeletedAt chỉ trả lời ai + lúc nào");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// T-2 — `TraLai` (98). Dev 0 row ⇒ seed THẲNG Phase (style-gate §D dữ-liệu đo tay).
|
||||||
|
// Ý nghĩa: phiếu bị trả về tay người soạn thì KHÔNG chiếm lượt duyệt của ai
|
||||||
|
// ⇒ cùng vị thế với nháp. `PhaseAtChange` assert theo phase THẬT (98) để chứng
|
||||||
|
// snapshot đọc từ entity chứ không hardcode DangSoanThao.
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
// T-2: HĐ ở Trả lại (98) → chủ HĐ xoá được.
|
||||||
|
public async Task DeleteContract_TraLai_ByOwner_Succeeds()
|
||||||
|
{
|
||||||
|
using var f = new ContractDeleteFixture();
|
||||||
|
var id = await SeedContractAsync(f, ContractPhase.TraLai, f.Owner.UserId);
|
||||||
|
|
||||||
|
await DeleteAsync(f, id, "Bị trả lại, soạn lại từ đầu", f.Owner);
|
||||||
|
|
||||||
|
await using var db = f.NewDb();
|
||||||
|
db.Contracts.Count(c => c.Id == id).Should().Be(0);
|
||||||
|
db.Contracts.IgnoreQueryFilters().AsNoTracking().Single(c => c.Id == id)
|
||||||
|
.IsDeleted.Should().BeTrue();
|
||||||
|
db.ContractChangelogs.AsNoTracking()
|
||||||
|
.Single(l => l.ContractId == id && l.Action == ChangelogAction.Delete)
|
||||||
|
.PhaseAtChange.Should().Be(ContractPhase.TraLai,
|
||||||
|
"snapshot phải là phase THẬT lúc xoá, không phải hằng số");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// T-3 — `TuChoi` (99). Terminal khoá, nhưng khoá là khoá SỬA, không phải khoá DỌN.
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
// T-3: HĐ ở Từ chối (99) → chủ HĐ xoá được.
|
||||||
|
public async Task DeleteContract_TuChoi_ByOwner_Succeeds()
|
||||||
|
{
|
||||||
|
using var f = new ContractDeleteFixture();
|
||||||
|
var id = await SeedContractAsync(f, ContractPhase.TuChoi, f.Owner.UserId);
|
||||||
|
|
||||||
|
await DeleteAsync(f, id, "Dọn phiếu bị từ chối", f.Owner);
|
||||||
|
|
||||||
|
await using var db = f.NewDb();
|
||||||
|
db.Contracts.Count(c => c.Id == id).Should().Be(0);
|
||||||
|
db.ContractChangelogs.AsNoTracking()
|
||||||
|
.Single(l => l.ContractId == id && l.Action == ChangelogAction.Delete)
|
||||||
|
.PhaseAtChange.Should().Be(ContractPhase.TuChoi);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// T-4 — CHỐNG VÁ-QUÁ-TAY (style-gate be2-R7). Allow-list HĐ là `Phase < DangInKy(5)`, tức
|
||||||
|
// gồm CẢ legacy 1/3/4 — rộng hơn bộ 3 của KHKK. Ai chép nguyên hình
|
||||||
|
// `is not (DangSoanThao or TuChoi or TraLai)` sẽ 409 oan đúng 3 phase này (Dev đang có
|
||||||
|
// HĐ sống ở `DangGopY`). Đo cả 1/3/4 trong 1 Fact vì chúng CÙNG một lớp ca — thêm phase
|
||||||
|
// là thêm điểm đo, không phải thêm ca.
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
// T-4: HĐ legacy phase 1/3/4 (< DangInKy) → chủ HĐ vẫn xoá được.
|
||||||
|
public async Task DeleteContract_LegacyPhaseBelowDangInKy_ByOwner_Succeeds()
|
||||||
|
{
|
||||||
|
using var f = new ContractDeleteFixture();
|
||||||
|
|
||||||
|
foreach (var phase in new[] { ContractPhase.DangChon, ContractPhase.DangGopY, ContractPhase.DangDamPhan })
|
||||||
|
{
|
||||||
|
var id = await SeedContractAsync(f, phase, f.Owner.UserId);
|
||||||
|
|
||||||
|
await DeleteAsync(f, id, $"Dọn HĐ legacy {phase}", f.Owner);
|
||||||
|
|
||||||
|
await using var db = f.NewDb();
|
||||||
|
db.Contracts.Count(c => c.Id == id)
|
||||||
|
.Should().Be(0, $"phase {phase} nằm dưới DangInKy nên vẫn thuộc quyền dọn của người soạn");
|
||||||
|
db.ContractChangelogs.AsNoTracking()
|
||||||
|
.Single(l => l.ContractId == id && l.Action == ChangelogAction.Delete)
|
||||||
|
.PhaseAtChange.Should().Be(phase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// T-5 — `ChoDuyet` (10): CHỦ HĐ cũng không được rút êm. Người đang chờ duyệt mất lượt.
|
||||||
|
// (Đường xoá-khi-đang-duyệt dành cho approver là Q4, KHÔNG thuộc đợt này.)
|
||||||
|
// Vế "còn nguyên" bắt buộc: ném xong mà vẫn ghi thì tệ hơn không rào.
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
// T-5: HĐ đang chờ duyệt + chính chủ → 409 Conflict, HĐ còn nguyên.
|
||||||
|
public async Task DeleteContract_ChoDuyet_ByOwner_ThrowsConflict()
|
||||||
|
{
|
||||||
|
using var f = new ContractDeleteFixture();
|
||||||
|
var id = await SeedContractAsync(f, ContractPhase.ChoDuyet, f.Owner.UserId);
|
||||||
|
|
||||||
|
var act = async () => await DeleteAsync(f, id, null, f.Owner);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ConflictException>(
|
||||||
|
"HĐ đang chờ duyệt thì chủ HĐ cũng không được rút êm");
|
||||||
|
|
||||||
|
await using var db = f.NewDb();
|
||||||
|
db.Contracts.Count(c => c.Id == id)
|
||||||
|
.Should().Be(1, "chặn xong HĐ phải còn NGUYÊN trong danh sách thường");
|
||||||
|
db.ContractChangelogs.AsNoTracking()
|
||||||
|
.Count(l => l.ContractId == id && l.Action == ChangelogAction.Delete)
|
||||||
|
.Should().Be(0, "chặn rồi thì KHÔNG được để lại vết xoá ma");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// T-6 — `DaPhatHanh` (9) + **Admin**: đã gen mã HĐ thì không ai xoá, kể cả Admin.
|
||||||
|
// Actor Admin là cố ý: nó chứng rào phase đứng ĐỘC LẬP với rào chủ-sở-hữu, không phải
|
||||||
|
// hệ quả của việc Admin bị chặn ở tầng khác.
|
||||||
|
// +Biên `DangInKy(5)`: allow-list là `< 5` STRICT ⇒ chính phase 5 phải bị chặn. Không có
|
||||||
|
// điểm đo tại biên thì `<=` và `<` không phân biệt được (bài S69b).
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
// T-6: HĐ đã phát hành (9) + Admin → 409 Conflict; biên DangInKy(5) cũng bị chặn.
|
||||||
|
public async Task DeleteContract_DaPhatHanh_ByAdmin_ThrowsConflict()
|
||||||
|
{
|
||||||
|
using var f = new ContractDeleteFixture();
|
||||||
|
var admin = Admin();
|
||||||
|
|
||||||
|
var released = await SeedContractAsync(
|
||||||
|
f, ContractPhase.DaPhatHanh, f.Owner.UserId, maHopDong: "FLOCK01/HĐTP/SOL&PVL/01");
|
||||||
|
var actReleased = async () => await DeleteAsync(f, released, "dọn dẹp", admin);
|
||||||
|
await actReleased.Should().ThrowAsync<ConflictException>(
|
||||||
|
"HĐ đã phát hành đã mang mã chính thức — Admin cũng không xoá");
|
||||||
|
|
||||||
|
var atBoundary = await SeedContractAsync(f, ContractPhase.DangInKy, f.Owner.UserId);
|
||||||
|
var actBoundary = async () => await DeleteAsync(f, atBoundary, "dọn dẹp", admin);
|
||||||
|
await actBoundary.Should().ThrowAsync<ConflictException>(
|
||||||
|
"biên: allow-list là Phase < DangInKy, nên chính DangInKy phải bị chặn");
|
||||||
|
|
||||||
|
await using var db = f.NewDb();
|
||||||
|
db.Contracts.Count(c => c.Id == released || c.Id == atBoundary)
|
||||||
|
.Should().Be(2, "cả hai HĐ phải còn nguyên sau khi bị chặn");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// T-7 — người lạ (không phải người soạn, không Admin) đụng HĐ NHÁP của người khác.
|
||||||
|
// Đây là lỗ SẴN CÓ đang vá (gotcha #82): trước W5 mọi user đăng nhập đều xoá được
|
||||||
|
// HĐ của người khác vì handler không hề hỏi actor là ai.
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
// T-7: HĐ nháp của người khác + người lạ → 403 Forbidden.
|
||||||
|
public async Task DeleteContract_DraftOfAnotherUser_ByStranger_ThrowsForbidden()
|
||||||
|
{
|
||||||
|
using var f = new ContractDeleteFixture();
|
||||||
|
var id = await SeedContractAsync(f, ContractPhase.DangSoanThao, f.Owner.UserId);
|
||||||
|
|
||||||
|
var act = async () => await DeleteAsync(f, id, null, Stranger());
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ForbiddenException>(
|
||||||
|
"chỉ người soạn hoặc Admin được xoá HĐ này");
|
||||||
|
|
||||||
|
await using var db = f.NewDb();
|
||||||
|
db.Contracts.Count(c => c.Id == id).Should().Be(1, "chặn xong HĐ phải còn NGUYÊN");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// T-8 — VẾ DƯƠNG của `|| isAdmin`. Không có ca này thì T-7/T-5/T-6/T-9/T-10/T-11 vẫn xanh
|
||||||
|
// trong cái thế giới mà handler ném với MỌI đầu vào. `DeletedBy` phải là ADMIN, không
|
||||||
|
// phải chủ HĐ — canh luôn chuyện interceptor và handler chạy cùng một actor.
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
// T-8: HĐ nháp của người khác + Admin → xoá được, DeletedBy ghi đúng Admin.
|
||||||
|
public async Task DeleteContract_DraftOfAnotherUser_ByAdmin_Succeeds()
|
||||||
|
{
|
||||||
|
using var f = new ContractDeleteFixture();
|
||||||
|
var admin = Admin();
|
||||||
|
var id = await SeedContractAsync(f, ContractPhase.DangSoanThao, f.Owner.UserId);
|
||||||
|
|
||||||
|
await DeleteAsync(f, id, "Admin dọn HĐ nháp bỏ quên", admin);
|
||||||
|
|
||||||
|
await using var db = f.NewDb();
|
||||||
|
db.Contracts.Count(c => c.Id == id).Should().Be(0);
|
||||||
|
db.Contracts.IgnoreQueryFilters().AsNoTracking().Single(c => c.Id == id)
|
||||||
|
.DeletedBy.Should().Be(admin.UserId,
|
||||||
|
"người xoá thật là Admin, không phải chủ HĐ");
|
||||||
|
db.ContractChangelogs.AsNoTracking()
|
||||||
|
.Single(l => l.ContractId == id && l.Action == ChangelogAction.Delete)
|
||||||
|
.UserId.Should().Be(admin.UserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// T-9 — NẤC 0 (khuôn `PeSoftDeleteFeatures.cs:56-58`). Khuôn KHKK `:1763` KHÔNG có nấc này:
|
||||||
|
// nó so thẳng `DrafterUserId == currentUser.UserId`, nên HĐ `DrafterUserId = null` gặp
|
||||||
|
// actor chưa đăng nhập ⇒ `null == null` ⇒ isOwner = TRUE ⇒ xoá LỌT.
|
||||||
|
// Ca này là lý do "đừng chép nguyên hình KHKK": nếu BE chép, test không ném gì cả và HĐ
|
||||||
|
// biến mất — chính xác cái ta sợ. Kỳ vọng `UnauthorizedException` (chưa đăng nhập),
|
||||||
|
// KHÔNG phải Forbidden (đã đăng nhập nhưng không đủ quyền) — hai mã HTTP khác nhau.
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
// T-9: HĐ DrafterUserId=null + actor UserId=null → 401 Unauthorized, HĐ còn nguyên.
|
||||||
|
public async Task DeleteContract_NullDrafter_ByAnonymousActor_ThrowsUnauthorized()
|
||||||
|
{
|
||||||
|
using var f = new ContractDeleteFixture();
|
||||||
|
var id = await SeedContractAsync(f, ContractPhase.DangSoanThao, drafterUserId: null);
|
||||||
|
|
||||||
|
var anonymous = TestCurrentUser.System(); // UserId = null ⇒ IsAuthenticated = false
|
||||||
|
var act = async () => await DeleteAsync(f, id, null, anonymous);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<UnauthorizedException>(
|
||||||
|
"chưa đăng nhập thì dừng ở nấc 0 — không được để null == null thành 'chủ sở hữu'");
|
||||||
|
|
||||||
|
await using var db = f.NewDb();
|
||||||
|
db.Contracts.Count(c => c.Id == id)
|
||||||
|
.Should().Be(1, "HĐ mồ côi người soạn KHÔNG được thành HĐ ai cũng xoá");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// T-10 — THỨ TỰ ném: NotFound TRƯỚC Forbidden. Actor cố ý là người lạ để hai khả năng đều
|
||||||
|
// mở; handler kiểm quyền trước khi nạp sẽ trả 403 và làm lộ "id này có tồn tại hay không"
|
||||||
|
// theo chiều ngược (403 = có, 404 = không). Chỉ đúng thứ tự mới cho 404 ở đây.
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
// T-10: id không tồn tại + người lạ → 404 NotFound (không phải 403).
|
||||||
|
public async Task DeleteContract_UnknownId_ByStranger_ThrowsNotFoundBeforeForbidden()
|
||||||
|
{
|
||||||
|
using var f = new ContractDeleteFixture();
|
||||||
|
await SeedContractAsync(f, ContractPhase.DangSoanThao, f.Owner.UserId); // thế giới KHÔNG rỗng
|
||||||
|
|
||||||
|
var act = async () => await DeleteAsync(f, Guid.NewGuid(), null, Stranger());
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<NotFoundException>(
|
||||||
|
"không tìm thấy thì báo không tìm thấy — kiểm quyền trước khi nạp là sai thứ tự");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// T-11 — THỨ TỰ ném: Forbidden TRƯỚC Conflict. Ca này có ĐỦ điều kiện cho cả hai (người lạ
|
||||||
|
// + phase cấm) nên nó phân biệt được thứ tự rào; T-5 (chính chủ + ChoDuyet) và T-7
|
||||||
|
// (người lạ + nháp) mỗi cái chỉ chạm một rào nên KHÔNG phân biệt được.
|
||||||
|
// Lý do nghiệp vụ: người ngoài không đáng được biết HĐ đó đang ở bước nào.
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
// T-11: HĐ chờ duyệt của người khác + người lạ → 403 Forbidden (không phải 409).
|
||||||
|
public async Task DeleteContract_ChoDuyetOfAnotherUser_ByStranger_ThrowsForbiddenBeforeConflict()
|
||||||
|
{
|
||||||
|
using var f = new ContractDeleteFixture();
|
||||||
|
var id = await SeedContractAsync(f, ContractPhase.ChoDuyet, f.Owner.UserId);
|
||||||
|
|
||||||
|
var act = async () => await DeleteAsync(f, id, null, Stranger());
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ForbiddenException>(
|
||||||
|
"rào chủ-sở-hữu đứng trước rào phase — người ngoài không được biết HĐ đang ở bước nào");
|
||||||
|
|
||||||
|
await using var db = f.NewDb();
|
||||||
|
db.Contracts.Count(c => c.Id == id).Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// T-12 — Reason 2001 ký tự. Đo qua `ValidationBehavior` THẬT (đường mà request đi trong
|
||||||
|
// prod) chứ không chỉ `validator.Validate(...)`: nó chứng luôn rằng lỗi nổi lên đúng
|
||||||
|
// `Application.Common.Exceptions.ValidationException` (→ 400) chứ không phải
|
||||||
|
// `DbUpdateException` khi EF cắt ở `HasMaxLength(2000)`
|
||||||
|
// (`ContractChangelogConfiguration.cs:20` — validator PHẢI match EF, không tự chế số).
|
||||||
|
// Control dương ĐÚNG 2000 ký tự: thiếu nó thì một validator ném với mọi Reason cũng xanh.
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
// T-12: Reason 2001 ký tự → ValidationException; đúng 2000 ký tự thì lọt (biên).
|
||||||
|
public async Task DeleteContract_ReasonOver2000Chars_FailsValidation()
|
||||||
|
{
|
||||||
|
var behavior = new ValidationBehavior<DeleteContractCommand, Unit>(
|
||||||
|
new IValidator<DeleteContractCommand>[] { new DeleteContractCommandValidator() });
|
||||||
|
|
||||||
|
var reached = false;
|
||||||
|
RequestHandlerDelegate<Unit> next = () => { reached = true; return Task.FromResult(Unit.Value); };
|
||||||
|
|
||||||
|
var tooLong = new DeleteContractCommand(Guid.NewGuid(), new string('x', 2001));
|
||||||
|
var act = async () => await behavior.Handle(tooLong, next, CancellationToken.None);
|
||||||
|
|
||||||
|
var ex = await act.Should().ThrowAsync<ValidationException>(
|
||||||
|
"2001 ký tự vượt trần 2000 của cột ContextNote — phải chặn ở tầng validate, không để EF nổ");
|
||||||
|
ex.Which.Errors.Should().ContainKey(nameof(DeleteContractCommand.Reason));
|
||||||
|
reached.Should().BeFalse("chặn ở validate thì handler không được chạy");
|
||||||
|
|
||||||
|
// Control dương: đúng trần thì đi tiếp.
|
||||||
|
var atLimit = new DeleteContractCommand(Guid.NewGuid(), new string('x', 2000));
|
||||||
|
await behavior.Handle(atLimit, next, CancellationToken.None);
|
||||||
|
reached.Should().BeTrue("đúng 2000 ký tự là hợp lệ — rào là ĐÚNG BIÊN, không phải cấm tuốt");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// T-13 — Reason toàn khoảng trắng ⇒ vẫn xoá được, nhưng ContextNote lưu NULL (trim-to-null,
|
||||||
|
// khuôn KHKK `:1785`). Không có ca này thì " " chui xuống DB thành một lý do rỗng trông
|
||||||
|
// như có — đọc log tưởng người ta đã ghi lý do.
|
||||||
|
// Viết RIÊNG một `[Fact]` theo X-9 (gộp vào T-1 làm vỡ ô đếm `[Fact]` ≥ 13).
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
// T-13: Reason toàn khoảng trắng → xoá OK + ContextNote lưu null.
|
||||||
|
public async Task DeleteContract_WhitespaceReason_Succeeds_AndStoresNullContextNote()
|
||||||
|
{
|
||||||
|
using var f = new ContractDeleteFixture();
|
||||||
|
var id = await SeedContractAsync(f, ContractPhase.DangSoanThao, f.Owner.UserId);
|
||||||
|
|
||||||
|
await DeleteAsync(f, id, " ", f.Owner);
|
||||||
|
|
||||||
|
await using var db = f.NewDb();
|
||||||
|
db.Contracts.Count(c => c.Id == id).Should().Be(0, "khoảng trắng không phải lý do để chặn xoá");
|
||||||
|
|
||||||
|
var del = db.ContractChangelogs.AsNoTracking()
|
||||||
|
.Single(l => l.ContractId == id && l.Action == ChangelogAction.Delete);
|
||||||
|
del.ContextNote.Should().BeNull(
|
||||||
|
"trim-to-null: chuỗi trắng phải thành NULL, đừng để log trông như có lý do");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user