[CLAUDE] Contract: K2 SP-002 phieu KHKK mang hang-muc + nhom duyet N1-8 (Mig 71) + FE x2 app + 11 test
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m12s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m12s
Mig 71 AddKhkkApprovalGroupCatalogAndFinalizeRuntime: +ApprovalGroup (backfill=1)
+EndedByLevelFinalize (K2 de, K3 tieu - ghost-window khai) + Line +CatalogEntryId/
TenHangMuc + unique (Plan,Supplier,CatalogEntry) filtered IsDeleted=0. Rao
1-PE-1-phieu doi (PeId,ApprovalGroup) 2 site + picker OG-7 ExistingGroups + nut
'Tao them cho nhom khac'. Submit-guard va-5 (CHAN null, thang nen cho-qua-legacy;
ghost-window K2->K4b khai) + dem-so-khop (gate F-1: bit duong hang-muc xoa-mem).
List ?group= end-to-end + Tong-Approved list (va-8). FE: select nhom default 1 +
badge N{g} + cot Hang-muc + cot Tong-duyet, SHA-pair 5/5. Gate K2 PASS-WITH-FLAGS
10f: F-1 guard fix + 2 test bit (xoa-mem chan + reflection ?group=); F-2/F-5/F-7/
F-8/F-9 comment-honest; F-3/F-4 docs-delta. Suite 609/0 (45D+564I). Wave co #53 x5,
artifact + relaunch-cat-got cuu 100% (0 mat cong).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -87,8 +87,10 @@ when-to-use:
|
||||
| **65** | **`BackfillPeQuoteIsSelectedAndWinnerInvariant`** | **🎯 S114 — DATA-ONLY no-schema (multi-NCC per hạng mục anh Kiệt): backfill **2-CHIỀU** thiết lập bất-biến `Supplier.IsWinner == Any(Quote.IsSelected)`. CHIỀU-1 `IsWinner→IsSelected` (phiếu cũ whole-supplier winner Mig58 → set IsSelected mọi quote NCC, **preserve tổng** winnerQuoteTotal) + CHIỀU-2 `IsSelected→IsWinner` (dormant/seed EXISTS). Idempotent (`WHERE ...=0`), SQL-Server-only (SQLite EnsureCreated bỏ qua migration), Down **no-op** (data backfill không reversible, mirror Mig 61). **No new table (89 giữ)** — snapshot UNCHANGED (0 CreateColumn). Prod-verified bundle rotate ×2 `D51OYyGV`/`BVdssm5S`; commit `d436af2`. Kèm **gotcha #81** (delete-writer sót re-derive IsWinner → phantom HĐ giaTri=0 — verify-bắt).** |
|
||||
| **66** | **`AddPeApprovedPriceRange`** | **🎯 S117 — PE +`ApprovedPriceMaxAmount decimal(18,2)?` (feature "duyệt theo khoảng Min–Max" anh Kiệt FDC "chọn cả 2 min và max"): source `"ProMinMax"` = Amount(Min)+MaxAmount(Max), bất-biến `MaxAmount != null ⟺ Source=="ProMinMax"` + Min≤Max (guard service `ApplyApprovedPriceOnFinalize` + FluentValidation). 1 AddColumn nullable, **no backfill** (phiếu cũ Source-đơn Max=null nguyên vẹn), **no new table (89 giữ)**, reversible Down (DropColumn). HĐ.GiaTri KHÔNG đọc giá chốt (=SUM báo giá NCC được chọn) → 0 tác động HĐ (range = record-of-decision hiển thị). Prod-verified Run #387 (`ApprovedPriceMaxAmount` col decimal(18,2) + `__EFMigrationsHistory` top); commit `316a82f`.** |
|
||||
| **67** | **`AddPeApprovedBudgetSnapshot`** | **🎯 S133 — PE freeze ngân sách phiếu DaDuyet (UAT anh Kiệt: cập nhật NS gói thầu làm phiếu ĐÃ DUYỆT đổi số theo): snapshot **11 cột** AddColumn, chốt tại finalize choke-point + site-5 admin-override + backfill `Sql()` OUTER APPLY 18/18 phiếu prod; **no new table (89 giữ)**; display-gate `budgetFrozen` — phiếu DaDuyet đọc snapshot 🔒 badge, phiếu chưa duyệt đọc live "bản cập nhật gần nhất"; reviewer bắt **#81-EXT indirect-assignment** (2 site gán Phase thẳng bypass choke-point → FIXED + test site-5); test 509→520 (+11 `PeBudgetFreezeTests`). Prod-verified Run #398 (mig top `20260717032812` + backfill 18/18); commit `d4fbced`.** *(row bổ sung @S151 — ring1 TRƯỢT-1 bắt bảng dừng ở 66 trong khi canonical Mig 67)* |
|
||||
| **68** | **`AddPeAllowApproverDelete`** | **🎯 S157 — PE nút XÓA phiếu ở màn duyệt: +`AllowApproverDelete bit` trên **`ApprovalWorkflowLevels`** (config PER-CẤP-DUYỆT, KHÔNG phải cột trên PE — admin bật cấp nào được xóa phiếu ngay màn duyệt). 1 AddColumn, **no new table (89 giữ)**, reversible Down (DropColumn). Ship 2 đợt `b1bae77`+`4464a46`+`f4494cf`, cicd PASS, test 532→562; byte-verify marker `allowApproverDelete`/`by-approver`/`deleted=1` (S156).** |
|
||||
| **69** | **`AddContractSigningPlans`** | **🎯 S161 — module MỚI Kế hoạch Ký kết HĐ (KHKK): **7 `CreateTable`** `ContractSigningPlans` + `~Approvals` + `~Attachments` + `~Changelogs` + `~DossierItems` + `~LevelOpinions` + `~Lines` — **89→96 bảng**, mirror khuôn PE/Contract V2 (Approvals/Changelogs/LevelOpinions tách vai). Menu-key `KeHoachKyKet` sinh **policy** KHÔNG sinh menu-row; 15 policy CRUD+Dossier (W2); duyệt V2 3-trạm (W3); 🔴 gotcha **#85** gate FE dùng đúng key policy endpoint, KHÔNG OR key con. Commit `a8027c0` (W1/5 wave S161).** |
|
||||
|
||||
Total: **89 bảng** `sys.tables` (= 88 dbo-app + `__EFMigrationsHistory`; re-ground S97 cicd Run #482 LIVE — số canonical → `docs/STATUS.md`; last schema-changing Mig 50 net 93→88-app, Mig 51-66 đều AddColumn/index/data-only/schema-expand không đổi số bảng). Xem `docs/database/schema-diagram.md` migration table + §11-15 module ERD (§16+ Mig 27-66 chi tiết pending).
|
||||
Total: **97 bảng** `sys.tables` (= 96 dbo-app + `__EFMigrationsHistory`; số canonical → `docs/STATUS.md`; last schema-changing **Mig 70 `AddContractCatalogEntries` +1 bảng 96→97 (S164/S165 K1 SP-002, commit `50e6d8c`)** — Mig 71 `AddKhkkApprovalGroupCatalogAndFinalizeRuntime` (K2) = AddColumn ×4 + index-swap unique 3-cột + backfill, 0 CreateTable; trước đó Mig 69 +7 bảng KHKK 89→96, Mig 50 net 93→88-app, Mig 51-68 đều AddColumn/index/data-only không đổi số bảng). Xem `docs/database/schema-diagram.md` migration table + §11-15 module ERD (§16+ Mig 27-66 chi tiết pending).
|
||||
|
||||
## N-stage workflow pattern (Mig 18-20 — Session 12-13)
|
||||
|
||||
|
||||
@ -3,7 +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.)
|
||||
|
||||
**🔥 CURRENT (S161→S162 + S164/S165 2026-07-31 — phiên-LOGIC **L9**: chuỗi SP-002 B0→B3 spec ĐÓNG BĂNG + **B4 K1 danh mục loại HĐ SHIP** gate PASS-WITH-FLAGS; lineage L8 giữ dưới):** Mig **70** (K1 `AddContractCatalogEntries` — bảng danh mục 86 dòng × 8 nhóm duyệt) · **97 bảng** · **598 test PASS** (45D+553I, đo tươi @S165 gate-K1) · gotcha **85** · counter **39** · roster 23 · **MenuItems prod 142 GIỮ** (key `KeHoachKyKet` sinh **policy** không sinh menu-row — H1 bác nghi-vấn stale) · Policies **224** · Menu keys **56** (+`ContractCatalog` K1 — 4 policy sinh ra, giới-hạn khai: chưa endpoint nào tiêu thụ, controller mirror khuôn Catalogs `Roles=Admin` #82) · bundle prod → **canonical ở `:479`, KHÔNG chép số ở đây** (B1). 🔴 *Cặp `DXoYDy7R`/`o8KDluD1` từng ghi ở dòng này **chưa bao giờ là trạng-thái cuối** — `e5123ff` ship thêm 6 file FE-src SAU snapshot; đo live @S164: admin `B43R6Y17` · user `NLI5umBg`.* Contracts prod **0**.
|
||||
**🔥 CURRENT (S161→S162 + S164/S165 2026-07-31 — phiên-LOGIC **L9**: chuỗi SP-002 B0→B3 spec ĐÓNG BĂNG + **B4 K1 danh mục loại HĐ SHIP** gate PASS-WITH-FLAGS; lineage L8 giữ dưới):** Mig **71** (K1 Mig70 `AddContractCatalogEntries` danh mục 86 dòng + K2 Mig71 `AddKhkkApprovalGroupCatalogAndFinalizeRuntime` — phiếu KHKK +nhóm duyệt N1-N8 backfill=1 + Line +hạng-mục + unique 3-cột) · **97 bảng** · **607 test PASS** (45D+562I, đo tươi @S165 gate-K2) · gotcha **85** · counter **39** · roster 23 · **MenuItems prod 142 GIỮ** (key `KeHoachKyKet` sinh **policy** không sinh menu-row — H1 bác nghi-vấn stale) · Policies **224** · Menu keys **56** (+`ContractCatalog` K1 — 4 policy sinh ra, giới-hạn khai: chưa endpoint nào tiêu thụ, controller mirror khuôn Catalogs `Roles=Admin` #82) · bundle prod → **canonical ở `:479`, KHÔNG chép số ở đây** (B1). 🔴 *Cặp `DXoYDy7R`/`o8KDluD1` từng ghi ở dòng này **chưa bao giờ là trạng-thái cuối** — `e5123ff` ship thêm 6 file FE-src SAU snapshot; đo live @S164: admin `B43R6Y17` · user `NLI5umBg`.* Contracts prod **0**.
|
||||
① **5 wave KHKK SHIP (window-3, S161):** W1 `a8027c0` (Mig 69, 7 bảng, 89→96) · W2 `6cbc6ad` (CRUD+Dossier 15 policy) · W3 `df52fa0` (duyệt V2 3-trạm, test-before RED→GREEN) · W6 `761d818` (đường-ống HĐ V2) · W7 `3119cb9` (bản-cứng **1-MỐC** theo owner: form điền đủ → duyệt nếu có → upload bản cứng = xong). Mỗi wave: gate 5-anchor → hmw → test → reviewer → F-0 → cicd 3-chân. 3 quyết owner: mã `KHKK/{YYYY}/{Seq:D3}` · **bỏ "MB"** · GĐ4 1-mốc.
|
||||
② **Cây 4-folder GĐ (window-4, S162) — `bfc7b79`, cicd PASS 5/5 Run #432:** lệnh owner *"Tạo folder: Duyệt NCC / Kế hoạch Hợp đồng. / Duyệt Hợp đồng / Hợp đồng cứng."* + AskUser chốt **DƯỚI TỪNG GÓI THẦU** + **áp CẢ trang GĐ khác**. 3 file MỚI ×2 app (`PipelineStageFolders` · `PipelineTreePanel` · `usePipelineStages`) + 3 trang ×2 app, **SHA-pair 6/6**, thuần FE 0-mig. designer SHIP-READY (tự soi ảnh bắt 4 lỗi build-không-báo) → reviewer PASS-WITH-FLAGS 8 → lead vá F-1/3/4/6/7 + F-8. 🔴 **CÒN NỢ: trang GĐ3 chưa làm** (owner nói "CẢ", lead giao 3/4 — lead-gap FLAG-3) · F-2 liên-danh N-HĐ chờ W5 · F-5 layout 19rem chờ owner nghiệm thu prod.
|
||||
③ **Bookend-close 5 vòng, 10 vai:** H1 `PASS_WITH_FLAGS` 10 finding · H2 **`GATE-FAIL` 4** (⭐ `ctx-verifier` chạy thật mà **chưa bao giờ tự APPEND** diary — S153+S158 đều lead-seed; `cicd-monitor` khai "verbatim→archive" thứ `git log -S` chứng **chưa từng vào archive**) · cặp H24 **20 FLAG** (stale 16 = **SÀN** + gap 4, `gap-carry-dropped` **streak 8**) · trio memory **MIXED** (headroom 165.767 tok đơn-điệu giảm 3 mốc · light 9/6 OVERDUE · baseline `measured` 17/23) · orphan run-folder **3 xác nhận** (2 thiếu THẬT + 1 dương-giả TÊN).
|
||||
|
||||
@ -2,14 +2,20 @@
|
||||
// Khuôn `pages/office/ProposalCreatePage.tsx` (PURO: ui/PageHeader + section
|
||||
// .card-accent + .icon-chip header). File MIRROR SHA256 identical với fe-admin.
|
||||
//
|
||||
// 3 section: (1) chọn phiếu PE nguồn — chỉ phiếu DaDuyet CHƯA có kế hoạch sống
|
||||
// (BE lọc, FE không tự đoán); (2) quy trình duyệt V2 ApplicableType=10 admin đã
|
||||
// ghim; (3) ghi chú. Dòng giá per-NCC do BE tự sinh từ winner của phiếu PE
|
||||
// 3 section: (1) chọn phiếu PE nguồn — phiếu DaDuyet có NCC trúng thầu (BE lọc, FE
|
||||
// không tự đoán); (2) NHÓM DUYỆT N1..N8 + quy trình duyệt V2 ApplicableType=10 admin
|
||||
// đã ghim; (3) ghi chú. Dòng giá per-NCC do BE tự sinh từ winner của phiếu PE
|
||||
// (snapshot `PeReferenceAmount`) — FE KHÔNG gửi lines.
|
||||
//
|
||||
// [K2 S164 — OG-7 UI-hướng-1] ĐỔI HÌNH DẠNG màn này: trước K2, PE đã có kế hoạch bị
|
||||
// BE loại khỏi picker (biến mất) và tạo trùng thì ăn 409 chặn hẳn. Từ K2, rào là
|
||||
// "1 PE = 1 kế hoạch MỖI NHÓM" ⇒ PE vẫn hiện, kèm danh sách nhóm đã dùng + nút phụ
|
||||
// "Tạo thêm cho nhóm khác" (preset sang nhóm còn trống). Ẩn PE đi thì người dùng
|
||||
// không phân biệt được "PE không đủ điều kiện" với "PE đã dùng hết nhóm".
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { CheckCircle2, FileCheck, FileText, Save, Settings2, X } from 'lucide-react'
|
||||
import { CheckCircle2, FileCheck, FileText, Layers, Plus, Save, Settings2, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { PageHeader } from '@/components/ui/PageHeader'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
@ -18,7 +24,13 @@ import { Textarea } from '@/components/ui/Textarea'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import type { CreateKhkkInput, KhkkPeCandidateDto } from '@/types/khkk'
|
||||
import {
|
||||
KHKK_APPROVAL_GROUPS,
|
||||
KHKK_APPROVAL_GROUP_DEFAULT,
|
||||
KHKK_APPROVAL_GROUP_LABELS,
|
||||
type CreateKhkkInput,
|
||||
type KhkkPeCandidateDto,
|
||||
} from '@/types/khkk'
|
||||
|
||||
// ApplicableType của workflow V2 dành cho Kế hoạch ký kết (mirror BE
|
||||
// `ApplicableType.ContractSigningPlan = 10`, Designer fe-admin đã mở từ W1).
|
||||
@ -36,14 +48,23 @@ function formatDate(iso: string): string {
|
||||
return d.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
// Nhóm trống ĐẦU TIÊN của một PE (null = đã dùng hết 8 nhóm). Dùng cả cho nút
|
||||
// "Tạo thêm cho nhóm khác" lẫn lúc bấm chọn PE — để ô chọn nhóm không đứng yên ở
|
||||
// một nhóm chắc chắn 409.
|
||||
function firstFreeGroup(taken: number[]): number | null {
|
||||
return KHKK_APPROVAL_GROUPS.find((g) => !taken.includes(g)) ?? null
|
||||
}
|
||||
|
||||
export function KhkkCreatePage() {
|
||||
const navigate = useNavigate()
|
||||
const [purchaseEvaluationId, setPurchaseEvaluationId] = useState('')
|
||||
const [approvalWorkflowId, setApprovalWorkflowId] = useState('')
|
||||
const [approvalGroup, setApprovalGroup] = useState<number>(KHKK_APPROVAL_GROUP_DEFAULT)
|
||||
const [ghiChu, setGhiChu] = useState('')
|
||||
|
||||
// Phiếu PE đủ điều kiện: Phase=DaDuyet VÀ chưa có kế hoạch sống (BE lọc,
|
||||
// mirror `ListApprovedPurchaseEvaluationsQuery` + vị ngữ chưa-có-plan).
|
||||
// Phiếu PE đủ điều kiện: Phase=DaDuyet VÀ có ≥1 NCC trúng thầu (BE lọc — khớp
|
||||
// ĐÚNG 2 rào còn lại của Create nên picker không mời bấm vào thứ chắc chắn 409).
|
||||
// [K2 S164] Vị ngữ "chưa có kế hoạch sống" ĐÃ BỎ (`Features.cs:1291-1295`).
|
||||
const candidates = useQuery({
|
||||
queryKey: ['khkk-pe-candidates'],
|
||||
queryFn: async () =>
|
||||
@ -73,6 +94,9 @@ export function KhkkCreatePage() {
|
||||
peId: purchaseEvaluationId, // [F-2] BE Command field = PeId
|
||||
approvalWorkflowId,
|
||||
ghiChu: ghiChu.trim() || null,
|
||||
// [K2 S164] Gửi TƯỜNG MINH (BE cho phép bỏ trống = nhóm 1, nhưng ô chọn đã
|
||||
// hiện số nào thì phiếu phải vào đúng số đó — không dựa default ngầm).
|
||||
approvalGroup,
|
||||
}
|
||||
const res = await api.post<{ id: string; maKeHoach: string | null }>('/contract-signing-plans', body)
|
||||
return res.data
|
||||
@ -91,6 +115,24 @@ export function KhkkCreatePage() {
|
||||
|
||||
const list = candidates.data ?? []
|
||||
|
||||
// [K2 S164] Nhóm đã có kế hoạch SỐNG trên PE đang chọn — nguồn DUY NHẤT là DTO picker
|
||||
// (`existingGroups`), FE không tự suy từ đâu khác. `?? []` giữ trang sống với payload
|
||||
// cũ còn nằm trong cache React Query trước khi K2 deploy.
|
||||
const selectedPe = list.find((p) => p.id === purchaseEvaluationId)
|
||||
const takenGroups = selectedPe?.existingGroups ?? []
|
||||
const groupTaken = takenGroups.includes(approvalGroup)
|
||||
|
||||
// Bấm chọn PE (cả ở dòng lẫn ở nút "Tạo thêm cho nhóm khác"): nếu nhóm đang chọn đã
|
||||
// bị PE đó dùng thì nhảy sang nhóm trống đầu tiên ⇒ người dùng không phải tự dò.
|
||||
const selectPe = (pe: KhkkPeCandidateDto) => {
|
||||
setPurchaseEvaluationId(pe.id)
|
||||
const taken = pe.existingGroups ?? []
|
||||
if (taken.includes(approvalGroup)) {
|
||||
const free = firstFreeGroup(taken)
|
||||
if (free !== null) setApprovalGroup(free)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
@ -126,17 +168,21 @@ export function KhkkCreatePage() {
|
||||
{candidates.isLoading && <div className="text-sm text-muted-foreground">Đang tải danh sách phiếu...</div>}
|
||||
{!candidates.isLoading && list.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-slate-300 p-6 text-center text-sm text-muted-foreground">
|
||||
Không có phiếu Duyệt NCC nào đã duyệt mà chưa lập kế hoạch ký kết.
|
||||
Không có phiếu Duyệt NCC nào đã duyệt và có NCC trúng thầu.
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-96 space-y-2 overflow-y-auto">
|
||||
{list.map((pe) => {
|
||||
const selected = pe.id === purchaseEvaluationId
|
||||
// [K2 S164 — OG-7] `?? []` = phòng payload cũ trong cache (trước K2 DTO
|
||||
// không có field này) — thiếu guard thì `.length` nổ trắng trang.
|
||||
const taken = pe.existingGroups ?? []
|
||||
const free = firstFreeGroup(taken)
|
||||
return (
|
||||
<div key={pe.id} className="space-y-1">
|
||||
<button
|
||||
key={pe.id}
|
||||
type="button"
|
||||
onClick={() => setPurchaseEvaluationId(pe.id)}
|
||||
onClick={() => selectPe(pe)}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-3 rounded-lg border p-3 text-left transition',
|
||||
selected
|
||||
@ -172,6 +218,33 @@ export function KhkkCreatePage() {
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* [K2 S164 — OG-7] PE đã có kế hoạch: KHÔNG ẩn dòng nữa, hiện nhóm
|
||||
đã dùng + lối đi tiếp. Nút nằm NGOÀI <button> ở trên (button lồng
|
||||
button là HTML không hợp lệ). */}
|
||||
{taken.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 pl-8 text-xs">
|
||||
<span className="text-slate-500">Đã có kế hoạch nhóm:</span>
|
||||
{taken.map((g) => (
|
||||
<span
|
||||
key={g}
|
||||
className="inline-flex items-center rounded-md border border-amber-300 bg-amber-100 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800"
|
||||
title={KHKK_APPROVAL_GROUP_LABELS[g] ?? `Nhóm ${g}`}
|
||||
>
|
||||
N{g}
|
||||
</span>
|
||||
))}
|
||||
{free === null ? (
|
||||
<span className="italic text-slate-400">Đã dùng hết 8 nhóm duyệt</span>
|
||||
) : (
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => selectPe(pe)}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
Tạo thêm cho nhóm khác
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@ -188,27 +261,64 @@ export function KhkkCreatePage() {
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold tracking-tight text-violet-700">Quy trình duyệt</h3>
|
||||
<h3 className="text-sm font-semibold tracking-tight text-violet-700">Nhóm duyệt & quy trình</h3>
|
||||
</header>
|
||||
<div className="space-y-2 p-5">
|
||||
<Label htmlFor="workflow">
|
||||
Quy trình duyệt <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<select
|
||||
id="workflow"
|
||||
value={approvalWorkflowId}
|
||||
onChange={(e) => setApprovalWorkflowId(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
|
||||
>
|
||||
<option value="">— Chọn quy trình —</option>
|
||||
{workflows.data?.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.code} - {w.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Chỉ hiển thị quy trình loại "Kế hoạch ký kết HĐ" admin đã ghim cho user chọn
|
||||
<div className="grid grid-cols-1 gap-4 p-5 md:grid-cols-2">
|
||||
{/* [K2 S164 — vá-4] Nhóm duyệt N1..N8. Mặc định nhóm 1 (UI-hướng-1: ca HẸP,
|
||||
user cần nhóm khác thì đổi ngay tại đây). Nhóm PE đã dùng bị disable —
|
||||
rào cứng 409 vẫn ở BE, ô này chỉ để không mời bấm nhầm. */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="approvalGroup" className="flex items-center gap-1.5">
|
||||
<Layers className="h-3.5 w-3.5 text-violet-600" />
|
||||
Nhóm duyệt (N1–N8) <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<select
|
||||
id="approvalGroup"
|
||||
value={approvalGroup}
|
||||
onChange={(e) => setApprovalGroup(Number(e.target.value))}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
|
||||
>
|
||||
{KHKK_APPROVAL_GROUPS.map((g) => {
|
||||
const used = takenGroups.includes(g)
|
||||
return (
|
||||
<option key={g} value={g} disabled={used}>
|
||||
N{g} - {KHKK_APPROVAL_GROUP_LABELS[g] ?? `Nhóm ${g}`}
|
||||
{used ? ' (đã có kế hoạch)' : ''}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
{groupTaken ? (
|
||||
<div className="mt-1 text-xs font-medium text-red-600">
|
||||
Phiếu Duyệt NCC này đã có kế hoạch ký kết cho nhóm N{approvalGroup} — chọn nhóm khác.
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Mỗi phiếu Duyệt NCC lập được 1 kế hoạch cho MỖI nhóm duyệt
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workflow">
|
||||
Quy trình duyệt <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<select
|
||||
id="workflow"
|
||||
value={approvalWorkflowId}
|
||||
onChange={(e) => setApprovalWorkflowId(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
|
||||
>
|
||||
<option value="">— Chọn quy trình —</option>
|
||||
{workflows.data?.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.code} - {w.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Chỉ hiển thị quy trình loại "Kế hoạch ký kết HĐ" admin đã ghim cho user chọn
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@ -243,7 +353,12 @@ export function KhkkCreatePage() {
|
||||
<Button type="button" variant="outline" onClick={() => navigate('/khkk/list')}>
|
||||
Huỷ
|
||||
</Button>
|
||||
<Button type="submit" disabled={create.isPending || !purchaseEvaluationId || !approvalWorkflowId}>
|
||||
{/* [K2 S164] `groupTaken` chặn ngay ở FE lượt bấm chắc chắn ăn 409 của BE
|
||||
(`Features.cs:395-402`) — vẫn để BE là rào thật, đây chỉ là đỡ 1 vòng mạng. */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={create.isPending || !purchaseEvaluationId || !approvalWorkflowId || groupTaken}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{create.isPending ? 'Đang lưu...' : 'Lưu nháp'}
|
||||
</Button>
|
||||
|
||||
@ -26,7 +26,8 @@ import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
DOSSIER_KIND_LABELS, DOSSIER_STATUS_BADGE, DOSSIER_STATUS_LABELS,
|
||||
DossierItemKind, DossierItemStatus, KHKK_PHASE_BADGE, KHKK_PHASE_LABELS,
|
||||
DossierItemKind, DossierItemStatus, KHKK_APPROVAL_GROUP_LABELS,
|
||||
KHKK_PHASE_BADGE, KHKK_PHASE_LABELS,
|
||||
KhkkAttachmentPurpose, KhkkPhase,
|
||||
type DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto,
|
||||
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue,
|
||||
@ -290,6 +291,18 @@ export function KhkkDetailPage() {
|
||||
accent="brand"
|
||||
actions={
|
||||
<>
|
||||
{/* [K2 S164] Badge NHÓM DUYỆT — đứng TRƯỚC badge phase vì nhóm là thứ quyết
|
||||
phiếu đi đường nào (8 quy trình khác nhau), phase chỉ là chỗ đang đứng.
|
||||
`!= null` guard: phiếu nạp từ cache trước K2 chưa có field ⇒ đừng in
|
||||
"Nundefined" (bài "vắng-mặt trông giống ổn"). */}
|
||||
{k.approvalGroup != null && (
|
||||
<span
|
||||
className="inline-flex items-center rounded-md border border-violet-300 bg-violet-100 px-2.5 py-1 text-xs font-semibold text-violet-800"
|
||||
title={KHKK_APPROVAL_GROUP_LABELS[k.approvalGroup] ?? `Nhóm ${k.approvalGroup}`}
|
||||
>
|
||||
N{k.approvalGroup}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-medium',
|
||||
@ -423,6 +436,16 @@ export function KhkkDetailPage() {
|
||||
chipBg="var(--color-teal-50)"
|
||||
chipFg="var(--color-teal-700)"
|
||||
count={k.lines.length}
|
||||
actions={
|
||||
// [K2 S164 — QĐ-9] `budgetFrozen` = cờ HIỂN THỊ do BE dẫn xuất từ phase
|
||||
// (`DetailDto:166`). Số đã đóng băng ở tầng dữ liệu bởi `ApprovedAmount ??=`
|
||||
// tại choke-point finalize — badge này chỉ nói cho người dùng biết điều đó.
|
||||
k.budgetFrozen ? (
|
||||
<span className="inline-flex items-center rounded-md border border-amber-300 bg-amber-100 px-2 py-0.5 text-[11px] font-semibold normal-case tracking-normal text-amber-800">
|
||||
🔒 Giá đã chốt tại thời điểm duyệt
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{k.lines.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">Phiếu nguồn chưa có NCC trúng thầu.</div>
|
||||
@ -431,6 +454,10 @@ export function KhkkDetailPage() {
|
||||
<thead className="border-b border-slate-200 bg-slate-50/70">
|
||||
<tr>
|
||||
<th className="label-eyebrow px-3 py-2 text-left">Nhà cung cấp</th>
|
||||
{/* [K2 S164] Hạng mục SP-002 của dòng — HIỂN THỊ-ONLY lượt này.
|
||||
Đường GÁN (Lines-editor) = **K4b**; K2 chỉ đẻ cột + cho thấy dòng
|
||||
nào còn trống (dòng trống KHÔNG trình duyệt được — submit-guard BE). */}
|
||||
<th className="label-eyebrow px-3 py-2 text-left">Hạng mục</th>
|
||||
<th className="label-eyebrow px-3 py-2 text-right">Giá tham chiếu (phiếu NCC)</th>
|
||||
<th className="label-eyebrow px-3 py-2 text-right">Giá đề xuất</th>
|
||||
<th className="label-eyebrow px-3 py-2 text-right">Giá duyệt</th>
|
||||
@ -441,6 +468,9 @@ export function KhkkDetailPage() {
|
||||
{k.lines.map((l) => (
|
||||
<tr key={l.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-3 py-2 font-medium text-brand-800">{l.supplierName ?? '—'}</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-700">
|
||||
{l.tenHangMuc ? l.tenHangMuc : <span className="text-slate-300">—</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{formatVnd(l.peReferenceAmount)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-brand-800">{formatVnd(l.proposedAmount)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-emerald-700">{formatVnd(l.approvedAmount)}</td>
|
||||
@ -452,6 +482,7 @@ export function KhkkDetailPage() {
|
||||
)}
|
||||
<div className="mt-3 text-xs text-muted-foreground">
|
||||
Giá tham chiếu là SNAPSHOT tổng báo giá được chọn của NCC trên phiếu Duyệt NCC tại thời điểm lập kế hoạch.
|
||||
{' '}Cột Hạng mục hiện chưa gán được từ màn này (ô gán thuộc bước Lines-editor).
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
|
||||
@ -25,6 +25,7 @@ import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
KHKK_APPROVAL_GROUP_LABELS,
|
||||
KHKK_PHASE_BADGE,
|
||||
KHKK_PHASE_LABELS,
|
||||
KhkkPhase,
|
||||
@ -134,7 +135,7 @@ export function KhkkListPage() {
|
||||
|
||||
{/* [S162] Cây toàn trình bám trái + nội dung cũ giữ nguyên bên phải (owner
|
||||
chốt AskUser 30-07: "áp cả các trang GĐ khác"). `minmax(0,1fr)` để bảng
|
||||
7 cột không thổi vỡ lưới ở 1366; `<lg` cây xếp trên và tự thu gọn. */}
|
||||
8 cột không thổi vỡ lưới ở 1366; `<lg` cây xếp trên và tự thu gọn. */}
|
||||
<div className="grid gap-5 lg:grid-cols-[19rem_minmax(0,1fr)] xl:grid-cols-[21rem_minmax(0,1fr)]">
|
||||
<PipelineTreePanel currentStage={2} />
|
||||
|
||||
@ -172,7 +173,7 @@ export function KhkkListPage() {
|
||||
</div>
|
||||
|
||||
<div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
|
||||
{/* [S162] Cột trái ăn ~19rem ⇒ bảng 7 cột cuộn ngang trong thẻ thay vì
|
||||
{/* [S162] Cột trái ăn ~19rem ⇒ bảng 8 cột cuộn ngang trong thẻ thay vì
|
||||
tràn ra ngoài lưới trên laptop 1366. */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
@ -183,6 +184,10 @@ export function KhkkListPage() {
|
||||
<th className="label-eyebrow px-4 py-2.5 text-left">Dự án</th>
|
||||
<th className="label-eyebrow px-4 py-2.5 text-left">Trạng thái</th>
|
||||
<th className="label-eyebrow px-4 py-2.5 text-right">Tổng đề xuất</th>
|
||||
{/* [K2 S164 — vá-8] Σ `ApprovedAmount` (`ListItemDto:64`). Không có cột này
|
||||
thì màn danh sách nói "Tổng đề xuất" còn màn chi tiết nói "Giá duyệt"
|
||||
trên CÙNG một phiếu đã duyệt ⇒ người dùng đọc ra hai con số. */}
|
||||
<th className="label-eyebrow px-4 py-2.5 text-right">Tổng duyệt</th>
|
||||
<th className="label-eyebrow px-4 py-2.5 text-left">Người soạn</th>
|
||||
<th className="label-eyebrow px-4 py-2.5 text-left">Ngày tạo</th>
|
||||
</tr>
|
||||
@ -190,14 +195,14 @@ export function KhkkListPage() {
|
||||
<tbody>
|
||||
{list.isLoading && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-slate-500">
|
||||
Đang tải...
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!list.isLoading && items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-10 text-center text-slate-500">
|
||||
<td colSpan={8} className="px-4 py-10 text-center text-slate-500">
|
||||
<span
|
||||
className="icon-chip mx-auto mb-2 flex"
|
||||
style={{ ['--chip-bg' as string]: '#f1f5f9', ['--chip-fg' as string]: '#94a3b8' }}
|
||||
@ -228,6 +233,18 @@ export function KhkkListPage() {
|
||||
<FileCheck className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="font-mono text-xs text-brand-800">{k.maKeHoach ?? '—'}</span>
|
||||
{/* [K2 S164] Badge NHÓM DUYỆT — đặt cạnh mã thay vì đẻ cột thứ 9:
|
||||
bảng này đã cuộn ngang ở 1366 khi có cây bên trái (ghi chú S162).
|
||||
`!= null` guard cho payload cũ còn trong cache React Query (trước
|
||||
K2 DTO chưa có field) — thiếu guard thì in ra "Nundefined". */}
|
||||
{k.approvalGroup != null && (
|
||||
<span
|
||||
className="inline-flex items-center rounded border border-violet-300 bg-violet-100 px-1.5 py-0.5 text-[10px] font-semibold text-violet-800"
|
||||
title={KHKK_APPROVAL_GROUP_LABELS[k.approvalGroup] ?? `Nhóm ${k.approvalGroup}`}
|
||||
>
|
||||
N{k.approvalGroup}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="max-w-xs truncate px-4 py-2.5 font-medium text-brand-800">
|
||||
@ -249,6 +266,11 @@ export function KhkkListPage() {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-brand-800">{formatVnd(k.totalProposedAmount)}</td>
|
||||
{/* Phiếu chưa duyệt BE trả 0 (không phải null) ⇒ ép về "—" cho khỏi đọc
|
||||
nhầm "đã duyệt 0 đ"; số chỉ hiện khi thật sự có tiền đã chốt. */}
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-emerald-700">
|
||||
{k.totalApprovedAmount ? formatVnd(k.totalApprovedAmount) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-slate-600">{k.drafterFullName ?? '—'}</td>
|
||||
<td className="px-4 py-2.5 text-xs text-slate-600">{formatDate(k.createdAt)}</td>
|
||||
</tr>
|
||||
|
||||
@ -24,6 +24,17 @@
|
||||
// Hợp đồng FE ↔ BE lượt W3 (LEAD chốt — 2 lane implement đúng LITERAL từng tên field):
|
||||
// POST /contract-signing-plans/{id}/transitions KhkkTransitionInput → KhkkTransitionResult
|
||||
// policy `KeHoachKyKet.Update`
|
||||
//
|
||||
// [K2 S164] Delta hợp-đồng lượt K2 (BE đã land — đọc `ContractSigningPlanFeatures.cs`):
|
||||
// GET /contract-signing-plans **+`?group=n`** (`Controller:42` `[FromQuery(Name="group")]`
|
||||
// → `ListContractSigningPlansQuery.ApprovalGroup` `:700-705`). K2 = CHỦ tham số;
|
||||
// cây 8 folder (K5) + leaf nhóm (K4b) chỉ TIÊU THỤ ⇒ FE list KHÔNG tự đẻ chỗ lọc
|
||||
// thứ hai (chờ K4b/K5), chỉ HIỂN THỊ nhóm của từng phiếu.
|
||||
// POST /contract-signing-plans body **+`approvalGroup?`** (`CreateCommand:333-337`,
|
||||
// null ⇒ nhóm 1 — CỐ Ý nullable để client cũ không gãy).
|
||||
// GET /contract-signing-plans/approved-pe-awaiting-plan → **ĐỔI HÌNH DẠNG**: PE đã có
|
||||
// kế hoạch KHÔNG còn bị loại khỏi danh sách (`Features.cs:1291-1295`), thay vào đó
|
||||
// trả `existingGroups` để FE khoá đúng nhóm đã dùng (OG-7 UI-hướng-1).
|
||||
|
||||
// Mirror `ContractSigningPlanPhase.cs` — 🔴 bắt đầu từ 1 (0 falsy JS).
|
||||
export const KhkkPhase = {
|
||||
@ -103,6 +114,29 @@ export const KHKK_ATTACHMENT_PURPOSE_LABELS: Record<KhkkAttachmentPurposeValue,
|
||||
99: 'Khác',
|
||||
}
|
||||
|
||||
// ===================== K2 S164 — Nhóm duyệt N1..N8 (SP-002) ==========================
|
||||
// Mirror `ContractSigningPlanScope.cs` (`ContractSigningPlanFeatures.cs:201-203`):
|
||||
// Min=1 · Max=8 · Default=1. Không gửi nhóm ⇒ BE gán 1 (khớp backfill Mig 71).
|
||||
export const KHKK_APPROVAL_GROUP_MIN = 1
|
||||
export const KHKK_APPROVAL_GROUP_MAX = 8
|
||||
export const KHKK_APPROVAL_GROUP_DEFAULT = 1
|
||||
export const KHKK_APPROVAL_GROUPS = [1, 2, 3, 4, 5, 6, 7, 8] as const
|
||||
|
||||
// 🔴 NHÃN NGHIỆP VỤ 8 nhóm — nguồn `danh-muc-sp002-transcribe.md:20-29` ("8 NHÃN MENU
|
||||
// đề xuất", **[OG-6] anh soát SAU trên UI**). Cùng bộ chữ này K4a sẽ seed làm LABEL menu
|
||||
// ⇒ nếu K4a land nhãn khác thì SỬA Ở ĐÂY (1 const, 2 app), đừng vá rải trong page.
|
||||
// Rút gọn có chủ đích: bỏ số thứ tự đầu dòng của bảng (ô select đã có "N{n}").
|
||||
export const KHKK_APPROVAL_GROUP_LABELS: Record<number, string> = {
|
||||
1: 'Thiết bị, máy móc, vật tư phụ (A1)',
|
||||
2: 'Kết cấu, hạ tầng, phá dỡ & DV hỗ trợ thi công (A2-A3)',
|
||||
3: 'Hoạt động văn phòng (A4)',
|
||||
4: 'Vật tư (B1)',
|
||||
5: 'Thầu phụ xây dựng (B2)',
|
||||
6: 'Thầu phụ MEP (B3)',
|
||||
7: 'BCH công trường (C1-C3)',
|
||||
8: 'HĐ chưa ủy quyền — CEO (5.2)',
|
||||
}
|
||||
|
||||
export interface KhkkListItemDto {
|
||||
id: string
|
||||
maKeHoach: string | null
|
||||
@ -120,6 +154,14 @@ export interface KhkkListItemDto {
|
||||
workflowCode: string | null
|
||||
currentApprovalLevelOrder: number | null
|
||||
totalProposedAmount: number | null
|
||||
// [K2 S164] `ContractSigningPlanListItemDto:63` — nhóm duyệt N1..N8 của phiếu (BE trả
|
||||
// TRÊN CẢ list lẫn detail ⇒ `KhkkDetailDto` kế thừa được, không khai lại).
|
||||
approvalGroup: number
|
||||
// [K2 S164 — vá-8] `:64` Σ `ApprovedAmount` (phiếu chưa duyệt → 0). Có Σ này thì màn
|
||||
// danh sách và màn chi tiết không nói hai con số khác nhau trên CÙNG một phiếu.
|
||||
// 🔴 nullable Ở ĐÂY (không phải ở BE): `KhkkDetailDto extends KhkkListItemDto` mà
|
||||
// `ContractSigningPlanDetailDto` KHÔNG có field này ⇒ trên màn chi tiết nó là undefined.
|
||||
totalApprovedAmount: number | null
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
@ -133,6 +175,12 @@ export interface KhkkLineDto {
|
||||
approvedAmount: number | null
|
||||
contractId: string | null
|
||||
note: string | null
|
||||
// [K2 S164] `ContractSigningPlanLineDto:78-79` — hạng mục SP-002 của dòng.
|
||||
// `tenHangMuc` là DENORM lúc gán (danh mục đổi tên KHÔNG viết lại lịch sử phiếu).
|
||||
// null = dòng chưa gán hạng mục (mồi lúc auto-sinh, `Features.cs:454-455`) — màn chi
|
||||
// tiết hiện "—"; ĐƯỜNG GÁN (Lines-editor) thuộc **K4b**, K2 chỉ hiển thị.
|
||||
catalogEntryId: string | null
|
||||
tenHangMuc: string | null
|
||||
}
|
||||
|
||||
export interface KhkkDossierItemDto {
|
||||
@ -206,10 +254,18 @@ export interface KhkkDetailDto extends KhkkListItemDto {
|
||||
levelOpinions: KhkkLevelOpinionDto[]
|
||||
// `ContractSigningPlanDetailDto:147` — field CUỐI của record BE.
|
||||
workflowSteps: KhkkWorkflowStepDto[]
|
||||
// [K2 S164] `ContractSigningPlanDetailDto:166` — CỜ HIỂN THỊ dẫn xuất từ phase
|
||||
// (`phase == DaDuyet`), KHÔNG phải cột DB. Số đã đóng băng ở tầng dữ liệu bởi
|
||||
// `ApprovedAmount ??=` tại choke-point finalize. Khuôn tên `budgetFrozen` của PE S133.
|
||||
// 🔸 BE **KHÔNG** expose `endedByLevelFinalize` ở DTO lượt này (cột Mig 71 nằm im chờ
|
||||
// K3 tiêu thụ — ghost-window đã khai ở `sub-implbe-k2.md §a`) ⇒ FE không khai field đó.
|
||||
budgetFrozen: boolean
|
||||
}
|
||||
|
||||
// Phiếu PE nguồn đủ điều kiện lập kế hoạch (DaDuyet + chưa có plan sống).
|
||||
// CỐ Ý là TẬP CON của `PurchaseEvaluationListItemDto` BE trả — field thừa bỏ qua runtime.
|
||||
// Phiếu PE nguồn đủ điều kiện lập kế hoạch (DaDuyet + có NCC trúng thầu).
|
||||
// CỐ Ý là TẬP CON của `ApprovedPeAwaitingPlanDto` BE trả — field thừa bỏ qua runtime.
|
||||
// 🔴 [K2 S164 — OG-7] Vị ngữ "chưa có plan sống" ĐÃ BỎ khỏi picker (`Features.cs:1291-1295`):
|
||||
// PE đã có kế hoạch VẪN hiện, kèm `existingGroups`.
|
||||
export interface KhkkPeCandidateDto {
|
||||
id: string
|
||||
maPhieu: string | null
|
||||
@ -218,6 +274,11 @@ export interface KhkkPeCandidateDto {
|
||||
workItemName: string | null
|
||||
winnerSupplierNames: string[]
|
||||
createdAt: string
|
||||
// [K2 S164] `ApprovedPeAwaitingPlanDto:191` — nhóm duyệt của các kế hoạch ĐANG SỐNG
|
||||
// trên PE này (`Phase != TuChoi`). Rỗng = PE chưa có kế hoạch nào. FE dùng để (a) hiện
|
||||
// nút "Tạo thêm cho nhóm khác" và (b) khoá đúng nhóm đã dùng trong ô chọn nhóm —
|
||||
// rào cứng 409 vẫn nằm ở BE (`:395-402`), FE chỉ có nhiệm vụ không mời bấm nhầm.
|
||||
existingGroups: number[]
|
||||
}
|
||||
|
||||
export interface CreateKhkkInput {
|
||||
@ -226,6 +287,10 @@ export interface CreateKhkkInput {
|
||||
peId: string
|
||||
approvalWorkflowId: string
|
||||
ghiChu?: string | null
|
||||
// [K2 S164] Nhóm duyệt N1..N8 (`CreateCommand:337` `int? ApprovalGroup = null`).
|
||||
// Bỏ trống ⇒ BE gán nhóm 1. FE LUÔN gửi (ô chọn có default 1) — gửi tường minh để
|
||||
// người dùng thấy đúng thứ mình chọn, không phụ thuộc default ngầm của BE.
|
||||
approvalGroup?: number | null
|
||||
}
|
||||
|
||||
export interface UpdateKhkkDraftInput {
|
||||
|
||||
@ -2,14 +2,20 @@
|
||||
// Khuôn `pages/office/ProposalCreatePage.tsx` (PURO: ui/PageHeader + section
|
||||
// .card-accent + .icon-chip header). File MIRROR SHA256 identical với fe-admin.
|
||||
//
|
||||
// 3 section: (1) chọn phiếu PE nguồn — chỉ phiếu DaDuyet CHƯA có kế hoạch sống
|
||||
// (BE lọc, FE không tự đoán); (2) quy trình duyệt V2 ApplicableType=10 admin đã
|
||||
// ghim; (3) ghi chú. Dòng giá per-NCC do BE tự sinh từ winner của phiếu PE
|
||||
// 3 section: (1) chọn phiếu PE nguồn — phiếu DaDuyet có NCC trúng thầu (BE lọc, FE
|
||||
// không tự đoán); (2) NHÓM DUYỆT N1..N8 + quy trình duyệt V2 ApplicableType=10 admin
|
||||
// đã ghim; (3) ghi chú. Dòng giá per-NCC do BE tự sinh từ winner của phiếu PE
|
||||
// (snapshot `PeReferenceAmount`) — FE KHÔNG gửi lines.
|
||||
//
|
||||
// [K2 S164 — OG-7 UI-hướng-1] ĐỔI HÌNH DẠNG màn này: trước K2, PE đã có kế hoạch bị
|
||||
// BE loại khỏi picker (biến mất) và tạo trùng thì ăn 409 chặn hẳn. Từ K2, rào là
|
||||
// "1 PE = 1 kế hoạch MỖI NHÓM" ⇒ PE vẫn hiện, kèm danh sách nhóm đã dùng + nút phụ
|
||||
// "Tạo thêm cho nhóm khác" (preset sang nhóm còn trống). Ẩn PE đi thì người dùng
|
||||
// không phân biệt được "PE không đủ điều kiện" với "PE đã dùng hết nhóm".
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { CheckCircle2, FileCheck, FileText, Save, Settings2, X } from 'lucide-react'
|
||||
import { CheckCircle2, FileCheck, FileText, Layers, Plus, Save, Settings2, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { PageHeader } from '@/components/ui/PageHeader'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
@ -18,7 +24,13 @@ import { Textarea } from '@/components/ui/Textarea'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import type { CreateKhkkInput, KhkkPeCandidateDto } from '@/types/khkk'
|
||||
import {
|
||||
KHKK_APPROVAL_GROUPS,
|
||||
KHKK_APPROVAL_GROUP_DEFAULT,
|
||||
KHKK_APPROVAL_GROUP_LABELS,
|
||||
type CreateKhkkInput,
|
||||
type KhkkPeCandidateDto,
|
||||
} from '@/types/khkk'
|
||||
|
||||
// ApplicableType của workflow V2 dành cho Kế hoạch ký kết (mirror BE
|
||||
// `ApplicableType.ContractSigningPlan = 10`, Designer fe-admin đã mở từ W1).
|
||||
@ -36,14 +48,23 @@ function formatDate(iso: string): string {
|
||||
return d.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
// Nhóm trống ĐẦU TIÊN của một PE (null = đã dùng hết 8 nhóm). Dùng cả cho nút
|
||||
// "Tạo thêm cho nhóm khác" lẫn lúc bấm chọn PE — để ô chọn nhóm không đứng yên ở
|
||||
// một nhóm chắc chắn 409.
|
||||
function firstFreeGroup(taken: number[]): number | null {
|
||||
return KHKK_APPROVAL_GROUPS.find((g) => !taken.includes(g)) ?? null
|
||||
}
|
||||
|
||||
export function KhkkCreatePage() {
|
||||
const navigate = useNavigate()
|
||||
const [purchaseEvaluationId, setPurchaseEvaluationId] = useState('')
|
||||
const [approvalWorkflowId, setApprovalWorkflowId] = useState('')
|
||||
const [approvalGroup, setApprovalGroup] = useState<number>(KHKK_APPROVAL_GROUP_DEFAULT)
|
||||
const [ghiChu, setGhiChu] = useState('')
|
||||
|
||||
// Phiếu PE đủ điều kiện: Phase=DaDuyet VÀ chưa có kế hoạch sống (BE lọc,
|
||||
// mirror `ListApprovedPurchaseEvaluationsQuery` + vị ngữ chưa-có-plan).
|
||||
// Phiếu PE đủ điều kiện: Phase=DaDuyet VÀ có ≥1 NCC trúng thầu (BE lọc — khớp
|
||||
// ĐÚNG 2 rào còn lại của Create nên picker không mời bấm vào thứ chắc chắn 409).
|
||||
// [K2 S164] Vị ngữ "chưa có kế hoạch sống" ĐÃ BỎ (`Features.cs:1291-1295`).
|
||||
const candidates = useQuery({
|
||||
queryKey: ['khkk-pe-candidates'],
|
||||
queryFn: async () =>
|
||||
@ -73,6 +94,9 @@ export function KhkkCreatePage() {
|
||||
peId: purchaseEvaluationId, // [F-2] BE Command field = PeId
|
||||
approvalWorkflowId,
|
||||
ghiChu: ghiChu.trim() || null,
|
||||
// [K2 S164] Gửi TƯỜNG MINH (BE cho phép bỏ trống = nhóm 1, nhưng ô chọn đã
|
||||
// hiện số nào thì phiếu phải vào đúng số đó — không dựa default ngầm).
|
||||
approvalGroup,
|
||||
}
|
||||
const res = await api.post<{ id: string; maKeHoach: string | null }>('/contract-signing-plans', body)
|
||||
return res.data
|
||||
@ -91,6 +115,24 @@ export function KhkkCreatePage() {
|
||||
|
||||
const list = candidates.data ?? []
|
||||
|
||||
// [K2 S164] Nhóm đã có kế hoạch SỐNG trên PE đang chọn — nguồn DUY NHẤT là DTO picker
|
||||
// (`existingGroups`), FE không tự suy từ đâu khác. `?? []` giữ trang sống với payload
|
||||
// cũ còn nằm trong cache React Query trước khi K2 deploy.
|
||||
const selectedPe = list.find((p) => p.id === purchaseEvaluationId)
|
||||
const takenGroups = selectedPe?.existingGroups ?? []
|
||||
const groupTaken = takenGroups.includes(approvalGroup)
|
||||
|
||||
// Bấm chọn PE (cả ở dòng lẫn ở nút "Tạo thêm cho nhóm khác"): nếu nhóm đang chọn đã
|
||||
// bị PE đó dùng thì nhảy sang nhóm trống đầu tiên ⇒ người dùng không phải tự dò.
|
||||
const selectPe = (pe: KhkkPeCandidateDto) => {
|
||||
setPurchaseEvaluationId(pe.id)
|
||||
const taken = pe.existingGroups ?? []
|
||||
if (taken.includes(approvalGroup)) {
|
||||
const free = firstFreeGroup(taken)
|
||||
if (free !== null) setApprovalGroup(free)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
@ -126,17 +168,21 @@ export function KhkkCreatePage() {
|
||||
{candidates.isLoading && <div className="text-sm text-muted-foreground">Đang tải danh sách phiếu...</div>}
|
||||
{!candidates.isLoading && list.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-slate-300 p-6 text-center text-sm text-muted-foreground">
|
||||
Không có phiếu Duyệt NCC nào đã duyệt mà chưa lập kế hoạch ký kết.
|
||||
Không có phiếu Duyệt NCC nào đã duyệt và có NCC trúng thầu.
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-96 space-y-2 overflow-y-auto">
|
||||
{list.map((pe) => {
|
||||
const selected = pe.id === purchaseEvaluationId
|
||||
// [K2 S164 — OG-7] `?? []` = phòng payload cũ trong cache (trước K2 DTO
|
||||
// không có field này) — thiếu guard thì `.length` nổ trắng trang.
|
||||
const taken = pe.existingGroups ?? []
|
||||
const free = firstFreeGroup(taken)
|
||||
return (
|
||||
<div key={pe.id} className="space-y-1">
|
||||
<button
|
||||
key={pe.id}
|
||||
type="button"
|
||||
onClick={() => setPurchaseEvaluationId(pe.id)}
|
||||
onClick={() => selectPe(pe)}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-3 rounded-lg border p-3 text-left transition',
|
||||
selected
|
||||
@ -172,6 +218,33 @@ export function KhkkCreatePage() {
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* [K2 S164 — OG-7] PE đã có kế hoạch: KHÔNG ẩn dòng nữa, hiện nhóm
|
||||
đã dùng + lối đi tiếp. Nút nằm NGOÀI <button> ở trên (button lồng
|
||||
button là HTML không hợp lệ). */}
|
||||
{taken.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 pl-8 text-xs">
|
||||
<span className="text-slate-500">Đã có kế hoạch nhóm:</span>
|
||||
{taken.map((g) => (
|
||||
<span
|
||||
key={g}
|
||||
className="inline-flex items-center rounded-md border border-amber-300 bg-amber-100 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800"
|
||||
title={KHKK_APPROVAL_GROUP_LABELS[g] ?? `Nhóm ${g}`}
|
||||
>
|
||||
N{g}
|
||||
</span>
|
||||
))}
|
||||
{free === null ? (
|
||||
<span className="italic text-slate-400">Đã dùng hết 8 nhóm duyệt</span>
|
||||
) : (
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => selectPe(pe)}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
Tạo thêm cho nhóm khác
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@ -188,27 +261,64 @@ export function KhkkCreatePage() {
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold tracking-tight text-violet-700">Quy trình duyệt</h3>
|
||||
<h3 className="text-sm font-semibold tracking-tight text-violet-700">Nhóm duyệt & quy trình</h3>
|
||||
</header>
|
||||
<div className="space-y-2 p-5">
|
||||
<Label htmlFor="workflow">
|
||||
Quy trình duyệt <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<select
|
||||
id="workflow"
|
||||
value={approvalWorkflowId}
|
||||
onChange={(e) => setApprovalWorkflowId(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
|
||||
>
|
||||
<option value="">— Chọn quy trình —</option>
|
||||
{workflows.data?.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.code} - {w.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Chỉ hiển thị quy trình loại "Kế hoạch ký kết HĐ" admin đã ghim cho user chọn
|
||||
<div className="grid grid-cols-1 gap-4 p-5 md:grid-cols-2">
|
||||
{/* [K2 S164 — vá-4] Nhóm duyệt N1..N8. Mặc định nhóm 1 (UI-hướng-1: ca HẸP,
|
||||
user cần nhóm khác thì đổi ngay tại đây). Nhóm PE đã dùng bị disable —
|
||||
rào cứng 409 vẫn ở BE, ô này chỉ để không mời bấm nhầm. */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="approvalGroup" className="flex items-center gap-1.5">
|
||||
<Layers className="h-3.5 w-3.5 text-violet-600" />
|
||||
Nhóm duyệt (N1–N8) <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<select
|
||||
id="approvalGroup"
|
||||
value={approvalGroup}
|
||||
onChange={(e) => setApprovalGroup(Number(e.target.value))}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
|
||||
>
|
||||
{KHKK_APPROVAL_GROUPS.map((g) => {
|
||||
const used = takenGroups.includes(g)
|
||||
return (
|
||||
<option key={g} value={g} disabled={used}>
|
||||
N{g} - {KHKK_APPROVAL_GROUP_LABELS[g] ?? `Nhóm ${g}`}
|
||||
{used ? ' (đã có kế hoạch)' : ''}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
{groupTaken ? (
|
||||
<div className="mt-1 text-xs font-medium text-red-600">
|
||||
Phiếu Duyệt NCC này đã có kế hoạch ký kết cho nhóm N{approvalGroup} — chọn nhóm khác.
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Mỗi phiếu Duyệt NCC lập được 1 kế hoạch cho MỖI nhóm duyệt
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workflow">
|
||||
Quy trình duyệt <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<select
|
||||
id="workflow"
|
||||
value={approvalWorkflowId}
|
||||
onChange={(e) => setApprovalWorkflowId(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
|
||||
>
|
||||
<option value="">— Chọn quy trình —</option>
|
||||
{workflows.data?.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.code} - {w.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Chỉ hiển thị quy trình loại "Kế hoạch ký kết HĐ" admin đã ghim cho user chọn
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@ -243,7 +353,12 @@ export function KhkkCreatePage() {
|
||||
<Button type="button" variant="outline" onClick={() => navigate('/khkk/list')}>
|
||||
Huỷ
|
||||
</Button>
|
||||
<Button type="submit" disabled={create.isPending || !purchaseEvaluationId || !approvalWorkflowId}>
|
||||
{/* [K2 S164] `groupTaken` chặn ngay ở FE lượt bấm chắc chắn ăn 409 của BE
|
||||
(`Features.cs:395-402`) — vẫn để BE là rào thật, đây chỉ là đỡ 1 vòng mạng. */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={create.isPending || !purchaseEvaluationId || !approvalWorkflowId || groupTaken}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{create.isPending ? 'Đang lưu...' : 'Lưu nháp'}
|
||||
</Button>
|
||||
|
||||
@ -26,7 +26,8 @@ import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
DOSSIER_KIND_LABELS, DOSSIER_STATUS_BADGE, DOSSIER_STATUS_LABELS,
|
||||
DossierItemKind, DossierItemStatus, KHKK_PHASE_BADGE, KHKK_PHASE_LABELS,
|
||||
DossierItemKind, DossierItemStatus, KHKK_APPROVAL_GROUP_LABELS,
|
||||
KHKK_PHASE_BADGE, KHKK_PHASE_LABELS,
|
||||
KhkkAttachmentPurpose, KhkkPhase,
|
||||
type DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto,
|
||||
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue,
|
||||
@ -290,6 +291,18 @@ export function KhkkDetailPage() {
|
||||
accent="brand"
|
||||
actions={
|
||||
<>
|
||||
{/* [K2 S164] Badge NHÓM DUYỆT — đứng TRƯỚC badge phase vì nhóm là thứ quyết
|
||||
phiếu đi đường nào (8 quy trình khác nhau), phase chỉ là chỗ đang đứng.
|
||||
`!= null` guard: phiếu nạp từ cache trước K2 chưa có field ⇒ đừng in
|
||||
"Nundefined" (bài "vắng-mặt trông giống ổn"). */}
|
||||
{k.approvalGroup != null && (
|
||||
<span
|
||||
className="inline-flex items-center rounded-md border border-violet-300 bg-violet-100 px-2.5 py-1 text-xs font-semibold text-violet-800"
|
||||
title={KHKK_APPROVAL_GROUP_LABELS[k.approvalGroup] ?? `Nhóm ${k.approvalGroup}`}
|
||||
>
|
||||
N{k.approvalGroup}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-medium',
|
||||
@ -423,6 +436,16 @@ export function KhkkDetailPage() {
|
||||
chipBg="var(--color-teal-50)"
|
||||
chipFg="var(--color-teal-700)"
|
||||
count={k.lines.length}
|
||||
actions={
|
||||
// [K2 S164 — QĐ-9] `budgetFrozen` = cờ HIỂN THỊ do BE dẫn xuất từ phase
|
||||
// (`DetailDto:166`). Số đã đóng băng ở tầng dữ liệu bởi `ApprovedAmount ??=`
|
||||
// tại choke-point finalize — badge này chỉ nói cho người dùng biết điều đó.
|
||||
k.budgetFrozen ? (
|
||||
<span className="inline-flex items-center rounded-md border border-amber-300 bg-amber-100 px-2 py-0.5 text-[11px] font-semibold normal-case tracking-normal text-amber-800">
|
||||
🔒 Giá đã chốt tại thời điểm duyệt
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{k.lines.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">Phiếu nguồn chưa có NCC trúng thầu.</div>
|
||||
@ -431,6 +454,10 @@ export function KhkkDetailPage() {
|
||||
<thead className="border-b border-slate-200 bg-slate-50/70">
|
||||
<tr>
|
||||
<th className="label-eyebrow px-3 py-2 text-left">Nhà cung cấp</th>
|
||||
{/* [K2 S164] Hạng mục SP-002 của dòng — HIỂN THỊ-ONLY lượt này.
|
||||
Đường GÁN (Lines-editor) = **K4b**; K2 chỉ đẻ cột + cho thấy dòng
|
||||
nào còn trống (dòng trống KHÔNG trình duyệt được — submit-guard BE). */}
|
||||
<th className="label-eyebrow px-3 py-2 text-left">Hạng mục</th>
|
||||
<th className="label-eyebrow px-3 py-2 text-right">Giá tham chiếu (phiếu NCC)</th>
|
||||
<th className="label-eyebrow px-3 py-2 text-right">Giá đề xuất</th>
|
||||
<th className="label-eyebrow px-3 py-2 text-right">Giá duyệt</th>
|
||||
@ -441,6 +468,9 @@ export function KhkkDetailPage() {
|
||||
{k.lines.map((l) => (
|
||||
<tr key={l.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-3 py-2 font-medium text-brand-800">{l.supplierName ?? '—'}</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-700">
|
||||
{l.tenHangMuc ? l.tenHangMuc : <span className="text-slate-300">—</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{formatVnd(l.peReferenceAmount)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-brand-800">{formatVnd(l.proposedAmount)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-emerald-700">{formatVnd(l.approvedAmount)}</td>
|
||||
@ -452,6 +482,7 @@ export function KhkkDetailPage() {
|
||||
)}
|
||||
<div className="mt-3 text-xs text-muted-foreground">
|
||||
Giá tham chiếu là SNAPSHOT tổng báo giá được chọn của NCC trên phiếu Duyệt NCC tại thời điểm lập kế hoạch.
|
||||
{' '}Cột Hạng mục hiện chưa gán được từ màn này (ô gán thuộc bước Lines-editor).
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
|
||||
@ -25,6 +25,7 @@ import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
KHKK_APPROVAL_GROUP_LABELS,
|
||||
KHKK_PHASE_BADGE,
|
||||
KHKK_PHASE_LABELS,
|
||||
KhkkPhase,
|
||||
@ -134,7 +135,7 @@ export function KhkkListPage() {
|
||||
|
||||
{/* [S162] Cây toàn trình bám trái + nội dung cũ giữ nguyên bên phải (owner
|
||||
chốt AskUser 30-07: "áp cả các trang GĐ khác"). `minmax(0,1fr)` để bảng
|
||||
7 cột không thổi vỡ lưới ở 1366; `<lg` cây xếp trên và tự thu gọn. */}
|
||||
8 cột không thổi vỡ lưới ở 1366; `<lg` cây xếp trên và tự thu gọn. */}
|
||||
<div className="grid gap-5 lg:grid-cols-[19rem_minmax(0,1fr)] xl:grid-cols-[21rem_minmax(0,1fr)]">
|
||||
<PipelineTreePanel currentStage={2} />
|
||||
|
||||
@ -172,7 +173,7 @@ export function KhkkListPage() {
|
||||
</div>
|
||||
|
||||
<div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
|
||||
{/* [S162] Cột trái ăn ~19rem ⇒ bảng 7 cột cuộn ngang trong thẻ thay vì
|
||||
{/* [S162] Cột trái ăn ~19rem ⇒ bảng 8 cột cuộn ngang trong thẻ thay vì
|
||||
tràn ra ngoài lưới trên laptop 1366. */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
@ -183,6 +184,10 @@ export function KhkkListPage() {
|
||||
<th className="label-eyebrow px-4 py-2.5 text-left">Dự án</th>
|
||||
<th className="label-eyebrow px-4 py-2.5 text-left">Trạng thái</th>
|
||||
<th className="label-eyebrow px-4 py-2.5 text-right">Tổng đề xuất</th>
|
||||
{/* [K2 S164 — vá-8] Σ `ApprovedAmount` (`ListItemDto:64`). Không có cột này
|
||||
thì màn danh sách nói "Tổng đề xuất" còn màn chi tiết nói "Giá duyệt"
|
||||
trên CÙNG một phiếu đã duyệt ⇒ người dùng đọc ra hai con số. */}
|
||||
<th className="label-eyebrow px-4 py-2.5 text-right">Tổng duyệt</th>
|
||||
<th className="label-eyebrow px-4 py-2.5 text-left">Người soạn</th>
|
||||
<th className="label-eyebrow px-4 py-2.5 text-left">Ngày tạo</th>
|
||||
</tr>
|
||||
@ -190,14 +195,14 @@ export function KhkkListPage() {
|
||||
<tbody>
|
||||
{list.isLoading && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-slate-500">
|
||||
Đang tải...
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!list.isLoading && items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-10 text-center text-slate-500">
|
||||
<td colSpan={8} className="px-4 py-10 text-center text-slate-500">
|
||||
<span
|
||||
className="icon-chip mx-auto mb-2 flex"
|
||||
style={{ ['--chip-bg' as string]: '#f1f5f9', ['--chip-fg' as string]: '#94a3b8' }}
|
||||
@ -228,6 +233,18 @@ export function KhkkListPage() {
|
||||
<FileCheck className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="font-mono text-xs text-brand-800">{k.maKeHoach ?? '—'}</span>
|
||||
{/* [K2 S164] Badge NHÓM DUYỆT — đặt cạnh mã thay vì đẻ cột thứ 9:
|
||||
bảng này đã cuộn ngang ở 1366 khi có cây bên trái (ghi chú S162).
|
||||
`!= null` guard cho payload cũ còn trong cache React Query (trước
|
||||
K2 DTO chưa có field) — thiếu guard thì in ra "Nundefined". */}
|
||||
{k.approvalGroup != null && (
|
||||
<span
|
||||
className="inline-flex items-center rounded border border-violet-300 bg-violet-100 px-1.5 py-0.5 text-[10px] font-semibold text-violet-800"
|
||||
title={KHKK_APPROVAL_GROUP_LABELS[k.approvalGroup] ?? `Nhóm ${k.approvalGroup}`}
|
||||
>
|
||||
N{k.approvalGroup}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="max-w-xs truncate px-4 py-2.5 font-medium text-brand-800">
|
||||
@ -249,6 +266,11 @@ export function KhkkListPage() {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-brand-800">{formatVnd(k.totalProposedAmount)}</td>
|
||||
{/* Phiếu chưa duyệt BE trả 0 (không phải null) ⇒ ép về "—" cho khỏi đọc
|
||||
nhầm "đã duyệt 0 đ"; số chỉ hiện khi thật sự có tiền đã chốt. */}
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-emerald-700">
|
||||
{k.totalApprovedAmount ? formatVnd(k.totalApprovedAmount) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-slate-600">{k.drafterFullName ?? '—'}</td>
|
||||
<td className="px-4 py-2.5 text-xs text-slate-600">{formatDate(k.createdAt)}</td>
|
||||
</tr>
|
||||
|
||||
@ -24,6 +24,17 @@
|
||||
// Hợp đồng FE ↔ BE lượt W3 (LEAD chốt — 2 lane implement đúng LITERAL từng tên field):
|
||||
// POST /contract-signing-plans/{id}/transitions KhkkTransitionInput → KhkkTransitionResult
|
||||
// policy `KeHoachKyKet.Update`
|
||||
//
|
||||
// [K2 S164] Delta hợp-đồng lượt K2 (BE đã land — đọc `ContractSigningPlanFeatures.cs`):
|
||||
// GET /contract-signing-plans **+`?group=n`** (`Controller:42` `[FromQuery(Name="group")]`
|
||||
// → `ListContractSigningPlansQuery.ApprovalGroup` `:700-705`). K2 = CHỦ tham số;
|
||||
// cây 8 folder (K5) + leaf nhóm (K4b) chỉ TIÊU THỤ ⇒ FE list KHÔNG tự đẻ chỗ lọc
|
||||
// thứ hai (chờ K4b/K5), chỉ HIỂN THỊ nhóm của từng phiếu.
|
||||
// POST /contract-signing-plans body **+`approvalGroup?`** (`CreateCommand:333-337`,
|
||||
// null ⇒ nhóm 1 — CỐ Ý nullable để client cũ không gãy).
|
||||
// GET /contract-signing-plans/approved-pe-awaiting-plan → **ĐỔI HÌNH DẠNG**: PE đã có
|
||||
// kế hoạch KHÔNG còn bị loại khỏi danh sách (`Features.cs:1291-1295`), thay vào đó
|
||||
// trả `existingGroups` để FE khoá đúng nhóm đã dùng (OG-7 UI-hướng-1).
|
||||
|
||||
// Mirror `ContractSigningPlanPhase.cs` — 🔴 bắt đầu từ 1 (0 falsy JS).
|
||||
export const KhkkPhase = {
|
||||
@ -103,6 +114,29 @@ export const KHKK_ATTACHMENT_PURPOSE_LABELS: Record<KhkkAttachmentPurposeValue,
|
||||
99: 'Khác',
|
||||
}
|
||||
|
||||
// ===================== K2 S164 — Nhóm duyệt N1..N8 (SP-002) ==========================
|
||||
// Mirror `ContractSigningPlanScope.cs` (`ContractSigningPlanFeatures.cs:201-203`):
|
||||
// Min=1 · Max=8 · Default=1. Không gửi nhóm ⇒ BE gán 1 (khớp backfill Mig 71).
|
||||
export const KHKK_APPROVAL_GROUP_MIN = 1
|
||||
export const KHKK_APPROVAL_GROUP_MAX = 8
|
||||
export const KHKK_APPROVAL_GROUP_DEFAULT = 1
|
||||
export const KHKK_APPROVAL_GROUPS = [1, 2, 3, 4, 5, 6, 7, 8] as const
|
||||
|
||||
// 🔴 NHÃN NGHIỆP VỤ 8 nhóm — nguồn `danh-muc-sp002-transcribe.md:20-29` ("8 NHÃN MENU
|
||||
// đề xuất", **[OG-6] anh soát SAU trên UI**). Cùng bộ chữ này K4a sẽ seed làm LABEL menu
|
||||
// ⇒ nếu K4a land nhãn khác thì SỬA Ở ĐÂY (1 const, 2 app), đừng vá rải trong page.
|
||||
// Rút gọn có chủ đích: bỏ số thứ tự đầu dòng của bảng (ô select đã có "N{n}").
|
||||
export const KHKK_APPROVAL_GROUP_LABELS: Record<number, string> = {
|
||||
1: 'Thiết bị, máy móc, vật tư phụ (A1)',
|
||||
2: 'Kết cấu, hạ tầng, phá dỡ & DV hỗ trợ thi công (A2-A3)',
|
||||
3: 'Hoạt động văn phòng (A4)',
|
||||
4: 'Vật tư (B1)',
|
||||
5: 'Thầu phụ xây dựng (B2)',
|
||||
6: 'Thầu phụ MEP (B3)',
|
||||
7: 'BCH công trường (C1-C3)',
|
||||
8: 'HĐ chưa ủy quyền — CEO (5.2)',
|
||||
}
|
||||
|
||||
export interface KhkkListItemDto {
|
||||
id: string
|
||||
maKeHoach: string | null
|
||||
@ -120,6 +154,14 @@ export interface KhkkListItemDto {
|
||||
workflowCode: string | null
|
||||
currentApprovalLevelOrder: number | null
|
||||
totalProposedAmount: number | null
|
||||
// [K2 S164] `ContractSigningPlanListItemDto:63` — nhóm duyệt N1..N8 của phiếu (BE trả
|
||||
// TRÊN CẢ list lẫn detail ⇒ `KhkkDetailDto` kế thừa được, không khai lại).
|
||||
approvalGroup: number
|
||||
// [K2 S164 — vá-8] `:64` Σ `ApprovedAmount` (phiếu chưa duyệt → 0). Có Σ này thì màn
|
||||
// danh sách và màn chi tiết không nói hai con số khác nhau trên CÙNG một phiếu.
|
||||
// 🔴 nullable Ở ĐÂY (không phải ở BE): `KhkkDetailDto extends KhkkListItemDto` mà
|
||||
// `ContractSigningPlanDetailDto` KHÔNG có field này ⇒ trên màn chi tiết nó là undefined.
|
||||
totalApprovedAmount: number | null
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
@ -133,6 +175,12 @@ export interface KhkkLineDto {
|
||||
approvedAmount: number | null
|
||||
contractId: string | null
|
||||
note: string | null
|
||||
// [K2 S164] `ContractSigningPlanLineDto:78-79` — hạng mục SP-002 của dòng.
|
||||
// `tenHangMuc` là DENORM lúc gán (danh mục đổi tên KHÔNG viết lại lịch sử phiếu).
|
||||
// null = dòng chưa gán hạng mục (mồi lúc auto-sinh, `Features.cs:454-455`) — màn chi
|
||||
// tiết hiện "—"; ĐƯỜNG GÁN (Lines-editor) thuộc **K4b**, K2 chỉ hiển thị.
|
||||
catalogEntryId: string | null
|
||||
tenHangMuc: string | null
|
||||
}
|
||||
|
||||
export interface KhkkDossierItemDto {
|
||||
@ -206,10 +254,18 @@ export interface KhkkDetailDto extends KhkkListItemDto {
|
||||
levelOpinions: KhkkLevelOpinionDto[]
|
||||
// `ContractSigningPlanDetailDto:147` — field CUỐI của record BE.
|
||||
workflowSteps: KhkkWorkflowStepDto[]
|
||||
// [K2 S164] `ContractSigningPlanDetailDto:166` — CỜ HIỂN THỊ dẫn xuất từ phase
|
||||
// (`phase == DaDuyet`), KHÔNG phải cột DB. Số đã đóng băng ở tầng dữ liệu bởi
|
||||
// `ApprovedAmount ??=` tại choke-point finalize. Khuôn tên `budgetFrozen` của PE S133.
|
||||
// 🔸 BE **KHÔNG** expose `endedByLevelFinalize` ở DTO lượt này (cột Mig 71 nằm im chờ
|
||||
// K3 tiêu thụ — ghost-window đã khai ở `sub-implbe-k2.md §a`) ⇒ FE không khai field đó.
|
||||
budgetFrozen: boolean
|
||||
}
|
||||
|
||||
// Phiếu PE nguồn đủ điều kiện lập kế hoạch (DaDuyet + chưa có plan sống).
|
||||
// CỐ Ý là TẬP CON của `PurchaseEvaluationListItemDto` BE trả — field thừa bỏ qua runtime.
|
||||
// Phiếu PE nguồn đủ điều kiện lập kế hoạch (DaDuyet + có NCC trúng thầu).
|
||||
// CỐ Ý là TẬP CON của `ApprovedPeAwaitingPlanDto` BE trả — field thừa bỏ qua runtime.
|
||||
// 🔴 [K2 S164 — OG-7] Vị ngữ "chưa có plan sống" ĐÃ BỎ khỏi picker (`Features.cs:1291-1295`):
|
||||
// PE đã có kế hoạch VẪN hiện, kèm `existingGroups`.
|
||||
export interface KhkkPeCandidateDto {
|
||||
id: string
|
||||
maPhieu: string | null
|
||||
@ -218,6 +274,11 @@ export interface KhkkPeCandidateDto {
|
||||
workItemName: string | null
|
||||
winnerSupplierNames: string[]
|
||||
createdAt: string
|
||||
// [K2 S164] `ApprovedPeAwaitingPlanDto:191` — nhóm duyệt của các kế hoạch ĐANG SỐNG
|
||||
// trên PE này (`Phase != TuChoi`). Rỗng = PE chưa có kế hoạch nào. FE dùng để (a) hiện
|
||||
// nút "Tạo thêm cho nhóm khác" và (b) khoá đúng nhóm đã dùng trong ô chọn nhóm —
|
||||
// rào cứng 409 vẫn nằm ở BE (`:395-402`), FE chỉ có nhiệm vụ không mời bấm nhầm.
|
||||
existingGroups: number[]
|
||||
}
|
||||
|
||||
export interface CreateKhkkInput {
|
||||
@ -226,6 +287,10 @@ export interface CreateKhkkInput {
|
||||
peId: string
|
||||
approvalWorkflowId: string
|
||||
ghiChu?: string | null
|
||||
// [K2 S164] Nhóm duyệt N1..N8 (`CreateCommand:337` `int? ApprovalGroup = null`).
|
||||
// Bỏ trống ⇒ BE gán nhóm 1. FE LUÔN gửi (ô chọn có default 1) — gửi tường minh để
|
||||
// người dùng thấy đúng thứ mình chọn, không phụ thuộc default ngầm của BE.
|
||||
approvalGroup?: number | null
|
||||
}
|
||||
|
||||
export interface UpdateKhkkDraftInput {
|
||||
|
||||
@ -39,13 +39,14 @@ public class ContractSigningPlansController(
|
||||
[FromQuery] Guid? projectId = null,
|
||||
[FromQuery] Guid? purchaseEvaluationId = null,
|
||||
[FromQuery] bool pendingMe = false,
|
||||
[FromQuery(Name = "group")] int? group = null,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] bool sortDesc = true,
|
||||
CancellationToken ct = default)
|
||||
=> Ok(await mediator.Send(
|
||||
new ListContractSigningPlansQuery(phase, projectId, purchaseEvaluationId, pendingMe)
|
||||
new ListContractSigningPlansQuery(phase, projectId, purchaseEvaluationId, pendingMe, group)
|
||||
{ Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct));
|
||||
|
||||
/// Inbox "chờ tôi duyệt" — wire sẵn cho W3 (W2 trả rỗng vì chưa có đường trình duyệt).
|
||||
|
||||
@ -54,7 +54,14 @@ public record ContractSigningPlanListItemDto(
|
||||
decimal TotalProposedAmount,
|
||||
DateTime? SlaDeadline,
|
||||
DateTime CreatedAt,
|
||||
DateTime? UpdatedAt);
|
||||
DateTime? UpdatedAt,
|
||||
// [K2 S164] append-CUỐI. `ApprovalGroup` = nhóm duyệt 1..8 (cây 8 folder + badge).
|
||||
// `TotalApprovedAmount` (vá-8): list ĐANG có 2 Σ (PeReference `:711` + Proposed `:713`)
|
||||
// nhưng KHÔNG có Σ của số ĐÃ CHỐT ⇒ sau khi phiếu DaDuyet, màn danh sách vẫn khoe số
|
||||
// đề xuất trong khi màn chi tiết đã khoá theo `ApprovedAmount` = hai màn lệch số trên
|
||||
// cùng một phiếu. Σ này đóng đúng khe đó. Phiếu chưa duyệt → mọi dòng null → 0.
|
||||
int ApprovalGroup,
|
||||
decimal TotalApprovedAmount);
|
||||
|
||||
public record ContractSigningPlanLineDto(
|
||||
Guid Id,
|
||||
@ -65,7 +72,11 @@ public record ContractSigningPlanLineDto(
|
||||
decimal ProposedAmount,
|
||||
decimal? ApprovedAmount,
|
||||
Guid? ContractId,
|
||||
string? Note);
|
||||
string? Note,
|
||||
// [K2 S164] append-CUỐI — hạng mục SP-002 của dòng. `TenHangMuc` là DENORM lúc gán
|
||||
// (đừng join ngược lấy tên mới: danh mục đổi tên không được viết lại lịch sử phiếu).
|
||||
Guid? CatalogEntryId,
|
||||
string? TenHangMuc);
|
||||
|
||||
public record ContractSigningPlanDossierItemDto(
|
||||
Guid Id,
|
||||
@ -144,7 +155,15 @@ public record ContractSigningPlanDetailDto(
|
||||
List<ContractSigningPlanDossierItemDto> DossierItems,
|
||||
List<ContractSigningPlanAttachmentDto> Attachments,
|
||||
List<ContractSigningPlanLevelOpinionDto> LevelOpinions,
|
||||
List<ContractSigningPlanWorkflowStepDto> WorkflowSteps);
|
||||
List<ContractSigningPlanWorkflowStepDto> WorkflowSteps,
|
||||
// [K2 S164] append-CUỐI (sau cả các List — convention của file là append cuối CÙNG,
|
||||
// không chèn giữa; JSON bind theo TÊN nên FE không quan tâm vị trí).
|
||||
int ApprovalGroup,
|
||||
// Display-gate (khuôn tên `budgetFrozen` PE S133): phiếu DaDuyet ⇒ FE đọc `ApprovedAmount`
|
||||
// + badge khoá; trước đó đọc `ProposedAmount`. Đây là CỜ HIỂN THỊ dẫn xuất từ Phase,
|
||||
// KHÔNG phải cột DB — số đã được đóng băng ở tầng dữ liệu bởi `ApprovedAmount ??=` tại
|
||||
// choke-point finalize (`ContractSigningPlanWorkflowService.cs:301-305`).
|
||||
bool BudgetFrozen);
|
||||
|
||||
/// Dòng picker "PE đã duyệt — chờ lập kế hoạch ký kết".
|
||||
public record ApprovedPeAwaitingPlanDto(
|
||||
@ -162,7 +181,14 @@ public record ApprovedPeAwaitingPlanDto(
|
||||
decimal WinnerQuoteTotal,
|
||||
DateTime CreatedAt,
|
||||
DateTime? UpdatedAt,
|
||||
List<string> WinnerSupplierNames); // [W2 S161 — F-6] FE render chip tên NCC trúng thầu (append cuối)
|
||||
List<string> WinnerSupplierNames, // [W2 S161 — F-6] FE render chip tên NCC trúng thầu (append cuối)
|
||||
// [K2 S164 — OG-7 UI-hướng-1] Nhóm duyệt của các kế hoạch ĐANG SỐNG trên PE này
|
||||
// (`Phase != TuChoi`, xoá mềm đã bị global filter loại). Rỗng = PE chưa có kế hoạch nào.
|
||||
// 🔴 Đổi HÌNH DẠNG picker: trước K2, PE có kế hoạch bị LOẠI khỏi danh sách (biến mất);
|
||||
// giờ vẫn hiện KÈM danh sách nhóm đã dùng để FE hiện nút "Tạo thêm cho nhóm khác" và
|
||||
// disable đúng những nhóm đã có. Ẩn phiếu đi thì user không phân biệt được
|
||||
// "PE không đủ điều kiện" với "PE đã dùng hết nhóm" — hai thứ khác nhau.
|
||||
List<int> ExistingGroups);
|
||||
|
||||
public record CreateContractSigningPlanResult(Guid Id, string MaKeHoach, int LineCount);
|
||||
|
||||
@ -170,6 +196,12 @@ public record CreateContractSigningPlanResult(Guid Id, string MaKeHoach, int Lin
|
||||
|
||||
internal static class ContractSigningPlanScope
|
||||
{
|
||||
// [K2 S164] Biên nhóm duyệt N1..N8 (SP-002) + default hướng-1 (OG-1/OG-7 UI-1-1:
|
||||
// FE không gửi nhóm ⇒ phiếu vào nhóm 1 — khớp backfill Mig 71 ApprovalGroup=1).
|
||||
internal const int MinApprovalGroup = 1;
|
||||
internal const int MaxApprovalGroup = 8;
|
||||
internal const int DefaultApprovalGroup = 1;
|
||||
|
||||
/// Phase cho phép SỬA nội dung nháp (header + căn cứ b.8-9).
|
||||
internal static bool IsDraftEditable(ContractSigningPlanPhase phase)
|
||||
=> phase is ContractSigningPlanPhase.DangSoanThao or ContractSigningPlanPhase.TraLai;
|
||||
@ -291,10 +323,18 @@ internal static class ContractSigningPlanScope
|
||||
/// W2 §②-2 viết đúng chữ đó và lane FE dựng body JSON từ CÙNG bản spec ⇒ đổi tên ở đây
|
||||
/// là gãy tích hợp giữa 2 lane. DTO trả về vẫn dùng tên đầy đủ.
|
||||
/// </remarks>
|
||||
/// <param name="ApprovalGroup">
|
||||
/// [K2 S164] Nhóm duyệt N1..N8 của kế hoạch. **NULL = nhóm 1** (mặc định) — CỐ Ý nullable
|
||||
/// thay vì `int` bắt buộc: client hiện tại (`KhkkCreatePage.tsx:73-75` ×2 app) CHƯA gửi
|
||||
/// trường này, mà giá trị bỏ trống của `int` là 0 → rơi thẳng vào 400 "ngoài 1..8" ⇒ đổi
|
||||
/// sang bắt buộc là làm gãy màn tạo phiếu đang chạy. `null → 1` khớp đúng ngữ nghĩa
|
||||
/// backfill của Mig 71 (mọi phiếu cũ = nhóm 1). Client gửi số thì validator 1..8 vẫn có răng.
|
||||
/// </param>
|
||||
public record CreateContractSigningPlanCommand(
|
||||
Guid PeId,
|
||||
Guid ApprovalWorkflowId,
|
||||
string? GhiChu = null) : IRequest<CreateContractSigningPlanResult>;
|
||||
string? GhiChu = null,
|
||||
int? ApprovalGroup = null) : IRequest<CreateContractSigningPlanResult>;
|
||||
|
||||
public class CreateContractSigningPlanCommandValidator : AbstractValidator<CreateContractSigningPlanCommand>
|
||||
{
|
||||
@ -305,6 +345,12 @@ public class CreateContractSigningPlanCommandValidator : AbstractValidator<Creat
|
||||
// MaxLength MATCH EF config `ContractSigningPlanConfiguration.cs:22` (GhiChu = 2000).
|
||||
// EF là source of truth — KHÔNG lấy số từ spec (bài S35).
|
||||
RuleFor(x => x.GhiChu).MaximumLength(2000);
|
||||
// Chỉ soi khi client CÓ gửi (`.When`): null là đường hợp lệ (= nhóm 1), 0 hoặc 9 thì không.
|
||||
RuleFor(x => x.ApprovalGroup)
|
||||
.InclusiveBetween(ContractSigningPlanScope.MinApprovalGroup,
|
||||
ContractSigningPlanScope.MaxApprovalGroup)
|
||||
.When(x => x.ApprovalGroup.HasValue)
|
||||
.WithMessage("Nhóm duyệt phải trong khoảng N1..N8.");
|
||||
}
|
||||
}
|
||||
|
||||
@ -336,15 +382,26 @@ public class CreateContractSigningPlanCommandHandler(
|
||||
if (winners.Count == 0)
|
||||
throw new ConflictException("Phiếu chưa chọn NCC/TP trúng thầu — chưa có dòng giá để lập kế hoạch.");
|
||||
|
||||
// ---- Rào (iii) 1 PE = 1 kế hoạch SỐNG. `TuChoi` KHÔNG tính là sống ⇒ phiếu bị từ
|
||||
// chối vẫn lập lại được. Phiếu xoá mềm cũng không tính (global filter tự loại) ⇒
|
||||
// xoá xong tạo lại được (acceptance §③-B "DELETE → tạo lại → 201").
|
||||
// ---- Rào (iii) [K2 S164 — OG-7] 1 PE = 1 kế hoạch SỐNG **MỖI NHÓM DUYỆT** (trước K2 là
|
||||
// 1 kế hoạch/PE tuyệt đối). Một gói thầu có thể phải ký NHIỀU loại hợp đồng thuộc các
|
||||
// nhóm duyệt khác nhau ⇒ khoá theo PE là khoá nhầm chiều. `TuChoi` KHÔNG tính là sống ⇒
|
||||
// phiếu bị từ chối vẫn lập lại được. Phiếu xoá mềm cũng không tính (global filter tự
|
||||
// loại) ⇒ xoá xong tạo lại được (acceptance §③-B "DELETE → tạo lại → 201").
|
||||
// 🔴 CỐ Ý KHÔNG dựng UNIQUE (PeId, ApprovalGroup) dưới DB — lý do THẬT (gate-K2 F-7 sửa;
|
||||
// bản cũ nói "unique không diễn tả được Phase != TuChoi" là SAI kỹ thuật: filtered index
|
||||
// `WHERE [Phase] <> 3 AND [IsDeleted] = 0` diễn tả được). Đợt này chấp nhận rào handler
|
||||
// (check-then-insert, còn cửa race 2-request-đồng-thời nhỏ) vì Phase là cột mutable +
|
||||
// UI-hướng-1; cần chống race tuyệt đối thì dựng filtered unique ở wave sau.
|
||||
var approvalGroup = request.ApprovalGroup ?? ContractSigningPlanScope.DefaultApprovalGroup;
|
||||
|
||||
var hasLivePlan = await db.ContractSigningPlans
|
||||
.AnyAsync(p => p.PurchaseEvaluationId == pe.Id
|
||||
&& p.ApprovalGroup == approvalGroup
|
||||
&& p.Phase != ContractSigningPlanPhase.TuChoi, ct);
|
||||
if (hasLivePlan)
|
||||
throw new ConflictException(
|
||||
"Phiếu Duyệt NCC này đã có kế hoạch ký kết. Xoá hoặc để bị từ chối kế hoạch cũ trước khi lập lại.");
|
||||
$"Phiếu Duyệt NCC này đã có kế hoạch ký kết cho nhóm duyệt N{approvalGroup}. " +
|
||||
"Chọn nhóm duyệt khác, hoặc xoá/để bị từ chối kế hoạch cũ của nhóm này trước khi lập lại.");
|
||||
|
||||
// ---- Rào (iv) WORKFLOW TYPE — copy trọn 2 nhánh Proposal `:258-267`.
|
||||
await ContractSigningPlanScope.EnsureWorkflowTypeAsync(db, request.ApprovalWorkflowId, ct);
|
||||
@ -371,6 +428,7 @@ public class CreateContractSigningPlanCommandHandler(
|
||||
// `Guid.Empty` cho phiếu PE cũ thiếu drafter = hỏng cả quyền sửa lẫn scope list.
|
||||
DrafterUserId = actorId,
|
||||
Phase = ContractSigningPlanPhase.DangSoanThao,
|
||||
ApprovalGroup = approvalGroup, // [K2 S164] nhóm duyệt của phiếu — chốt lúc TẠO
|
||||
ApprovalWorkflowId = request.ApprovalWorkflowId,
|
||||
GhiChu = string.IsNullOrWhiteSpace(request.GhiChu) ? null : request.GhiChu.Trim(),
|
||||
};
|
||||
@ -390,6 +448,13 @@ public class CreateContractSigningPlanCommandHandler(
|
||||
{
|
||||
ContractSigningPlanId = plan.Id,
|
||||
SupplierId = w.SupplierId,
|
||||
// [K2 S164] Mồi hạng mục = NULL CÓ CHỦ ĐÍCH. Lúc tạo, hệ thống biết NCC nào
|
||||
// trúng thầu nhưng KHÔNG biết dòng đó sẽ ký theo hạng mục SP-002 nào — đoán bừa
|
||||
// một hạng mục là bịa dữ liệu pháp lý. Người soạn gán sau qua
|
||||
// `UpdateContractSigningPlanLineCommand`; cửa ra là SUBMIT-GUARD
|
||||
// (`ContractSigningPlanWorkflowService.SubmitAsync`) chặn trình khi còn dòng NULL.
|
||||
CatalogEntryId = null,
|
||||
TenHangMuc = null,
|
||||
PeReferenceAmount = peReference,
|
||||
// Mồi ProposedAmount = số PE. W2 CHƯA có đường sửa dòng giá (spec §②-2 không
|
||||
// liệt kê command nào cho Line) ⇒ để 0 thì cả phiếu là 0 và không ai sửa được.
|
||||
@ -571,7 +636,11 @@ public class GetContractSigningPlanQueryHandler(IApplicationDbContext db, ICurre
|
||||
l.Id, l.SupplierId,
|
||||
suppliers.TryGetValue(l.SupplierId, out var sup) ? sup.Name : null,
|
||||
sup?.Code,
|
||||
l.PeReferenceAmount, l.ProposedAmount, l.ApprovedAmount, l.ContractId, l.Note))
|
||||
l.PeReferenceAmount, l.ProposedAmount, l.ApprovedAmount, l.ContractId, l.Note,
|
||||
// [K2 S164] Tên hạng mục lấy từ CỘT DENORM của chính dòng, KHÔNG join ngược sang
|
||||
// `ContractCatalogEntries`: danh mục sửa tên/xoá mềm sau ngày ký không được phép
|
||||
// viết lại nội dung phiếu đã lập (khuôn `SignedByFullName` Mig 26).
|
||||
l.CatalogEntryId, l.TenHangMuc))
|
||||
.ToList();
|
||||
|
||||
var dossierItems = plan.DossierItems
|
||||
@ -613,17 +682,29 @@ public class GetContractSigningPlanQueryHandler(IApplicationDbContext db, ICurre
|
||||
plan.CurrentWorkflowStepIndex, plan.CurrentApprovalLevelOrder,
|
||||
plan.SlaDeadline, plan.HoSoLink, plan.GhiChu,
|
||||
plan.CreatedAt, plan.UpdatedAt,
|
||||
lines, dossierItems, attachments, opinions, stepDtos);
|
||||
lines, dossierItems, attachments, opinions, stepDtos,
|
||||
plan.ApprovalGroup,
|
||||
// [K2 S164] `BudgetFrozen` = CỜ HIỂN THỊ dẫn xuất từ Phase, KHÔNG phải cột DB. Số đã
|
||||
// được đóng băng ở tầng dữ liệu bởi `ApprovedAmount ??=` tại choke-point finalize
|
||||
// (`ContractSigningPlanWorkflowService.ApplyApprovedValuesOnFinalize`); cờ này chỉ
|
||||
// bảo FE "đọc `ApprovedAmount` + khoá ô" thay vì đọc `ProposedAmount`.
|
||||
plan.Phase == ContractSigningPlanPhase.DaDuyet);
|
||||
}
|
||||
}
|
||||
|
||||
// ======================= Region 4: LIST / INBOX / ĐÃ XOÁ ============================
|
||||
|
||||
/// <param name="ApprovalGroup">
|
||||
/// [K2 S164] Lọc theo nhóm duyệt N1..N8. K2 là CHỦ tham số này; cây 8 folder (K5) và các
|
||||
/// màn con (K4b) chỉ TIÊU THỤ — thêm chỗ lọc thứ hai ở tầng trên là đẻ hai định nghĩa
|
||||
/// "thuộc nhóm N" rồi lệch nhau. `null` = mọi nhóm (giữ nguyên hành vi trước K2).
|
||||
/// </param>
|
||||
public record ListContractSigningPlansQuery(
|
||||
ContractSigningPlanPhase? Phase = null,
|
||||
Guid? ProjectId = null,
|
||||
Guid? PurchaseEvaluationId = null,
|
||||
bool PendingMe = false) : PagedRequest, IRequest<PagedResult<ContractSigningPlanListItemDto>>;
|
||||
bool PendingMe = false,
|
||||
int? ApprovalGroup = null) : PagedRequest, IRequest<PagedResult<ContractSigningPlanListItemDto>>;
|
||||
|
||||
public class ListContractSigningPlansQueryHandler(IApplicationDbContext db, ICurrentUser currentUser)
|
||||
: IRequestHandler<ListContractSigningPlansQuery, PagedResult<ContractSigningPlanListItemDto>>
|
||||
@ -653,6 +734,10 @@ public class ListContractSigningPlansQueryHandler(IApplicationDbContext db, ICur
|
||||
if (request.ProjectId is not null) q = q.Where(p => p.ProjectId == request.ProjectId);
|
||||
if (request.PurchaseEvaluationId is not null)
|
||||
q = q.Where(p => p.PurchaseEvaluationId == request.PurchaseEvaluationId);
|
||||
// [K2 S164] Lọc nhóm duyệt — đặt CÙNG chỗ với các vế lọc khác, SAU vế IDOR ở trên:
|
||||
// lọc không bao giờ được là thứ NỚI phạm vi thấy được, chỉ được thu hẹp.
|
||||
if (request.ApprovalGroup is not null)
|
||||
q = q.Where(p => p.ApprovalGroup == request.ApprovalGroup);
|
||||
|
||||
if (request.PendingMe)
|
||||
{
|
||||
@ -711,7 +796,14 @@ public class ListContractSigningPlansQueryHandler(IApplicationDbContext db, ICur
|
||||
.Sum(l => (decimal?)l.PeReferenceAmount) ?? 0m,
|
||||
db.ContractSigningPlanLines.Where(l => l.ContractSigningPlanId == p.Id)
|
||||
.Sum(l => (decimal?)l.ProposedAmount) ?? 0m,
|
||||
p.SlaDeadline, p.CreatedAt, p.UpdatedAt))
|
||||
p.SlaDeadline, p.CreatedAt, p.UpdatedAt,
|
||||
p.ApprovalGroup,
|
||||
// [K2 S164 — vá-8] Σ số ĐÃ CHỐT. Thiếu Σ này thì phiếu `DaDuyet` khoe số ĐỀ XUẤT
|
||||
// ở màn danh sách trong khi màn chi tiết đã khoá theo `ApprovedAmount` ⇒ hai màn
|
||||
// nói hai con số trên CÙNG một phiếu. `ApprovedAmount` vốn nullable ⇒ phiếu chưa
|
||||
// duyệt cho ra `null` → `0`, đúng nghĩa "chưa chốt đồng nào".
|
||||
db.ContractSigningPlanLines.Where(l => l.ContractSigningPlanId == p.Id)
|
||||
.Sum(l => l.ApprovedAmount) ?? 0m))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return new PagedResult<ContractSigningPlanListItemDto>(items, total, page, pageSize);
|
||||
@ -1198,8 +1290,11 @@ public class ListApprovedPeAwaitingPlanQueryHandler(IApplicationDbContext db)
|
||||
from wi in wij.DefaultIfEmpty()
|
||||
where e.Phase == PurchaseEvaluationPhase.DaDuyet
|
||||
&& db.PurchaseEvaluationSuppliers.Any(sw => sw.PurchaseEvaluationId == e.Id && sw.IsWinner)
|
||||
&& !db.ContractSigningPlans.Any(pl => pl.PurchaseEvaluationId == e.Id
|
||||
&& pl.Phase != ContractSigningPlanPhase.TuChoi)
|
||||
// 🔴 [K2 S164 — OG-7] ĐÃ GỠ vế `&& !db.ContractSigningPlans.Any(...)`: rào không còn
|
||||
// là "1 PE = 1 kế hoạch" mà là "1 PE = 1 kế hoạch MỖI NHÓM" ⇒ loại PE-đã-có-phiếu
|
||||
// khỏi picker sẽ chặn luôn đường lập cho nhóm khác (chính là năng lực K2 mở ra).
|
||||
// Vế đó chuyển thành DỮ LIỆU trả về (`ExistingGroups` dưới) để FE khoá đúng nhóm
|
||||
// đã dùng — rào cứng 409 vẫn nằm ở Create, picker chỉ có nhiệm vụ không mời nhầm.
|
||||
orderby e.UpdatedAt ?? e.CreatedAt descending
|
||||
select new ApprovedPeAwaitingPlanDto(
|
||||
e.Id, e.MaPhieu, e.TenGoiThau,
|
||||
@ -1223,6 +1318,14 @@ public class ListApprovedPeAwaitingPlanQueryHandler(IApplicationDbContext db)
|
||||
where sw.PurchaseEvaluationId == e.Id && sw.IsWinner
|
||||
join sup in db.Suppliers on sw.SupplierId equals sup.Id
|
||||
orderby sup.Name
|
||||
select sup.Name).ToList())).ToListAsync(ct);
|
||||
select sup.Name).ToList(),
|
||||
// [K2 S164] Nhóm đã dùng — CÙNG HÌNH DẠNG subquery-ToList với vế trên (khuôn đã
|
||||
// chạy thật), KHÔNG `Distinct()`: rào Create bảo đảm mỗi (PE, nhóm) tối đa 1 phiếu
|
||||
// sống ⇒ danh sách vốn đã không trùng; thêm Distinct chỉ để SQL sinh phức tạp hơn.
|
||||
(from pl in db.ContractSigningPlans
|
||||
where pl.PurchaseEvaluationId == e.Id
|
||||
&& pl.Phase != ContractSigningPlanPhase.TuChoi
|
||||
orderby pl.ApprovalGroup
|
||||
select pl.ApprovalGroup).ToList())).ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,6 +30,26 @@ public class ContractSigningPlan : AuditableEntity
|
||||
|
||||
public ContractSigningPlanPhase Phase { get; set; } = ContractSigningPlanPhase.DangSoanThao;
|
||||
|
||||
// [K2 Mig 71 — S164] NHÓM DUYỆT 1..8 (N1..N8 theo SOL-CCM-SP-002). 1 phiếu = 1 nhóm,
|
||||
// N hạng mục CÙNG nhóm. Phiếu cũ backfill = 1 (Mig 71 `Sql UPDATE`) ⇒ COUNT(phiếu) ==
|
||||
// Σ COUNT 8 folder, không có phiếu nào rơi ngoài cây.
|
||||
// 🔴 K2 là CHỦ DUY NHẤT của cột này (QĐ-3/spec §②-3): K3 chỉ ĐỌC (map Code `KHKK-N{n}`
|
||||
// ↔ group + validator phiếu-pin-đúng-nhóm). Range 1..8 gác bằng validator, KHÔNG CHECK
|
||||
// constraint (khuôn repo: `ContractCatalogEntry.ApprovalGroup` cũng vậy).
|
||||
// 🔴 [gate-K2 F-9] Mig 71 để DEFAULT constraint 0 (AddColumn) — app LUÔN set giá trị;
|
||||
// CẤM INSERT SQL thô (họ #84): SQL thô sẽ ra nhóm 0 ngoài dải 1..8, rơi khỏi cây 8 folder câm lặng.
|
||||
public int ApprovalGroup { get; set; } = 1;
|
||||
|
||||
// [K2 Mig 71 — S164, forward-provision cho K3] Cờ RUNTIME "phiếu kết thúc SỚM bằng
|
||||
// level-finalize" (approver ở Cấp có `AllowApproverFinalize` tick kết-thúc-tại-đây).
|
||||
// 🔴 GHOST-WINDOW KHAI TRƯỚC: K2 ĐẺ cột, K3 TIÊU THỤ. Từ lúc K2 land tới lúc K3 land,
|
||||
// cột này NẰM IM = luôn false, KHÔNG có site nào ghi (service KHKK hiện chỉ có đúng một
|
||||
// nhánh set DaDuyet — `ContractSigningPlanWorkflowService.cs:277-278` — và nhánh đó là
|
||||
// đường THƯỜNG, không phải finalize sớm). Cố ý gộp vào Mig 71 để K3 = 0-mig
|
||||
// (spec §②-9 + vá-7 item-6). Đọc RUNTIME chứ không suy từ CONFIG = né tái diễn #78
|
||||
// (PE S97: phiếu opt-out vẫn lên CEO mà display suy từ cờ config nên báo sai).
|
||||
public bool EndedByLevelFinalize { get; set; }
|
||||
|
||||
// Workflow V2 pin (ApplicableType = ContractSigningPlan = 10). Con-trỏ ĐÔI mirror PE:
|
||||
// StepIndex 0-based (Bước) + LevelOrder 1-based (Cấp trong Bước). Null khi terminal.
|
||||
public Guid? ApprovalWorkflowId { get; set; }
|
||||
|
||||
@ -14,6 +14,18 @@ public class ContractSigningPlanLine : AuditableEntity
|
||||
public Guid ContractSigningPlanId { get; set; }
|
||||
public Guid SupplierId { get; set; } // loose-Guid (KHÔNG FK vật lý sang Suppliers)
|
||||
|
||||
// [K2 Mig 71 — S164] Hạng mục danh mục SP-002 của dòng này (→ `ContractCatalogEntries.Id`).
|
||||
// 🔴 loose-Guid KHÔNG FK VẬT LÝ — mirror `PurchaseEvaluation.WorkItemId` (Mig 49) và
|
||||
// chính `SupplierId` ngay trên: convention module là mọi con trỏ ra ngoài module đều
|
||||
// loose. Thêm FK thật ở đây = phá khuôn + chặn soft-delete dòng danh mục.
|
||||
// NULL = dòng legacy (phiếu lập trước K2) hoặc dòng vừa auto-sinh chưa gán hạng mục.
|
||||
// Trình duyệt thì BẮT BUỘC có (submit-guard `ContractSigningPlanWorkflowService`).
|
||||
public Guid? CatalogEntryId { get; set; }
|
||||
|
||||
// Denorm tên hạng mục TẠI THỜI ĐIỂM GÁN — danh mục đổi tên sau KHÔNG viết lại lịch sử
|
||||
// phiếu (khuôn `SignedByFullName` Mig 26). Đọc để hiển thị; đừng join ngược lấy tên mới.
|
||||
public string? TenHangMuc { get; set; }
|
||||
|
||||
// SNAPSHOT tổng báo giá của NCC này trên phiếu PE (SUM Quote.IsSelected) tại thời
|
||||
// điểm TẠO kế hoạch — chụp 1 lần, KHÔNG đọc live (PE sửa sau không được làm trôi
|
||||
// số của kế hoạch đã lập; cùng triết lý freeze ngân sách Mig 67).
|
||||
|
||||
@ -28,6 +28,17 @@ public class ContractSigningPlanConfiguration : IEntityTypeConfiguration<Contrac
|
||||
b.HasIndex(x => x.MaKeHoach).IsUnique().HasFilter("[MaKeHoach] IS NOT NULL");
|
||||
|
||||
b.HasIndex(x => x.PurchaseEvaluationId);
|
||||
|
||||
// [K2 Mig 71 — S164] Rào "1 PE = 1 phiếu" đổi thành "(PE × NHÓM DUYỆT)" [OG-7] ⇒ 1 PE
|
||||
// được N phiếu miễn khác nhóm. 🔴 CỐ Ý **KHÔNG** dựng unique (PeId, ApprovalGroup) —
|
||||
// lý do THẬT (gate-K2 F-7 sửa; bản cũ "unique không diễn tả được Phase != TuChoi" SAI:
|
||||
// filtered index `WHERE [Phase] <> 3 AND [IsDeleted] = 0` diễn tả được). Đợt này chấp
|
||||
// nhận rào handler (check-then-insert, còn cửa race nhỏ) vì Phase mutable + UI-hướng-1;
|
||||
// chống race tuyệt đối = dựng filtered unique ở wave sau. Rào sống ở handler
|
||||
// (`ContractSigningPlanFeatures.cs` Region 1); index dưới đây chỉ để lọc/đếm theo nhóm
|
||||
// (list `?group=` + cây 8 folder) cho nhanh.
|
||||
b.HasIndex(x => new { x.ApprovalGroup, x.Phase });
|
||||
|
||||
b.HasIndex(x => x.ProjectId);
|
||||
b.HasIndex(x => x.DepartmentId);
|
||||
b.HasIndex(x => x.DrafterUserId);
|
||||
|
||||
@ -18,11 +18,27 @@ public class ContractSigningPlanLineConfiguration : IEntityTypeConfiguration<Con
|
||||
b.Property(x => x.ProposedAmount).HasPrecision(18, 2);
|
||||
b.Property(x => x.ApprovedAmount).HasPrecision(18, 2);
|
||||
b.Property(x => x.Note).HasMaxLength(1000);
|
||||
// MaxLength khớp `ContractCatalogEntry.TenVi` (500, `ContractCatalogEntryConfiguration.cs`)
|
||||
// — denorm mà hẹp hơn nguồn thì tên dài nhất (A3-09 ~150 ký tự) vẫn lọt, nhưng để
|
||||
// BẰNG nguồn thì không bao giờ phải cắt. Validator lấy số từ ĐÂY (EF = source of truth).
|
||||
b.Property(x => x.TenHangMuc).HasMaxLength(500);
|
||||
|
||||
b.HasIndex(x => new { x.ContractSigningPlanId, x.SupplierId })
|
||||
// [K2 Mig 71 — S164] UNIQUE đổi (Plan, Supplier) → (Plan, Supplier, CatalogEntry):
|
||||
// 1 NCC được nhiều dòng trên cùng phiếu, mỗi dòng 1 hạng mục khác nhau.
|
||||
// 🔴 BẪY NULL-UNIQUE (vá-9): trong UNIQUE index SQL Server, NULL == NULL ⇒ HAI dòng
|
||||
// cùng (Plan, Supplier) mà đều `CatalogEntryId NULL` sẽ VI PHẠM index này. Dữ liệu cũ
|
||||
// an toàn (unique cũ bảo đảm ≤ 1 dòng/cặp ⇒ tối đa 1 NULL/cặp). Với dòng phát sinh
|
||||
// MỚI, mắt xích chặn KHÔNG PHẢI index này (index cũ đã bị DROP trong chính Mig 71)
|
||||
// mà là (gate-K2 F-2 — nói đúng THÌ): (1) SUBMIT-GUARD chặn null trước khi trình
|
||||
// (`ContractSigningPlanWorkflowService`) — lớp DUY NHẤT ĐANG TỒN TẠI; +
|
||||
// (2) [K4b — THÌ TƯƠNG LAI] validator đường gán hạng mục khi Lines-editor ra đời.
|
||||
// 🔴 CỐ Ý KHÔNG thêm `AND [CatalogEntryId] IS NOT NULL` vào filter: làm vậy là thả
|
||||
// trôi vô hạn dòng trùng (Plan, Supplier, NULL).
|
||||
b.HasIndex(x => new { x.ContractSigningPlanId, x.SupplierId, x.CatalogEntryId })
|
||||
.IsUnique()
|
||||
.HasFilter("[IsDeleted] = 0");
|
||||
b.HasIndex(x => x.SupplierId);
|
||||
b.HasIndex(x => x.CatalogEntryId);
|
||||
b.HasIndex(x => x.ContractId);
|
||||
|
||||
b.HasOne(x => x.ContractSigningPlan)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,144 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
// [K2 Mig 71 — S164 2026-07-31] KHKK mang HẠNG MỤC + NHÓM DUYỆT (SOL-CCM-SP-002).
|
||||
// 6 item, KHÔNG bảng mới (96 giữ nguyên), 0 RenameColumn:
|
||||
// 1. ContractSigningPlans +ApprovalGroup int — nhóm duyệt 1..8, BACKFILL = 1
|
||||
// 2. ContractSigningPlans +EndedByLevelFinalize bit default 0 — cờ RUNTIME (xem ghi chú 🔴)
|
||||
// 3. ContractSigningPlanLines +CatalogEntryId uniqueidentifier NULL — loose-Guid, KHÔNG FK
|
||||
// 4. ContractSigningPlanLines +TenHangMuc nvarchar(500) NULL — denorm tên lúc gán
|
||||
// 5. UNIQUE (Plan, Supplier) → (Plan, Supplier, CatalogEntry) filtered [IsDeleted]=0 (#57)
|
||||
// 6. +2 index tra cứu: (ApprovalGroup, Phase) trên header · CatalogEntryId trên dòng
|
||||
//
|
||||
// 🔴 GHOST-WINDOW KHAI TRƯỚC (item 2): `EndedByLevelFinalize` do K2 ĐẺ, K3 TIÊU THỤ.
|
||||
// Từ Mig 71 tới khi K3 land, cột này KHÔNG có site nào ghi ⇒ luôn = 0. Cố ý gộp vào
|
||||
// đây để K3 = 0-mig (spec §②-9 + vá-7 item-6). Ai đọc cột này TRƯỚC K3 sẽ thấy "mọi
|
||||
// phiếu đều không kết thúc sớm" — ĐÚNG với hiện trạng (service KHKK hiện chỉ có đúng
|
||||
// MỘT nhánh set DaDuyet, là đường thường), không phải dữ liệu hỏng.
|
||||
//
|
||||
// 🔴 BẤT-BIẾN TIỀN: migration này có **0 câu lệnh** chạm `PeReferenceAmount` /
|
||||
// `ProposedAmount` / `ApprovedAmount` ⇒ Σ trước == Σ sau là đúng theo CẤU TRÚC, không
|
||||
// chỉ theo phép đo. (Đo local: cả Dev lẫn Design đều 0 dòng KHKK ⇒ Σ = tập RỖNG hai
|
||||
// phía — khai thẳng vì "trích rỗng đọc thành sạch" là bẫy, phép đo prod mới có răng.)
|
||||
public partial class AddKhkkApprovalGroupCatalogAndFinalizeRuntime : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// 🔴 UNIQUE CŨ CHẾT TỪ ĐÂY (item 5). Sau lệnh này, mắt xích chặn "2 dòng cùng
|
||||
// (Plan, Supplier) đều CatalogEntryId NULL" KHÔNG còn là index nữa — trong UNIQUE
|
||||
// index SQL Server NULL == NULL nên index MỚI vẫn chặn cặp NULL-trùng, nhưng nó
|
||||
// chặn bằng lỗi 2601 lúc SaveChanges (500 khó hiểu), không phải bằng nghiệp vụ.
|
||||
// Mắt xích THẬT do tầng ứng dụng giữ (vá-9) — hiện 1 lớp, K4b thêm lớp 2 (gate-K2 F-2):
|
||||
// (a) SUBMIT-GUARD `ContractSigningPlanWorkflowService.SubmitAsync` — mọi dòng
|
||||
// phải có hạng mục trước khi trình duyệt (ĐANG TỒN TẠI);
|
||||
// (b) [K4b — THÌ TƯƠNG LAI] validator đường gán hạng mục của Lines-editor:
|
||||
// `CatalogEntryId` BẮT BUỘC trên dòng mới qua API.
|
||||
// Dữ liệu cũ AN TOÀN không cần backfill dòng: unique cũ đã bảo đảm ≤ 1 dòng cho
|
||||
// mỗi cặp (Plan, Supplier) ⇒ tối đa 1 dòng NULL mỗi cặp, không thể đụng nhau.
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ContractSigningPlanLines_ContractSigningPlanId_SupplierId",
|
||||
table: "ContractSigningPlanLines");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ApprovalGroup",
|
||||
table: "ContractSigningPlans",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
// BACKFILL = 1 (item 1). Mọi phiếu tồn tại trước K2 thuộc NHÓM 1 — acceptance
|
||||
// `COUNT(phiếu) == Σ COUNT 8 folder` (không phiếu nào rơi ngoài cây 8 nhóm).
|
||||
// Khuôn Mig 64 `Sql("UPDATE Suppliers SET IsPublic=1")`: `Sql()` trần, KHÔNG
|
||||
// provider-guard (khuôn Mig 61/65/67) — SQLite trong test dùng EnsureCreated nên
|
||||
// không chạy migration, không có nhánh nào cần rẽ. Vô điều kiện (KHÔNG
|
||||
// `WHERE ApprovalGroup = 0`) để phép đo có răng: rowcount phải == số phiếu cũ;
|
||||
// nếu viết WHERE thì backfill hụt sẽ trông y hệt backfill thành công.
|
||||
migrationBuilder.Sql("UPDATE [ContractSigningPlans] SET [ApprovalGroup] = 1;");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EndedByLevelFinalize",
|
||||
table: "ContractSigningPlans",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "CatalogEntryId",
|
||||
table: "ContractSigningPlanLines",
|
||||
type: "uniqueidentifier",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TenHangMuc",
|
||||
table: "ContractSigningPlanLines",
|
||||
type: "nvarchar(500)",
|
||||
maxLength: 500,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ContractSigningPlans_ApprovalGroup_Phase",
|
||||
table: "ContractSigningPlans",
|
||||
columns: new[] { "ApprovalGroup", "Phase" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ContractSigningPlanLines_CatalogEntryId",
|
||||
table: "ContractSigningPlanLines",
|
||||
column: "CatalogEntryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ContractSigningPlanLines_ContractSigningPlanId_SupplierId_CatalogEntryId",
|
||||
table: "ContractSigningPlanLines",
|
||||
columns: new[] { "ContractSigningPlanId", "SupplierId", "CatalogEntryId" },
|
||||
unique: true,
|
||||
filter: "[IsDeleted] = 0");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ContractSigningPlans_ApprovalGroup_Phase",
|
||||
table: "ContractSigningPlans");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ContractSigningPlanLines_CatalogEntryId",
|
||||
table: "ContractSigningPlanLines");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ContractSigningPlanLines_ContractSigningPlanId_SupplierId_CatalogEntryId",
|
||||
table: "ContractSigningPlanLines");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ApprovalGroup",
|
||||
table: "ContractSigningPlans");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EndedByLevelFinalize",
|
||||
table: "ContractSigningPlans");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CatalogEntryId",
|
||||
table: "ContractSigningPlanLines");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TenHangMuc",
|
||||
table: "ContractSigningPlanLines");
|
||||
|
||||
// [gate-K2 F-8] Down chỉ AN TOÀN khi CHƯA có dữ liệu tách-dòng (pre-K4b): một khi 1 NCC
|
||||
// có nhiều dòng/phiếu (khác hạng mục), DropColumn ở trên đã xoá chiều phân biệt và
|
||||
// CreateIndex 2-cột dưới đây FAIL vì trùng (Plan, Supplier). Rollback lúc đó = quyết định
|
||||
// owner + dọn/gộp dữ liệu trước, không chạy Down mù.
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ContractSigningPlanLines_ContractSigningPlanId_SupplierId",
|
||||
table: "ContractSigningPlanLines",
|
||||
columns: new[] { "ContractSigningPlanId", "SupplierId" },
|
||||
unique: true,
|
||||
filter: "[IsDeleted] = 0");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -318,6 +318,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int>("ApprovalGroup")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<Guid?>("ApprovalWorkflowId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
@ -345,6 +348,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
b.Property<Guid>("DrafterUserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<bool>("EndedByLevelFinalize")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("GhiChu")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
@ -394,6 +400,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasIndex("PurchaseEvaluationId");
|
||||
|
||||
b.HasIndex("ApprovalGroup", "Phase");
|
||||
|
||||
b.HasIndex("Phase", "IsDeleted");
|
||||
|
||||
b.ToTable("ContractSigningPlans", (string)null);
|
||||
@ -710,6 +718,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<Guid?>("CatalogEntryId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid?>("ContractId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
@ -746,6 +757,10 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
b.Property<Guid>("SupplierId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("TenHangMuc")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
@ -754,11 +769,13 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CatalogEntryId");
|
||||
|
||||
b.HasIndex("ContractId");
|
||||
|
||||
b.HasIndex("SupplierId");
|
||||
|
||||
b.HasIndex("ContractSigningPlanId", "SupplierId")
|
||||
b.HasIndex("ContractSigningPlanId", "SupplierId", "CatalogEntryId")
|
||||
.IsUnique()
|
||||
.HasFilter("[IsDeleted] = 0");
|
||||
|
||||
|
||||
@ -164,6 +164,26 @@ public class ContractSigningPlanWorkflowService(
|
||||
|
||||
await EnsureCanSubmitAsync(plan, actorId, isAdmin, ct);
|
||||
|
||||
// ── [K2 S164 — vá-5 spec-cụm1, THẮNG nền K2.d vế null] Rào HẠNG MỤC trước khi trình:
|
||||
// (i) mọi dòng phải đã gán hạng mục — auto-gen mồi CatalogEntryId=null chỉ hợp lệ trong
|
||||
// nháp; nền `:126` định CHO-QUA null-legacy nhưng vá-5 chốt CHẶN cho NHẤT QUÁN.
|
||||
// Ghost-window K2→K4b khai chủ-đích: phiếu cũ bị Trả-lại sẽ kẹt trình tới khi
|
||||
// Lines-editor (K4b) land — message chỉ rõ việc cần làm.
|
||||
// (ii) hạng mục phải CÙNG nhóm duyệt với phiếu (code mẫu nền K2.d) — chốt CUỐI chống
|
||||
// data cũ/lách API; validator đường-gán K4b là fail-fast tầng trước.
|
||||
if (plan.Lines.Any(l => l.CatalogEntryId == null))
|
||||
throw new ConflictException(
|
||||
"Còn dòng chưa gán hạng mục công việc (danh mục SP-002) — gán hạng mục cho mọi dòng trước khi trình.");
|
||||
// [gate-K2 F-1] Hỏi câu ĐÚNG: "MỌI id resolve tới hạng mục CÒN SỐNG + ĐÚNG nhóm" — không
|
||||
// phải "có id nào SAI nhóm". Query filter !IsDeleted nuốt hạng mục xoá mềm ⇒ Any(sai nhóm)
|
||||
// = false cho cả Guid rác lẫn hạng mục đã xoá (vắng-mặt đọc thành sạch). Đếm-so-khớp bịt cả hai.
|
||||
var catalogIds = plan.Lines.Select(l => l.CatalogEntryId!.Value).Distinct().ToList();
|
||||
var okCount = await db.ContractCatalogEntries
|
||||
.CountAsync(c => catalogIds.Contains(c.Id) && c.ApprovalGroup == plan.ApprovalGroup, ct);
|
||||
if (okCount != catalogIds.Count)
|
||||
throw new ConflictException(
|
||||
$"Có hạng mục không hợp lệ (đã xoá / không tồn tại) hoặc không thuộc nhóm duyệt của phiếu — mọi dòng phải cùng nhóm N{plan.ApprovalGroup}.");
|
||||
|
||||
var steps = await LoadStepsAsync(awId, ct);
|
||||
var firstLevelGroup = steps[0].Levels.Where(l => l.Order == 1).ToList();
|
||||
if (firstLevelGroup.Count == 0)
|
||||
|
||||
@ -7,6 +7,7 @@ using SolutionErp.Application.ContractSigningPlans.Services;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.ContractSigningPlans;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Master.Catalogs; // [K2 S164] fixture gán hạng mục cho line trước trình
|
||||
using SolutionErp.Infrastructure.Persistence;
|
||||
using SolutionErp.Infrastructure.Persistence.Interceptors;
|
||||
using SolutionErp.Infrastructure.Services;
|
||||
@ -196,6 +197,22 @@ public class ContractSigningPlanApprovalTests
|
||||
// Seed dưới danh nghĩa NGƯỜI SOẠN ⇒ interceptor ghi `CreatedBy = drafter` (xem ghi chú (1)).
|
||||
await using var db = f.NewDb(drafter);
|
||||
|
||||
// [K2 S164 — vá-5] SubmitAsync nay CHẶN dòng chưa gán hạng mục + hạng mục khác nhóm.
|
||||
// Fixture gán MỌI line vào 1 hạng mục CÙNG NHÓM với phiếu (entity default ApprovalGroup=1)
|
||||
// — mirror phiếu "đã gán đủ" trước trình; Code random né unique-filtered giữa các plan.
|
||||
var catalog = new ContractCatalogEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = "T-" + Guid.NewGuid().ToString("N")[..8],
|
||||
TenVi = "Hạng mục test W3",
|
||||
GroupCode = "B1",
|
||||
ApprovalGroup = 1,
|
||||
SignerRole = "TP.PRO",
|
||||
IsActive = true,
|
||||
SortOrder = 1,
|
||||
};
|
||||
db.ContractCatalogEntries.Add(catalog);
|
||||
|
||||
var plan = new ContractSigningPlan
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
@ -219,6 +236,8 @@ public class ContractSigningPlanApprovalTests
|
||||
Id = Guid.NewGuid(),
|
||||
ContractSigningPlanId = plan.Id,
|
||||
SupplierId = Guid.NewGuid(), // loose-Guid (`ContractSigningPlanLine.cs:15`)
|
||||
CatalogEntryId = catalog.Id, // [K2] đã gán hạng mục ⇒ qua submit-guard
|
||||
TenHangMuc = catalog.TenVi,
|
||||
PeReferenceAmount = l.PeReference,
|
||||
ProposedAmount = l.Proposed,
|
||||
ApprovedAmount = l.Approved,
|
||||
@ -230,6 +249,55 @@ public class ContractSigningPlanApprovalTests
|
||||
return plan.Id;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// [gate-K2 F-1] Submit-guard phải hỏi "MỌI hạng mục resolve CÒN SỐNG + đúng nhóm".
|
||||
// Hạng mục xoá mềm biến khỏi query (filter !IsDeleted) — guard kiểu Any(sai-nhóm)
|
||||
// đọc vắng-mặt thành sạch và cho TRÌNH QUA. Test khoá: đếm-so-khớp phải CHẶN.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task Submit_HangMucDaXoaMem_ThiBiChan()
|
||||
{
|
||||
using var f = new KhkkWorkflowFixture();
|
||||
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
var a = await AddUserAsync(f, "PMH cấp 1");
|
||||
var wf = await SeedWorkflowAsync(f, "QT-KHKK-F1", steps: [[[a.Id]]]);
|
||||
var planId = await SeedPlanAsync(f, drafter, wf, "KH-F1", ContractSigningPlanPhase.DangSoanThao,
|
||||
null, null, [new LineSeed(100m, 90m)]);
|
||||
|
||||
// Xoá mềm hạng mục line đang trỏ (app-layer set cờ — mirror khuôn SchemaTests:
|
||||
// TestApplicationDbContext không wire AuditingInterceptor cho Remove()).
|
||||
await using (var db = f.NewDb(TestCurrentUser.System()))
|
||||
{
|
||||
var catIds = await db.ContractSigningPlanLines
|
||||
.Where(l => l.ContractSigningPlanId == planId)
|
||||
.Select(l => l.CatalogEntryId!.Value).Distinct().ToListAsync(CancellationToken.None);
|
||||
var cats = await db.ContractCatalogEntries.Where(c => catIds.Contains(c.Id))
|
||||
.ToListAsync(CancellationToken.None);
|
||||
foreach (var c in cats) { c.IsDeleted = true; c.DeletedAt = DateTime.UtcNow; }
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var act = async () => await ActAsync(f, drafter, planId, "submit");
|
||||
await act.Should().ThrowAsync<ConflictException>(
|
||||
"hạng mục đã xoá mềm không resolve được — guard đếm-so-khớp phải chặn trình");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// [gate-K2 F-6] Khoá TÊN THAM SỐ HTTP của bộ lọc nhóm: controller bind `?group=`
|
||||
// (một chỗ nền viết `?approvalGroup=` — gõ theo đó là lọc câm trả tất, HTTP 200).
|
||||
// Reflection giữ tên ở tầng API để drift kêu trong CI thay vì sai im lặng trên prod.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public void List_ThamSoLocNhom_BindDungTenGroup()
|
||||
{
|
||||
var p = typeof(SolutionErp.Api.Controllers.ContractSigningPlansController)
|
||||
.GetMethod("List")!.GetParameters().Single(x => x.Name == "group");
|
||||
var attr = (Microsoft.AspNetCore.Mvc.FromQueryAttribute)p
|
||||
.GetCustomAttributes(typeof(Microsoft.AspNetCore.Mvc.FromQueryAttribute), false).Single();
|
||||
attr.Name.Should().Be("group",
|
||||
"controller expose ?group= (spec-tổng K2) — đổi tên là vỡ FE K4b lẫn acceptance K8");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Act / Read helpers — gọi service THẬT, KHÔNG mock.
|
||||
// ===================================================================
|
||||
|
||||
@ -0,0 +1,824 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces; // ICurrentUser — bơm cho AuditingInterceptor
|
||||
using SolutionErp.Application.Common.Models; // PagedResult / PagedRequest
|
||||
using SolutionErp.Application.ContractSigningPlans;
|
||||
using SolutionErp.Application.ContractSigningPlans.Services; // ContractSigningPlanTransitionResult
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.ContractSigningPlans;
|
||||
using SolutionErp.Domain.Identity; // AppRoles + User
|
||||
using SolutionErp.Domain.Master;
|
||||
using SolutionErp.Domain.Master.Catalogs; // ContractCatalogEntry (K1 — Mig 70)
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
using SolutionErp.Infrastructure.Persistence;
|
||||
using SolutionErp.Infrastructure.Persistence.Interceptors;
|
||||
using SolutionErp.Infrastructure.Services;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
|
||||
// [K2 KHKK — S164 2026-07-31] Phiếu Kế hoạch ký kết mang HẠNG MỤC (danh mục SP-002) + NHÓM
|
||||
// DUYỆT N1..N8 (Mig 71) — test-AFTER (`docs/rules.md §7`: BE đã land, spec ĐÓNG BĂNG).
|
||||
//
|
||||
// Code under test (đo lại trên đĩa, KHÔNG chép số từ sổ):
|
||||
// submit-guard hạng mục `ContractSigningPlanWorkflowService.cs:174-182` (vá-5 spec-cụm1)
|
||||
// rào (PeId, ApprovalGroup) `ContractSigningPlanFeatures.cs:393-402`
|
||||
// list `?group=` `ContractSigningPlanFeatures.cs:737-738`
|
||||
// Σ đã-chốt (vá-8) `ContractSigningPlanFeatures.cs:803-804`
|
||||
// gate ghi nháp `ContractSigningPlanFeatures.cs:229-237`
|
||||
// site gán-Phase-qua-BIẾN `ContractSigningPlanWorkflowService.cs:327-359`
|
||||
//
|
||||
// ── 3 quyết định hạ tầng (mỗi cái là ĐIỀU KIỆN để phép đo có nghĩa) ─────────────
|
||||
//
|
||||
// (1) File RIÊNG + fixture RIÊNG (mirror `ContractSigningPlanCrudTests.cs:49-82`) thay vì
|
||||
// nối vào 2 file KHKK sẵn có: cả hai file đó vừa được sửa theo spec K2 ở lane khác —
|
||||
// chèn thêm vào chúng là hai tay cùng ghi một tệp. Fixture wire `AuditingInterceptor`
|
||||
// vì guard trình đọc `plan.CreatedBy` (`…Service.cs:222`); `TestApplicationDbContext`
|
||||
// trần KHÔNG wire ⇒ `CreatedBy` null ⇒ submit có thể đỏ/xanh vì LÝ DO SAI (bài S155).
|
||||
//
|
||||
// (2) MỖI lệnh một `DbContext` mới trên CÙNG một connection — mô phỏng scoped-per-request.
|
||||
// Dùng lại context của seed thì Lines/Changelogs đã nằm sẵn trong change-tracker, một
|
||||
// đường đi không có thật ở prod (ở đó service chỉ có thứ nó tự `Include`).
|
||||
//
|
||||
// (3) Seed User bằng `db.Users.Add` thẳng, KHÔNG kéo `IdentityFixture`: các handler dưới
|
||||
// test nhận vai qua `ICurrentUser.Roles`; `ApprovalWorkflowLevel.ApproverUserId` chỉ
|
||||
// cần ROW tồn tại (FK Restrict).
|
||||
//
|
||||
// ⚠️ QUAN SÁT (KHÔNG phải bug-claim, không pin thành test): submit-guard dùng
|
||||
// `plan.Lines.Any(l => l.CatalogEntryId == null)` ⇒ phiếu KHÔNG có dòng nào sẽ đi lọt cả
|
||||
// hai vế guard. Đường tạo phiếu của K2 luôn sinh ≥1 dòng (mỗi winner 1 dòng, và create
|
||||
// ném khi 0 winner) nên hiện KHÔNG với tới được; nếu K4b mở đường xoá dòng thì khe này
|
||||
// thành thật. Vì thế các test dưới đây đo trên phiếu CÓ dòng thật (DoD vá-5: chống
|
||||
// PASS-bằng-tập-rỗng) và assert số dòng trước khi trình.
|
||||
public class ContractSigningPlanGroupAndCatalogTests
|
||||
{
|
||||
private static readonly DateTime Clock2026 = new(2026, 7, 31, 3, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// ===================================================================
|
||||
// Fixture: SQLite in-memory + AuditingInterceptor.
|
||||
// ===================================================================
|
||||
private sealed class KhkkGroupFixture : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _conn;
|
||||
public FixedDateTime Clock { get; } = new(Clock2026);
|
||||
|
||||
public KhkkGroupFixture()
|
||||
{
|
||||
_conn = new SqliteConnection("DataSource=:memory:");
|
||||
_conn.Open();
|
||||
using var boot = NewDb(TestCurrentUser.System());
|
||||
boot.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
/// `actor` phải là ĐÚNG người mà lệnh chạy dưới danh nghĩa — prod resolve CÙNG một
|
||||
/// `ICurrentUser` scoped cho cả interceptor lẫn handler.
|
||||
public TestApplicationDbContext NewDb(ICurrentUser actor)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite(_conn)
|
||||
.EnableSensitiveDataLogging()
|
||||
.AddInterceptors(new AuditingInterceptor(actor, Clock))
|
||||
.Options;
|
||||
return new TestApplicationDbContext(options);
|
||||
}
|
||||
|
||||
public void Dispose() => _conn.Dispose();
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Seed helpers
|
||||
// ===================================================================
|
||||
|
||||
private static TestCurrentUser As(User u, params string[] roles)
|
||||
=> new(u.Id, u.FullName, u.Email, roles);
|
||||
|
||||
private static async Task<User> AddUserAsync(KhkkGroupFixture f, string fullName)
|
||||
{
|
||||
await using var db = f.NewDb(TestCurrentUser.System());
|
||||
var email = $"u{Guid.NewGuid():N}@test.local";
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FullName = fullName,
|
||||
UserName = email,
|
||||
NormalizedUserName = email.ToUpperInvariant(),
|
||||
Email = email,
|
||||
NormalizedEmail = email.ToUpperInvariant(),
|
||||
EmailConfirmed = true,
|
||||
SecurityStamp = Guid.NewGuid().ToString("N"),
|
||||
ConcurrencyStamp = Guid.NewGuid().ToString("N"),
|
||||
IsActive = true,
|
||||
CreatedAt = Clock2026,
|
||||
};
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return user;
|
||||
}
|
||||
|
||||
/// 1 dòng danh mục SP-002 thuộc `approvalGroup`. `Code` random né UNIQUE filtered
|
||||
/// (`ContractCatalogEntryConfiguration` — K1 Mig 70).
|
||||
private static async Task<Guid> AddCatalogAsync(KhkkGroupFixture f, int approvalGroup, string tenVi)
|
||||
{
|
||||
await using var db = f.NewDb(TestCurrentUser.System());
|
||||
var entry = new ContractCatalogEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = "K2-" + Guid.NewGuid().ToString("N")[..8],
|
||||
TenVi = tenVi,
|
||||
GroupCode = "B1",
|
||||
ApprovalGroup = approvalGroup,
|
||||
SignerRole = "TP.PRO",
|
||||
IsActive = true,
|
||||
SortOrder = 1,
|
||||
};
|
||||
db.ContractCatalogEntries.Add(entry);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return entry.Id;
|
||||
}
|
||||
|
||||
/// `steps[i][j]` = NV của Cấp j+1 trong Bước i+1 (OR-of-N nếu nhiều NV cùng Cấp).
|
||||
private static async Task<Guid> SeedWorkflowAsync(
|
||||
KhkkGroupFixture f,
|
||||
string code,
|
||||
Guid[][][] steps,
|
||||
ApprovalWorkflowApplicableType type = ApprovalWorkflowApplicableType.ContractSigningPlan)
|
||||
{
|
||||
await using var db = f.NewDb(TestCurrentUser.System());
|
||||
var wf = new ApprovalWorkflow
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = code,
|
||||
Version = 1,
|
||||
Name = "Quy trình duyệt Kế hoạch ký kết",
|
||||
ApplicableType = type,
|
||||
IsActive = true,
|
||||
IsUserSelectable = true,
|
||||
};
|
||||
db.ApprovalWorkflows.Add(wf);
|
||||
|
||||
for (var s = 0; s < steps.Length; s++)
|
||||
{
|
||||
var step = new ApprovalWorkflowStep
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
Order = s + 1,
|
||||
Name = $"Bước {s + 1}",
|
||||
DepartmentId = null, // null = né FK phòng ban (Pattern 11)
|
||||
};
|
||||
db.ApprovalWorkflowSteps.Add(step);
|
||||
|
||||
for (var l = 0; l < steps[s].Length; l++)
|
||||
foreach (var approverId in steps[s][l])
|
||||
db.ApprovalWorkflowLevels.Add(new ApprovalWorkflowLevel
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowStepId = step.Id,
|
||||
Order = l + 1,
|
||||
Name = $"Cấp {l + 1}",
|
||||
ApproverUserId = approverId,
|
||||
});
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return wf.Id;
|
||||
}
|
||||
|
||||
/// Dòng phiếu: `CatalogId = null` = dòng CHƯA gán hạng mục (đúng trạng thái auto-gen
|
||||
/// `ContractSigningPlanFeatures.cs:454-455`).
|
||||
private sealed record LineSeed(
|
||||
decimal PeReference, decimal Proposed, Guid? CatalogId, decimal? Approved = null);
|
||||
|
||||
private static async Task<Guid> SeedPlanAsync(
|
||||
KhkkGroupFixture f,
|
||||
TestCurrentUser drafter,
|
||||
Guid? workflowId,
|
||||
string maKeHoach,
|
||||
ContractSigningPlanPhase phase,
|
||||
int approvalGroup,
|
||||
LineSeed[] lines,
|
||||
int? stepIndex = null,
|
||||
int? levelOrder = null,
|
||||
Guid? peId = null)
|
||||
{
|
||||
await using var db = f.NewDb(drafter); // interceptor ghi CreatedBy = người soạn
|
||||
|
||||
var plan = new ContractSigningPlan
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
MaKeHoach = maKeHoach,
|
||||
PurchaseEvaluationId = peId ?? Guid.NewGuid(), // loose-Guid, không FK vật lý
|
||||
ProjectId = Guid.NewGuid(),
|
||||
DepartmentId = Guid.NewGuid(),
|
||||
DrafterUserId = drafter.UserId!.Value,
|
||||
Phase = phase,
|
||||
ApprovalGroup = approvalGroup,
|
||||
ApprovalWorkflowId = workflowId,
|
||||
CurrentWorkflowStepIndex = stepIndex,
|
||||
CurrentApprovalLevelOrder = levelOrder,
|
||||
};
|
||||
|
||||
foreach (var l in lines)
|
||||
plan.Lines.Add(new ContractSigningPlanLine
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ContractSigningPlanId = plan.Id,
|
||||
SupplierId = Guid.NewGuid(),
|
||||
CatalogEntryId = l.CatalogId,
|
||||
TenHangMuc = l.CatalogId is null ? null : "Hạng mục seed",
|
||||
PeReferenceAmount = l.PeReference,
|
||||
ProposedAmount = l.Proposed,
|
||||
ApprovedAmount = l.Approved,
|
||||
});
|
||||
|
||||
db.ContractSigningPlans.Add(plan);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return plan.Id;
|
||||
}
|
||||
|
||||
// ---- Act / Read helpers — service + handler THẬT, không mock ----
|
||||
|
||||
private static async Task<ContractSigningPlanTransitionResult> ActAsync(
|
||||
KhkkGroupFixture f, TestCurrentUser actor, Guid planId, string action, string? comment = null)
|
||||
{
|
||||
await using var db = f.NewDb(actor);
|
||||
var svc = new ContractSigningPlanWorkflowService(db, actor, f.Clock, new NoOpNotificationServiceApp());
|
||||
return await svc.TransitionAsync(planId, action, comment, CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task<ContractSigningPlan> ReadPlanAsync(KhkkGroupFixture f, Guid planId)
|
||||
{
|
||||
await using var db = f.NewDb(TestCurrentUser.System());
|
||||
return await db.ContractSigningPlans.AsNoTracking()
|
||||
.Include(p => p.Lines)
|
||||
.SingleAsync(p => p.Id == planId, CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task<PagedResult<ContractSigningPlanListItemDto>> ListAsync(
|
||||
KhkkGroupFixture f, TestCurrentUser actor, int? group)
|
||||
{
|
||||
await using var db = f.NewDb(actor);
|
||||
var handler = new ListContractSigningPlansQueryHandler(db, actor);
|
||||
return await handler.Handle(
|
||||
new ListContractSigningPlansQuery(ApprovalGroup: group) { Page = 1, PageSize = 50 },
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T-a) SUBMIT-GUARD null — vá-5 spec-cụm1 (`…Service.cs:174-176`), THẮNG nền `:126`.
|
||||
//
|
||||
// Đo trên phiếu CÓ DÒNG THẬT (DoD chống PASS-bằng-tập-rỗng) và bằng CẶP CÙNG-RUỘT khác
|
||||
// đúng 1 biến: hai phiếu y hệt nhau (cùng workflow, cùng nhóm, cùng 2 dòng, cùng người
|
||||
// soạn), chỉ khác việc dòng thứ hai đã gán hạng mục hay chưa. Thiếu vế dương thì test âm
|
||||
// vẫn xanh trong cái thế giới mà submit hỏng vì lý do khác (workflow rỗng, guard quyền…).
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task Submit_ConDongChuaGanHangMuc_ThiBiChan_GanDuThiTrinhQua()
|
||||
{
|
||||
using var f = new KhkkGroupFixture();
|
||||
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
var approver = await AddUserAsync(f, "NV Cung ứng duyệt Cấp 1");
|
||||
var wf = await SeedWorkflowAsync(f, "QT-KHKK-N1", steps: [[[approver.Id]]]);
|
||||
var catalog = await AddCatalogAsync(f, approvalGroup: 1, tenVi: "Thi công phần thô");
|
||||
|
||||
// ---- Ca ÂM: dòng 2 còn NULL.
|
||||
var pBad = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/001",
|
||||
ContractSigningPlanPhase.DangSoanThao, approvalGroup: 1,
|
||||
lines: [new(100m, 100m, catalog), new(200m, 200m, CatalogId: null)]);
|
||||
|
||||
var beforeBad = await ReadPlanAsync(f, pBad);
|
||||
beforeBad.Lines.Should().HaveCount(2,
|
||||
"guard phải được đo trên phiếu CÓ dòng thật — phiếu rỗng đi lọt `Any(...)` nên "
|
||||
+ "một test seed 0 dòng sẽ xanh mà chẳng chứng minh điều gì (DoD vá-5)");
|
||||
|
||||
Func<Task> actBad = async () => await ActAsync(f, drafter, pBad, "submit");
|
||||
(await actBad.Should().ThrowAsync<ConflictException>(
|
||||
"còn dòng chưa gán hạng mục SP-002 thì chưa đủ căn cứ để trình duyệt"))
|
||||
.WithMessage("*chưa gán hạng mục*");
|
||||
|
||||
var afterBad = await ReadPlanAsync(f, pBad);
|
||||
afterBad.Phase.Should().Be(ContractSigningPlanPhase.DangSoanThao,
|
||||
"bị chặn thì phiếu phải NGUYÊN Ở NHÁP — ném xong mà vẫn đổi phase còn tệ hơn không rào");
|
||||
afterBad.CurrentWorkflowStepIndex.Should().BeNull("chưa trình thì chưa có con-trỏ Bước");
|
||||
afterBad.CurrentApprovalLevelOrder.Should().BeNull("chưa trình thì chưa có con-trỏ Cấp");
|
||||
|
||||
// ---- Ca DƯƠNG (đối chứng, đổi ĐÚNG 1 biến): dòng 2 đã gán.
|
||||
var pOk = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/002",
|
||||
ContractSigningPlanPhase.DangSoanThao, approvalGroup: 1,
|
||||
lines: [new(100m, 100m, catalog), new(200m, 200m, catalog)]);
|
||||
|
||||
var res = await ActAsync(f, drafter, pOk, "submit");
|
||||
res.Phase.Should().Be((int)ContractSigningPlanPhase.ChoDuyet);
|
||||
|
||||
var afterOk = await ReadPlanAsync(f, pOk);
|
||||
afterOk.Phase.Should().Be(ContractSigningPlanPhase.ChoDuyet,
|
||||
"cùng bộ seed đó, gán đủ hạng mục thì phải trình được — nếu vế này cũng đỏ thì "
|
||||
+ "ca âm ở trên đang bắt một lỗi khác chứ không phải guard hạng mục");
|
||||
afterOk.CurrentWorkflowStepIndex.Should().Be(0);
|
||||
afterOk.CurrentApprovalLevelOrder.Should().Be(1);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T-b) SUBMIT-GUARD khác nhóm — `…Service.cs:177-182` (code mẫu nền K2.d).
|
||||
//
|
||||
// 3 vế, mỗi vế bắt một lỗi khác nhau:
|
||||
// • phiếu nhóm 1 + hạng mục nhóm 2 → 409 (vế chính)
|
||||
// • phiếu nhóm 1 + [hạng mục nhóm 1, hạng mục nhóm 2] → 409 (chứng vị ngữ là ANY,
|
||||
// không phải "mọi dòng đều sai"; cài đặt bằng `All` sẽ cho lọt phiếu pha trộn)
|
||||
// • phiếu nhóm 2 + hạng mục nhóm 2 → QUA (chứng guard so theo nhóm
|
||||
// CỦA PHIẾU, không phải hằng số 1)
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task Submit_HangMucKhacNhomDuyetCuaPhieu_ThiBiChan_CungNhomThiQua()
|
||||
{
|
||||
using var f = new KhkkGroupFixture();
|
||||
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
var approver = await AddUserAsync(f, "NV duyệt");
|
||||
var wf = await SeedWorkflowAsync(f, "QT-KHKK-MIX", steps: [[[approver.Id]]]);
|
||||
|
||||
var catN1 = await AddCatalogAsync(f, approvalGroup: 1, tenVi: "Hạng mục nhóm 1");
|
||||
var catN2 = await AddCatalogAsync(f, approvalGroup: 2, tenVi: "Hạng mục nhóm 2");
|
||||
|
||||
// ---- Vế 1: phiếu nhóm 1, dòng duy nhất thuộc nhóm 2.
|
||||
var pWrong = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/010",
|
||||
ContractSigningPlanPhase.DangSoanThao, approvalGroup: 1,
|
||||
lines: [new(100m, 100m, catN2)]);
|
||||
|
||||
Func<Task> actWrong = async () => await ActAsync(f, drafter, pWrong, "submit");
|
||||
(await actWrong.Should().ThrowAsync<ConflictException>(
|
||||
"hạng mục quyết định workflow nào duyệt phiếu — lệch nhóm là trình sai hội đồng"))
|
||||
.WithMessage("*N1*");
|
||||
|
||||
(await ReadPlanAsync(f, pWrong)).Phase.Should().Be(ContractSigningPlanPhase.DangSoanThao,
|
||||
"bị chặn ⇒ phiếu bất động");
|
||||
|
||||
// ---- Vế 2: phiếu PHA TRỘN — 1 dòng đúng nhóm + 1 dòng sai nhóm.
|
||||
var pMixed = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/011",
|
||||
ContractSigningPlanPhase.DangSoanThao, approvalGroup: 1,
|
||||
lines: [new(100m, 100m, catN1), new(200m, 200m, catN2)]);
|
||||
|
||||
Func<Task> actMixed = async () => await ActAsync(f, drafter, pMixed, "submit");
|
||||
await actMixed.Should().ThrowAsync<ConflictException>(
|
||||
"chỉ cần MỘT dòng lệch nhóm là phiếu đã sai hội đồng duyệt — cài bằng `All(...)` "
|
||||
+ "thì phiếu pha trộn lọt qua mà vẫn trông như có rào");
|
||||
|
||||
// ---- Vế 3 (đối chứng): phiếu nhóm 2 + hạng mục nhóm 2 ⇒ QUA.
|
||||
var pN2 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/012",
|
||||
ContractSigningPlanPhase.DangSoanThao, approvalGroup: 2,
|
||||
lines: [new(100m, 100m, catN2)]);
|
||||
|
||||
await ActAsync(f, drafter, pN2, "submit");
|
||||
(await ReadPlanAsync(f, pN2)).Phase.Should().Be(ContractSigningPlanPhase.ChoDuyet,
|
||||
"hạng mục nhóm 2 trên phiếu nhóm 2 là hợp lệ — nếu vế này cũng chặn thì guard đang "
|
||||
+ "so với hằng số 1 chứ không phải với nhóm của phiếu");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T-c) Rào (PeId, ApprovalGroup) qua CreateHandler THẬT — `Features.cs:393-402`.
|
||||
//
|
||||
// Trước K2 rào là "1 PE = 1 phiếu" tuyệt đối; K2 nới thành 1 phiếu SỐNG MỖI NHÓM.
|
||||
// Đo 4 nước liên tiếp trên CÙNG một PE: nhóm 1 → nhóm 2 → nhóm 2 lần nữa (409) →
|
||||
// nhóm 1 lần nữa (409). Kèm phép đo phụ "KHÔNG ĐỐT MÃ": rào đứng TRƯỚC codegen
|
||||
// (`:395` vs `:413`) ⇒ 2 lần 409 không được làm nhảy số thứ tự.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task Create_CungPeCungNhom_ThiConflict_KhacNhom_ThiTaoDuoc_VaKhongDotMa()
|
||||
{
|
||||
using var f = new KhkkGroupFixture();
|
||||
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
var seeded = await SeedPeForCreateAsync(f);
|
||||
|
||||
var g1 = await CreateAsync(f, drafter, seeded.PeId, seeded.WorkflowId, approvalGroup: 1);
|
||||
g1.LineCount.Should().Be(1, "PE có đúng 1 NCC trúng thầu ⇒ 1 dòng");
|
||||
|
||||
var g2 = await CreateAsync(f, drafter, seeded.PeId, seeded.WorkflowId, approvalGroup: 2);
|
||||
g2.Id.Should().NotBe(g1.Id, "cùng PE khác NHÓM = phiếu khác, không phải cập nhật phiếu cũ");
|
||||
g2.MaKeHoach.Should().NotBe(g1.MaKeHoach, "mỗi phiếu một mã");
|
||||
|
||||
// ---- 409 nhóm 2 (đã có phiếu sống nhóm 2).
|
||||
Func<Task> dupG2 = async () =>
|
||||
await CreateAsync(f, drafter, seeded.PeId, seeded.WorkflowId, approvalGroup: 2);
|
||||
(await dupG2.Should().ThrowAsync<ConflictException>(
|
||||
"1 PE chỉ được 1 kế hoạch SỐNG mỗi nhóm duyệt"))
|
||||
.WithMessage("*N2*");
|
||||
|
||||
// ---- 409 nhóm 1 (chứng rào không phải "chỉ chặn nhóm vừa tạo").
|
||||
Func<Task> dupG1 = async () =>
|
||||
await CreateAsync(f, drafter, seeded.PeId, seeded.WorkflowId, approvalGroup: 1);
|
||||
(await dupG1.Should().ThrowAsync<ConflictException>()).WithMessage("*N1*");
|
||||
|
||||
await using var db = f.NewDb(drafter);
|
||||
db.ContractSigningPlans.Count(p => p.PurchaseEvaluationId == seeded.PeId)
|
||||
.Should().Be(2, "hai lần 409 KHÔNG được để lại phiếu nửa vời");
|
||||
db.ContractSigningPlans.Where(p => p.PurchaseEvaluationId == seeded.PeId)
|
||||
.Select(p => p.ApprovalGroup).OrderBy(x => x).ToList()
|
||||
.Should().Equal([1, 2], "hai phiếu sống thuộc hai nhóm khác nhau");
|
||||
|
||||
// Rào đứng TRƯỚC codegen ⇒ số thứ tự chỉ nhảy đúng 2 lần (2 phiếu tạo được).
|
||||
db.WorkflowAppCodeSequences.Single(s => s.Prefix == "KHKK/2026").LastSeq
|
||||
.Should().Be(2, "409 xảy ra trước khi cấp mã ⇒ không đốt số; đổi thứ tự hai bước "
|
||||
+ "này thì dải mã KHKK thủng lỗ mà chẳng phiếu nào mang");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T-c bis) Phiếu bị TỪ CHỐI không chiếm chỗ — vế `Phase != TuChoi` của rào.
|
||||
//
|
||||
// Chính vế này là lý do BE CỐ Ý không dựng unique (PeId, ApprovalGroup) dưới DB
|
||||
// (`ContractSigningPlanConfiguration.cs:33-37`): unique không diễn tả được "trừ phiếu
|
||||
// đã từ chối". Test khoá cả hai chiều — TuChoi nhường chỗ, ChoDuyet thì KHÔNG.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task Create_PhieuNhomDaTuChoi_KhongChiemCho_PhieuDangChoDuyet_ThiChiemCho()
|
||||
{
|
||||
using var f = new KhkkGroupFixture();
|
||||
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
var seeded = await SeedPeForCreateAsync(f);
|
||||
|
||||
var first = await CreateAsync(f, drafter, seeded.PeId, seeded.WorkflowId, approvalGroup: 3);
|
||||
|
||||
// Phiếu bị từ chối (kết quả của nhánh reject — ở đây set thẳng phase để cô lập rào create).
|
||||
await using (var db = f.NewDb(drafter))
|
||||
{
|
||||
var plan = await db.ContractSigningPlans.SingleAsync(p => p.Id == first.Id);
|
||||
plan.Phase = ContractSigningPlanPhase.TuChoi;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var again = await CreateAsync(f, drafter, seeded.PeId, seeded.WorkflowId, approvalGroup: 3);
|
||||
again.Id.Should().NotBe(first.Id,
|
||||
"phiếu bị từ chối là ngõ cụt ⇒ phải lập lại được cho CÙNG nhóm, bằng một phiếu MỚI");
|
||||
|
||||
// ---- Vế đối chứng: phiếu vừa tạo đang SỐNG (Nháp) ⇒ nhóm 3 lại bị khoá.
|
||||
Func<Task> third = async () =>
|
||||
await CreateAsync(f, drafter, seeded.PeId, seeded.WorkflowId, approvalGroup: 3);
|
||||
(await third.Should().ThrowAsync<ConflictException>(
|
||||
"phiếu sống mới lập lại chiếm chỗ nhóm 3 — nếu vế này cũng qua thì rào đã chết hẳn "
|
||||
+ "chứ không phải 'TuChoi được miễn'"))
|
||||
.WithMessage("*N3*");
|
||||
|
||||
await using var check = f.NewDb(drafter);
|
||||
check.ContractSigningPlans.Count(p => p.PurchaseEvaluationId == seeded.PeId
|
||||
&& p.Phase != ContractSigningPlanPhase.TuChoi)
|
||||
.Should().Be(1, "đúng MỘT phiếu sống cho nhóm 3");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T-d) List lọc theo nhóm — `Features.cs:737-738`.
|
||||
//
|
||||
// Hai điều dễ hỏng, test khoá cả hai:
|
||||
// • lọc ở CLIENT (lấy hết rồi `.Where` trên list): `Items` trông y hệt nhưng `Total`
|
||||
// vẫn là cả thế giới ⇒ phân trang sai. Vì thế assert CẢ `Total`, không chỉ `Items`.
|
||||
// • lọc đặt NHẦM vào trong khối IDOR (`:720-729`) ⇒ chỉ đúng cho một vai. Vì thế đo
|
||||
// bằng CẢ non-admin (chủ phiếu) LẪN admin.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task List_LocTheoNhomDuyet_ChiTraDungNhom_VaTotalKhopFilter()
|
||||
{
|
||||
using var f = new KhkkGroupFixture();
|
||||
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
var admin = As(await AddUserAsync(f, "Quản trị"), AppRoles.Admin);
|
||||
var wf = await SeedWorkflowAsync(f, "QT-KHKK-LIST", steps: [[[drafter.UserId!.Value]]]);
|
||||
var cat = await AddCatalogAsync(f, 1, "Hạng mục list");
|
||||
|
||||
LineSeed[] one = [new(10m, 10m, cat)];
|
||||
await SeedPlanAsync(f, drafter, wf, "KHKK/2026/020", ContractSigningPlanPhase.DangSoanThao, 1, one);
|
||||
await SeedPlanAsync(f, drafter, wf, "KHKK/2026/021", ContractSigningPlanPhase.DangSoanThao, 1, one);
|
||||
await SeedPlanAsync(f, drafter, wf, "KHKK/2026/022", ContractSigningPlanPhase.DangSoanThao, 2, one);
|
||||
|
||||
foreach (var (actor, vai) in new[] { (drafter, "người soạn"), (admin, "admin") })
|
||||
{
|
||||
var all = await ListAsync(f, actor, group: null);
|
||||
all.Total.Should().Be(3, $"{vai}: không lọc ⇒ thấy cả 3 phiếu (giữ nguyên hành vi trước K2)");
|
||||
all.Items.Should().HaveCount(3);
|
||||
|
||||
var g2 = await ListAsync(f, actor, group: 2);
|
||||
g2.Items.Should().ContainSingle($"{vai}: chỉ 1 phiếu thuộc nhóm 2")
|
||||
.Which.ApprovalGroup.Should().Be(2, "DTO phải mang nhóm để FE gắn badge/cây folder");
|
||||
g2.Total.Should().Be(1,
|
||||
$"{vai}: `Total` là dấu vân tay của việc lọc CHẠY DƯỚI DB — lọc ở client thì "
|
||||
+ "Items đúng mà Total vẫn là 3");
|
||||
|
||||
var g1 = await ListAsync(f, actor, group: 1);
|
||||
g1.Total.Should().Be(2, $"{vai}: 2 phiếu nhóm 1");
|
||||
g1.Items.Should().OnlyContain(i => i.ApprovalGroup == 1);
|
||||
|
||||
var g8 = await ListAsync(f, actor, group: 8);
|
||||
g8.Total.Should().Be(0, $"{vai}: nhóm chưa có phiếu ⇒ rỗng, KHÔNG rơi về 'trả hết'");
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T-e) Σ ĐÃ CHỐT trên list — vá-8 (`Features.cs:803-804`).
|
||||
//
|
||||
// Thiếu Σ này thì màn danh sách khoe số ĐỀ XUẤT còn màn chi tiết khoá theo số ĐÃ CHỐT =
|
||||
// hai màn nói hai con số về cùng một phiếu. Seed cố ý cho Proposed ≠ Approved để một cài
|
||||
// đặt "Σ Approved = Σ Proposed" (copy nhầm dòng trên) lộ ra ngay.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task List_TongDaChot_CongTheoApprovedAmount_PhieuChuaDuyetThiBangKhong()
|
||||
{
|
||||
using var f = new KhkkGroupFixture();
|
||||
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
var wf = await SeedWorkflowAsync(f, "QT-KHKK-SUM", steps: [[[drafter.UserId!.Value]]]);
|
||||
var cat = await AddCatalogAsync(f, 1, "Hạng mục Σ");
|
||||
|
||||
// Phiếu ĐÃ DUYỆT: đề xuất 70+50=120 nhưng chốt 30+20=50 (hai số KHÁC nhau).
|
||||
var approved = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/030",
|
||||
ContractSigningPlanPhase.DaDuyet, 1,
|
||||
[new(80m, 70m, cat, Approved: 30m), new(60m, 50m, cat, Approved: 20m)]);
|
||||
|
||||
// Phiếu NHÁP: có tiền đề xuất nhưng chưa chốt đồng nào.
|
||||
var draft = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/031",
|
||||
ContractSigningPlanPhase.DangSoanThao, 1,
|
||||
[new(111m, 111m, cat)]);
|
||||
|
||||
// Phiếu CHỐT DỞ (1 dòng có, 1 dòng chưa) — khoá ngữ nghĩa "Σ bỏ qua dòng null".
|
||||
var partial = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/032",
|
||||
ContractSigningPlanPhase.ChoDuyet, 1,
|
||||
[new(40m, 40m, cat, Approved: 10m), new(40m, 40m, cat)]);
|
||||
|
||||
var page = await ListAsync(f, drafter, group: null);
|
||||
|
||||
var a = page.Items.Single(i => i.Id == approved);
|
||||
a.TotalApprovedAmount.Should().Be(50m, "Σ số ĐÃ CHỐT = 30 + 20");
|
||||
a.TotalProposedAmount.Should().Be(120m,
|
||||
"Σ đề xuất phải VẪN là 120 — hai Σ song song, không được cái này đè cái kia");
|
||||
a.TotalPeReferenceAmount.Should().Be(140m, "Σ tham chiếu PE bất biến (bài K2.e)");
|
||||
|
||||
var d = page.Items.Single(i => i.Id == draft);
|
||||
d.TotalApprovedAmount.Should().Be(0m, "phiếu chưa duyệt ⇒ mọi dòng null ⇒ 0");
|
||||
d.TotalProposedAmount.Should().Be(111m,
|
||||
"và số 0 ở trên phải là do null-coalesce chứ không phải do phiếu rỗng tiền");
|
||||
|
||||
var p = page.Items.Single(i => i.Id == partial);
|
||||
p.TotalApprovedAmount.Should().Be(10m,
|
||||
"Σ cộng đúng phần đã chốt, bỏ qua dòng chưa chốt — nếu ra 0 thì cài đặt đang null-hoá "
|
||||
+ "cả tổng khi gặp một dòng null");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T-f) Gate ghi nháp — `ContractSigningPlanScope.EnsureDraftEditable` (`Features.cs:229-237`).
|
||||
//
|
||||
// BE lane chốt (`sub-implbe-k2.md §e`): K2 KHÔNG dựng guard mồ-côi cho line-edit; ràng buộc
|
||||
// là "mọi đường ghi (kể cả Lines-editor K4b) PHẢI đi qua gate này". Test đo 2 tầng:
|
||||
// (1) BẢN THÂN gate: ma trận 5 phase + vế quyền;
|
||||
// (2) gate ĐÃ ĐƯỢC NỐI vào một đường ghi THẬT (`UpdateContractSigningPlanDraftCommandHandler`
|
||||
// `:518`) và chặn được thật — chống ghost-wire (guard tồn tại nhưng không ai gọi).
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task EnsureDraftEditable_ChanMoiPhaseNgoaiNhapVaTraLai_VaDaNoiVaoDuongGhiThat()
|
||||
{
|
||||
using var f = new KhkkGroupFixture();
|
||||
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
var stranger = As(await AddUserAsync(f, "Người ngoài"));
|
||||
var admin = As(await AddUserAsync(f, "Quản trị"), AppRoles.Admin);
|
||||
|
||||
// ---- (1) Ma trận phase, gọi thẳng gate (InternalsVisibleTo).
|
||||
ContractSigningPlan PlanAt(ContractSigningPlanPhase phase) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DrafterUserId = drafter.UserId!.Value,
|
||||
Phase = phase,
|
||||
};
|
||||
|
||||
foreach (var open in new[] { ContractSigningPlanPhase.DangSoanThao, ContractSigningPlanPhase.TraLai })
|
||||
{
|
||||
var act = () => ContractSigningPlanScope.EnsureDraftEditable(PlanAt(open), drafter);
|
||||
act.Should().NotThrow($"{open} là trạng thái người soạn đang cầm phiếu — phải sửa được");
|
||||
}
|
||||
|
||||
foreach (var closed in new[]
|
||||
{
|
||||
ContractSigningPlanPhase.ChoDuyet,
|
||||
ContractSigningPlanPhase.DaDuyet,
|
||||
ContractSigningPlanPhase.TuChoi,
|
||||
})
|
||||
{
|
||||
var act = () => ContractSigningPlanScope.EnsureDraftEditable(PlanAt(closed), drafter);
|
||||
act.Should().Throw<ConflictException>(
|
||||
$"{closed}: phiếu đã rời tay người soạn — sửa/xoá dòng lúc này làm Σ của phiếu "
|
||||
+ "đã duyệt trôi ÂM THẦM (khe rò gián tiếp duy nhất, spec vá-6)")
|
||||
.WithMessage("*Nháp hoặc Trả lại*");
|
||||
}
|
||||
|
||||
// Admin vẫn bị chặn theo PHASE (vế quyền và vế phase là hai rào độc lập).
|
||||
var adminOnApproved = () =>
|
||||
ContractSigningPlanScope.EnsureDraftEditable(PlanAt(ContractSigningPlanPhase.DaDuyet), admin);
|
||||
adminOnApproved.Should().Throw<ConflictException>(
|
||||
"Admin bỏ qua vế QUYỀN chứ không bỏ qua vế PHASE — nếu Admin sửa được phiếu đã duyệt "
|
||||
+ "thì số đã chốt không còn là số chốt");
|
||||
|
||||
// Người ngoài bị chặn ngay ở vế quyền, kể cả phiếu đang mở.
|
||||
var strangerOnDraft = () =>
|
||||
ContractSigningPlanScope.EnsureDraftEditable(PlanAt(ContractSigningPlanPhase.DangSoanThao), stranger);
|
||||
strangerOnDraft.Should().Throw<ForbiddenException>("phiếu nháp là của người soạn (S89)");
|
||||
|
||||
// ---- (2) Gate đã NỐI vào đường ghi thật.
|
||||
var wf = await SeedWorkflowAsync(f, "QT-KHKK-GATE", steps: [[[drafter.UserId!.Value]]]);
|
||||
var cat = await AddCatalogAsync(f, 1, "Hạng mục gate");
|
||||
var approvedPlan = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/040",
|
||||
ContractSigningPlanPhase.DaDuyet, 1, [new(10m, 10m, cat)]);
|
||||
|
||||
async Task UpdateAsync()
|
||||
{
|
||||
await using var db = f.NewDb(drafter);
|
||||
var handler = new UpdateContractSigningPlanDraftCommandHandler(db, drafter);
|
||||
await handler.Handle(
|
||||
new UpdateContractSigningPlanDraftCommand(approvedPlan, GhiChu: "sửa sau khi duyệt"),
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
Func<Task> actUpdate = UpdateAsync;
|
||||
await actUpdate.Should().ThrowAsync<ConflictException>(
|
||||
"gate phải chặn TẠI handler thật, không chỉ tồn tại như một hàm không ai gọi");
|
||||
|
||||
await using var db2 = f.NewDb(drafter);
|
||||
(await db2.ContractSigningPlans.AsNoTracking().SingleAsync(p => p.Id == approvedPlan))
|
||||
.GhiChu.Should().BeNull("ném rồi thì KHÔNG được ghi gì xuống phiếu");
|
||||
db2.ContractSigningPlanChangelogs.Count(c => c.ContractSigningPlanId == approvedPlan)
|
||||
.Should().Be(0, "cũng không được để lại dòng nhật ký cho một lần sửa đã bị chặn");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T-g) Mặc định nhóm 1 — `ContractSigningPlan.cs:39` + backfill Mig 71 (`:61`).
|
||||
//
|
||||
// Ngữ nghĩa backfill: MỌI phiếu sinh ra từ đường code cũ (không set nhóm) phải rơi vào
|
||||
// nhóm 1, khớp `UPDATE … SET ApprovalGroup = 1`. Kèm vế đối chứng "set 5 thì ra 5" —
|
||||
// thiếu vế đó, một cài đặt ép cứng 1 (default DB đè giá trị) vẫn xanh.
|
||||
// Đo luôn `EndedByLevelFinalize` (cột K2 ĐẺ, K3 tiêu thụ — ghost-window khai ở
|
||||
// `sub-implbe-k2.md §a vá-7`): tới khi K3 land, nó phải luôn false.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task Entity_MacDinhNhomMot_VaGiuNguyenKhiGanTay_ConCoKetThucSomMacDinhTat()
|
||||
{
|
||||
new ContractSigningPlan().ApprovalGroup.Should().Be(1,
|
||||
"phiếu không khai nhóm = nhóm 1, đúng ngữ nghĩa backfill Mig 71 cho dữ liệu cũ");
|
||||
new ContractSigningPlan().EndedByLevelFinalize.Should().BeFalse(
|
||||
"cột runtime của K3: từ nay tới khi K3 land KHÔNG site nào ghi ⇒ luôn false");
|
||||
|
||||
using var f = new KhkkGroupFixture();
|
||||
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
|
||||
// Ghi phiếu KHÔNG set ApprovalGroup — đúng hình dạng code-path cũ.
|
||||
var legacyId = Guid.NewGuid();
|
||||
await using (var db = f.NewDb(drafter))
|
||||
{
|
||||
db.ContractSigningPlans.Add(new ContractSigningPlan
|
||||
{
|
||||
Id = legacyId,
|
||||
MaKeHoach = "KHKK/2026/050",
|
||||
PurchaseEvaluationId = Guid.NewGuid(),
|
||||
ProjectId = Guid.NewGuid(),
|
||||
DrafterUserId = drafter.UserId!.Value,
|
||||
Phase = ContractSigningPlanPhase.DangSoanThao,
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var legacy = await ReadPlanAsync(f, legacyId);
|
||||
legacy.ApprovalGroup.Should().Be(1, "round-trip qua DB vẫn là nhóm 1");
|
||||
legacy.EndedByLevelFinalize.Should().BeFalse();
|
||||
|
||||
// Đối chứng: gán tay nhóm 5 ⇒ đọc lại phải là 5 (default không được đè).
|
||||
var explicitId = await SeedPlanAsync(f, drafter, workflowId: null, "KHKK/2026/051",
|
||||
ContractSigningPlanPhase.DangSoanThao, approvalGroup: 5, lines: []);
|
||||
(await ReadPlanAsync(f, explicitId)).ApprovalGroup.Should().Be(5,
|
||||
"giá trị người dùng chọn phải thắng giá trị mặc định — nếu cũng ra 1 thì cột đang bị "
|
||||
+ "ép cứng và mọi phiếu nhóm 2..8 sẽ âm thầm nằm sai folder");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T-h) MỌI đường tới `DaDuyet` đều đi qua chốt giá — vá-2 spec-cụm1 phương án (ii).
|
||||
//
|
||||
// `ReturnOrRejectAsync` (`…Service.cs:327-359`) gán `plan.Phase = targetPhase` QUA BIẾN —
|
||||
// đúng lớp bẫy #81-EXT (PE Mig 67 site-5). Spec chốt KHÔNG sửa site đó mà KHOÁ bằng test:
|
||||
// biến ấy chỉ nhận `TraLai`/`TuChoi`, nên không đường nào tới `DaDuyet` mà bỏ qua
|
||||
// `ApplyApprovedValuesOnFinalize`. Đo bằng CẢ BA hành động trên ba phiếu song sinh.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task Transition_ChiNhanhDuyetCuoiMoiToiDaDuyet_VaLuonKemChotGia()
|
||||
{
|
||||
using var f = new KhkkGroupFixture();
|
||||
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
var approver = await AddUserAsync(f, "NV duyệt Cấp cuối");
|
||||
var wf = await SeedWorkflowAsync(f, "QT-KHKK-END", steps: [[[approver.Id]]]);
|
||||
var cat = await AddCatalogAsync(f, 1, "Hạng mục kết thúc");
|
||||
|
||||
async Task<Guid> PendingPlanAsync(string ma) => await SeedPlanAsync(
|
||||
f, drafter, wf, ma, ContractSigningPlanPhase.ChoDuyet, 1,
|
||||
[new(500m, 400m, cat)], stepIndex: 0, levelOrder: 1);
|
||||
|
||||
var pReturn = await PendingPlanAsync("KHKK/2026/060");
|
||||
var pReject = await PendingPlanAsync("KHKK/2026/061");
|
||||
var pApprove = await PendingPlanAsync("KHKK/2026/062");
|
||||
|
||||
await ActAsync(f, As(approver), pReturn, "return", "thiếu hồ sơ");
|
||||
var afterReturn = await ReadPlanAsync(f, pReturn);
|
||||
afterReturn.Phase.Should().Be(ContractSigningPlanPhase.TraLai,
|
||||
"biến `targetPhase` ở nhánh trả lại chỉ nhận TraLai — ra DaDuyet ở đây là phiếu tự duyệt");
|
||||
afterReturn.Lines.Should().OnlyContain(l => l.ApprovedAmount == null,
|
||||
"trả lại KHÔNG chốt giá");
|
||||
|
||||
await ActAsync(f, As(approver), pReject, "reject", "không ký loại HĐ này");
|
||||
var afterReject = await ReadPlanAsync(f, pReject);
|
||||
afterReject.Phase.Should().Be(ContractSigningPlanPhase.TuChoi);
|
||||
afterReject.Lines.Should().OnlyContain(l => l.ApprovedAmount == null, "từ chối KHÔNG chốt giá");
|
||||
|
||||
// Đường DUY NHẤT tới DaDuyet — và nó phải kèm chốt giá.
|
||||
await ActAsync(f, As(approver), pApprove, "approve", "đồng ý");
|
||||
var afterApprove = await ReadPlanAsync(f, pApprove);
|
||||
afterApprove.Phase.Should().Be(ContractSigningPlanPhase.DaDuyet);
|
||||
afterApprove.Lines.Should().OnlyContain(l => l.ApprovedAmount == 400m,
|
||||
"tới DaDuyet là phải đi qua `ApplyApprovedValuesOnFinalize` ⇒ mọi dòng có số chốt "
|
||||
+ "(mặc định = số đề xuất); phiếu 'Đã duyệt' mà ApprovedAmount rỗng thì GĐ3 sinh HĐ 0đ");
|
||||
|
||||
// Hai phiếu kia PHẢI bất động ở đúng chỗ của chúng (không lây tác dụng phụ).
|
||||
(await ReadPlanAsync(f, pReturn)).Phase.Should().Be(ContractSigningPlanPhase.TraLai);
|
||||
(await ReadPlanAsync(f, pReject)).Phase.Should().Be(ContractSigningPlanPhase.TuChoi);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Seed PE + create helper (dùng cho T-c / T-c bis)
|
||||
// ===================================================================
|
||||
|
||||
private sealed record SeededPe(Guid PeId, Guid WorkflowId);
|
||||
|
||||
/// PE `DaDuyet`, 1 NCC trúng thầu, 1 báo giá được chọn — bộ tối thiểu để CreateHandler
|
||||
/// đi hết 4 rào (`Features.cs:377-405`) mà không vướng cái nào.
|
||||
private static async Task<SeededPe> SeedPeForCreateAsync(KhkkGroupFixture f)
|
||||
{
|
||||
var wf = await SeedWorkflowAsync(f, "QT-KHKK-CREATE", steps: []);
|
||||
|
||||
await using var db = f.NewDb(TestCurrentUser.System());
|
||||
var peId = Guid.NewGuid();
|
||||
|
||||
var master = new Supplier
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = "NCC-K2",
|
||||
Name = "Nhà cung cấp K2",
|
||||
Type = SupplierType.NhaThauPhu,
|
||||
};
|
||||
db.Suppliers.Add(master);
|
||||
|
||||
var row = new PurchaseEvaluationSupplier
|
||||
{
|
||||
Id = Guid.NewGuid(), // ≠ master.Id — bẫy `w.Id` vs `w.SupplierId`
|
||||
PurchaseEvaluationId = peId,
|
||||
SupplierId = master.Id,
|
||||
Order = 0,
|
||||
IsWinner = true,
|
||||
};
|
||||
|
||||
var pe = new PurchaseEvaluation
|
||||
{
|
||||
Id = peId,
|
||||
MaPhieu = "PE-K2-001",
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.DaDuyet,
|
||||
TenGoiThau = "Gói thầu K2",
|
||||
ProjectId = Guid.NewGuid(),
|
||||
DepartmentId = Guid.NewGuid(),
|
||||
DrafterUserId = Guid.NewGuid(),
|
||||
ApprovalWorkflowId = wf,
|
||||
};
|
||||
pe.Suppliers.Add(row);
|
||||
|
||||
var detail = new PurchaseEvaluationDetail
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationId = peId,
|
||||
GroupCode = "A.I",
|
||||
GroupName = "Nhóm A",
|
||||
NoiDung = "Hạng mục 1",
|
||||
Order = 0,
|
||||
ThanhTienNganSach = 1_000_000_000m,
|
||||
};
|
||||
detail.Quotes.Add(new PurchaseEvaluationQuote
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationDetailId = detail.Id,
|
||||
PurchaseEvaluationSupplierId = row.Id,
|
||||
ThanhTien = 250m,
|
||||
IsSelected = true,
|
||||
});
|
||||
pe.Details.Add(detail);
|
||||
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
return new SeededPe(peId, wf);
|
||||
}
|
||||
|
||||
private static async Task<CreateContractSigningPlanResult> CreateAsync(
|
||||
KhkkGroupFixture f, TestCurrentUser actor, Guid peId, Guid workflowId, int? approvalGroup)
|
||||
{
|
||||
await using var db = f.NewDb(actor);
|
||||
var handler = new CreateContractSigningPlanCommandHandler(
|
||||
db, actor, new ContractSigningPlanCodeGenerator(db, f.Clock));
|
||||
return await handler.Handle(
|
||||
new CreateContractSigningPlanCommand(peId, workflowId, GhiChu: null, ApprovalGroup: approvalGroup),
|
||||
CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@ -193,11 +193,17 @@ public class ContractSigningPlanSchemaTests
|
||||
db.ContractSigningPlans.Add(plan);
|
||||
|
||||
var supplierId = Guid.NewGuid();
|
||||
// [K2 Mig 71] UNIQUE nay 3 cột (PlanId, SupplierId, CatalogEntryId). CẢ HAI vế của test
|
||||
// dùng CatalogEntryId NON-NULL GIỐNG NHAU để phép đo độc lập provider: SQLite coi
|
||||
// NULL ≠ NULL trong unique (cặp NULL không nổ) còn SQL Server coi bằng nhau — cặp NULL
|
||||
// do SUBMIT-GUARD (WorkflowService) chặn ở tầng nghiệp vụ, không đo ở đây.
|
||||
var catalogId = Guid.NewGuid(); // loose-Guid — index test không cần row danh mục thật
|
||||
db.ContractSigningPlanLines.Add(new ContractSigningPlanLine
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ContractSigningPlanId = plan.Id,
|
||||
SupplierId = supplierId,
|
||||
CatalogEntryId = catalogId,
|
||||
PeReferenceAmount = 100_000_000m,
|
||||
ProposedAmount = 95_000_000m,
|
||||
});
|
||||
@ -217,13 +223,14 @@ public class ContractSigningPlanSchemaTests
|
||||
Id = Guid.NewGuid(),
|
||||
ContractSigningPlanId = plan.Id,
|
||||
SupplierId = supplierId,
|
||||
CatalogEntryId = catalogId, // trùng cả 3 cột với dòng đã XOÁ MỀM
|
||||
PeReferenceAmount = 100_000_000m,
|
||||
ProposedAmount = 90_000_000m,
|
||||
});
|
||||
|
||||
var act = async () => await db.SaveChangesAsync();
|
||||
await act.Should().NotThrowAsync(
|
||||
"UNIQUE (PlanId, SupplierId) phải là filtered `WHERE [IsDeleted] = 0` — gotcha #57");
|
||||
"UNIQUE (PlanId, SupplierId, CatalogEntryId) phải là filtered `WHERE [IsDeleted] = 0` — gotcha #57");
|
||||
|
||||
db.ContractSigningPlanLines.AsNoTracking()
|
||||
.Count(x => x.ContractSigningPlanId == plan.Id)
|
||||
@ -246,23 +253,48 @@ public class ContractSigningPlanSchemaTests
|
||||
};
|
||||
db.ContractSigningPlans.Add(plan2);
|
||||
var dupSupplierId = Guid.NewGuid();
|
||||
foreach (var _ in new[] { 1, 2 })
|
||||
var dupCatalogId = Guid.NewGuid();
|
||||
db.ContractSigningPlanLines.Add(new ContractSigningPlanLine
|
||||
{
|
||||
db.ContractSigningPlanLines.Add(new ContractSigningPlanLine
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ContractSigningPlanId = plan2.Id,
|
||||
SupplierId = dupSupplierId,
|
||||
PeReferenceAmount = 1m,
|
||||
ProposedAmount = 1m,
|
||||
});
|
||||
}
|
||||
Id = Guid.NewGuid(),
|
||||
ContractSigningPlanId = plan2.Id,
|
||||
SupplierId = dupSupplierId,
|
||||
CatalogEntryId = dupCatalogId,
|
||||
PeReferenceAmount = 1m,
|
||||
ProposedAmount = 1m,
|
||||
});
|
||||
await db.SaveChangesAsync(); // dòng 1 sống — mốc đối chứng
|
||||
|
||||
db.ContractSigningPlanLines.Add(new ContractSigningPlanLine
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ContractSigningPlanId = plan2.Id,
|
||||
SupplierId = dupSupplierId,
|
||||
CatalogEntryId = dupCatalogId, // TRÙNG cả 3 cột, cả 2 còn SỐNG
|
||||
PeReferenceAmount = 1m,
|
||||
ProposedAmount = 1m,
|
||||
});
|
||||
var actDup = async () => await db.SaveChangesAsync();
|
||||
await actDup.Should().ThrowAsync<DbUpdateException>(
|
||||
"2 dòng SỐNG cùng (PlanId, SupplierId) phải bị UNIQUE chặn — vế này chứng "
|
||||
"2 dòng SỐNG cùng (PlanId, SupplierId, CatalogEntryId) phải bị UNIQUE chặn — vế này chứng "
|
||||
+ "index thật sự tồn tại, nên vế 'xoá mềm rồi thêm lại KHÔNG nổ' ở trên "
|
||||
+ "là do FILTER chứ không phải do thiếu index");
|
||||
db.ChangeTracker.Clear();
|
||||
|
||||
// [K2 Mig 71] Vế MỞ — chính tính năng K2: CÙNG (Plan, NCC) nhưng KHÁC hạng mục = 2 dòng
|
||||
// hợp lệ (tách 1 NCC theo nhiều hạng mục). Unique 2 cột cũ CẤM ca này — nay phải QUA.
|
||||
db.ContractSigningPlanLines.Add(new ContractSigningPlanLine
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ContractSigningPlanId = plan2.Id,
|
||||
SupplierId = dupSupplierId,
|
||||
CatalogEntryId = Guid.NewGuid(), // hạng mục KHÁC ⇒ được phép
|
||||
PeReferenceAmount = 1m,
|
||||
ProposedAmount = 1m,
|
||||
});
|
||||
var actSplit = async () => await db.SaveChangesAsync();
|
||||
await actSplit.Should().NotThrowAsync(
|
||||
"unique 3 cột phải CHO PHÉP cùng (Plan, NCC) khác CatalogEntryId — mục đích của Mig 71");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
Reference in New Issue
Block a user