diff --git a/docs/STATUS.md b/docs/STATUS.md index 8cee79f1..3bb8927e 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -3,6 +3,7 @@ > **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.) +> 🔄 **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`). > ✅ **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`.** diff --git a/fe-admin/src/components/contracts/ContractDetailContent.tsx b/fe-admin/src/components/contracts/ContractDetailContent.tsx index 42009bd1..ecb514aa 100644 --- a/fe-admin/src/components/contracts/ContractDetailContent.tsx +++ b/fe-admin/src/components/contracts/ContractDetailContent.tsx @@ -2,10 +2,11 @@ // in MyContractsPage 3-panel layout (Panel 2). Renders header (title + phase // + actions) + Info + Comments + Attachments + transition Dialog. Workflow + // approval history live separately in WorkflowHistoryPanel (Panel 3). +// File MIRROR SHA256 identical với fe-admin counterpart. import { useState, type FormEvent } from 'react' import { useMutation, useQueryClient } from '@tanstack/react-query' 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 { PhaseBadge } from '@/components/PhaseBadge' import { SlaTimer } from '@/components/SlaTimer' @@ -18,6 +19,7 @@ import { Button } from '@/components/ui/Button' import { Select } from '@/components/ui/Select' import { Textarea } from '@/components/ui/Textarea' import { Dialog } from '@/components/ui/Dialog' +import { useAuth } from '@/contexts/AuthContext' import { api } from '@/lib/api' import { getErrorMessage } from '@/lib/apiError' import { @@ -38,12 +40,21 @@ const fmtMoney = (v: number) => v.toLocaleString('vi-VN') + ' VND' export function ContractDetailContent({ contract: c, onBack, + onDeleted, }: { contract: ContractDetail /** Optional back handler — shown as arrow button next to title. Pass `navigate(-1)` for fullpage; omit for embedded panel. */ 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 { user: currentUser } = useAuth() const [actionOpen, setActionOpen] = useState(false) const [targetPhase, setTargetPhase] = useState(0) const [decision, setDecision] = useState(ApprovalDecision.Approve) @@ -77,6 +88,44 @@ export function ContractDetailContent({ 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 ?? [] // ===== [W6 S187 · YC-023 GĐ3] DUAL-RENDER V2-trạm ⟂ V1-legacy ========================== @@ -397,6 +446,37 @@ export function ContractDetailContent({ + + {/* [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 && ( +
+ + + + Xoá mềm — dữ liệu không mất khỏi hệ thống. + +
+ )} ) } diff --git a/fe-user/src/components/contracts/ContractDetailContent.tsx b/fe-user/src/components/contracts/ContractDetailContent.tsx index 42009bd1..ecb514aa 100644 --- a/fe-user/src/components/contracts/ContractDetailContent.tsx +++ b/fe-user/src/components/contracts/ContractDetailContent.tsx @@ -2,10 +2,11 @@ // in MyContractsPage 3-panel layout (Panel 2). Renders header (title + phase // + actions) + Info + Comments + Attachments + transition Dialog. Workflow + // approval history live separately in WorkflowHistoryPanel (Panel 3). +// File MIRROR SHA256 identical với fe-admin counterpart. import { useState, type FormEvent } from 'react' import { useMutation, useQueryClient } from '@tanstack/react-query' 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 { PhaseBadge } from '@/components/PhaseBadge' import { SlaTimer } from '@/components/SlaTimer' @@ -18,6 +19,7 @@ import { Button } from '@/components/ui/Button' import { Select } from '@/components/ui/Select' import { Textarea } from '@/components/ui/Textarea' import { Dialog } from '@/components/ui/Dialog' +import { useAuth } from '@/contexts/AuthContext' import { api } from '@/lib/api' import { getErrorMessage } from '@/lib/apiError' import { @@ -38,12 +40,21 @@ const fmtMoney = (v: number) => v.toLocaleString('vi-VN') + ' VND' export function ContractDetailContent({ contract: c, onBack, + onDeleted, }: { contract: ContractDetail /** Optional back handler — shown as arrow button next to title. Pass `navigate(-1)` for fullpage; omit for embedded panel. */ 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 { user: currentUser } = useAuth() const [actionOpen, setActionOpen] = useState(false) const [targetPhase, setTargetPhase] = useState(0) const [decision, setDecision] = useState(ApprovalDecision.Approve) @@ -77,6 +88,44 @@ export function ContractDetailContent({ 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 ?? [] // ===== [W6 S187 · YC-023 GĐ3] DUAL-RENDER V2-trạm ⟂ V1-legacy ========================== @@ -397,6 +446,37 @@ export function ContractDetailContent({ + + {/* [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 && ( +
+ + + + Xoá mềm — dữ liệu không mất khỏi hệ thống. + +
+ )} ) } diff --git a/src/Backend/SolutionErp.Api/Controllers/ContractsController.cs b/src/Backend/SolutionErp.Api/Controllers/ContractsController.cs index 01f8e38c..38cdef2c 100644 --- a/src/Backend/SolutionErp.Api/Controllers/ContractsController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/ContractsController.cs @@ -75,10 +75,22 @@ public class ContractsController(IMediator mediator) : ControllerBase 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}")] - public async Task Delete(Guid id, CancellationToken ct) + public async Task 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(); } diff --git a/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs b/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs index dac38d3b..ee3be9e2 100644 --- a/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs +++ b/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs @@ -883,20 +883,107 @@ public class GetContractQueryHandler( } // ========== 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; +/// Lý do xoá (tuỳ chọn) — chảy vào `ContractChangelog.ContextNote`. +public record DeleteContractCommand(Guid Id, string? Reason = null) : IRequest; -public class DeleteContractCommandHandler(IApplicationDbContext db) : IRequestHandler +public class DeleteContractCommandValidator : AbstractValidator +{ + 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 { 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) ?? throw new NotFoundException("Contract", request.Id); - if (entity.Phase >= ContractPhase.DangInKy) - throw new ConflictException("Không được xóa HĐ đã qua phase 'Đang in ký'."); + // (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 + // 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.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); } } diff --git a/src/Backend/SolutionErp.Domain/Contracts/ContractChangelog.cs b/src/Backend/SolutionErp.Domain/Contracts/ContractChangelog.cs index 2cf3460f..c5336580 100644 --- a/src/Backend/SolutionErp.Domain/Contracts/ContractChangelog.cs +++ b/src/Backend/SolutionErp.Domain/Contracts/ContractChangelog.cs @@ -7,9 +7,19 @@ namespace SolutionErp.Domain.Contracts; // log workflow transitions, dùng cho guard logic) — Changelog là VIEW LAYER // cho user đọc lịch sử thao tác. // -// Populate qua MediatR `AuditBehavior` interceptor — auto từ Insert/Update/ -// Delete commands trên Contract + Details (xem Application/Common/Behaviors/ -// AuditBehavior.cs). +// 🔴 [S190 W5] ĐÍNH CHÍNH — câu cũ ở đây ghi "populate qua MediatR `AuditBehavior` interceptor +// (xem Application/Common/Behaviors/AuditBehavior.cs)". **File đó CHƯA BAO GIỜ tồn tại** +// (`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 Guid ContractId { get; set; } diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/ContractDeleteGuardTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/ContractDeleteGuardTests.cs new file mode 100644 index 00000000..0349ae93 --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/ContractDeleteGuardTests.cs @@ -0,0 +1,500 @@ +using FluentValidation; // IValidator — 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` +// — 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() + .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 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( + "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( + "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( + "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( + "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( + "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( + "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( + "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( + new IValidator[] { new DeleteContractCommandValidator() }); + + var reached = false; + RequestHandlerDelegate 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( + "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"); + } +}