From 0779f2d45a932ec7c279c191d9ae3fe3505442af Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Fri, 31 Jul 2026 17:05:27 +0700 Subject: [PATCH] [CLAUDE] Contract: K2 SP-002 phieu KHKK mang hang-muc + nhom duyet N1-8 (Mig 71) + FE x2 app + 11 test 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 --- .claude/skills/ef-core-migration/SKILL.md | 4 +- docs/STATUS.md | 2 +- fe-admin/src/pages/khkk/KhkkCreatePage.tsx | 177 +- fe-admin/src/pages/khkk/KhkkDetailPage.tsx | 33 +- fe-admin/src/pages/khkk/KhkkListPage.tsx | 30 +- fe-admin/src/types/khkk.ts | 69 +- fe-user/src/pages/khkk/KhkkCreatePage.tsx | 177 +- fe-user/src/pages/khkk/KhkkDetailPage.tsx | 33 +- fe-user/src/pages/khkk/KhkkListPage.tsx | 30 +- fe-user/src/types/khkk.ts | 69 +- .../ContractSigningPlansController.cs | 3 +- .../ContractSigningPlanFeatures.cs | 135 +- .../ContractSigningPlan.cs | 20 + .../ContractSigningPlanLine.cs | 12 + .../ContractSigningPlanConfiguration.cs | 11 + .../ContractSigningPlanLineConfiguration.cs | 18 +- ...GroupCatalogAndFinalizeRuntime.Designer.cs | 7060 +++++++++++++++++ ...kApprovalGroupCatalogAndFinalizeRuntime.cs | 144 + .../ApplicationDbContextModelSnapshot.cs | 19 +- .../ContractSigningPlanWorkflowService.cs | 20 + .../ContractSigningPlanApprovalTests.cs | 68 + ...ContractSigningPlanGroupAndCatalogTests.cs | 824 ++ .../Common/ContractSigningPlanSchemaTests.cs | 56 +- 23 files changed, 8905 insertions(+), 109 deletions(-) create mode 100644 src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260731085624_AddKhkkApprovalGroupCatalogAndFinalizeRuntime.Designer.cs create mode 100644 src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260731085624_AddKhkkApprovalGroupCatalogAndFinalizeRuntime.cs create mode 100644 tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanGroupAndCatalogTests.cs diff --git a/.claude/skills/ef-core-migration/SKILL.md b/.claude/skills/ef-core-migration/SKILL.md index 3d07e55..cc69a01 100644 --- a/.claude/skills/ef-core-migration/SKILL.md +++ b/.claude/skills/ef-core-migration/SKILL.md @@ -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) diff --git a/docs/STATUS.md b/docs/STATUS.md index fa8312a..b1f83a8 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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). diff --git a/fe-admin/src/pages/khkk/KhkkCreatePage.tsx b/fe-admin/src/pages/khkk/KhkkCreatePage.tsx index 313b60d..1506a67 100644 --- a/fe-admin/src/pages/khkk/KhkkCreatePage.tsx +++ b/fe-admin/src/pages/khkk/KhkkCreatePage.tsx @@ -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(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 (
Đang tải danh sách phiếu...
} {!candidates.isLoading && list.length === 0 && (
- 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.
)}
{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 ( +
+ + {/* [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 + )} +
+ )} +
) })} @@ -188,27 +261,64 @@ export function KhkkCreatePage() { > -

Quy trình duyệt

+

Nhóm duyệt & quy trình

-
- - -
- Chỉ hiển thị quy trình loại "Kế hoạch ký kết HĐ" admin đã ghim cho user chọn +
+ {/* [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. */} +
+ + + {groupTaken ? ( +
+ 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. +
+ ) : ( +
+ Mỗi phiếu Duyệt NCC lập được 1 kế hoạch cho MỖI nhóm duyệt +
+ )} +
+ +
+ + +
+ Chỉ hiển thị quy trình loại "Kế hoạch ký kết HĐ" admin đã ghim cho user chọn +
@@ -243,7 +353,12 @@ export function KhkkCreatePage() { - diff --git a/fe-admin/src/pages/khkk/KhkkDetailPage.tsx b/fe-admin/src/pages/khkk/KhkkDetailPage.tsx index a154832..c8315d4 100644 --- a/fe-admin/src/pages/khkk/KhkkDetailPage.tsx +++ b/fe-admin/src/pages/khkk/KhkkDetailPage.tsx @@ -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 && ( + + N{k.approvalGroup} + + )} + 🔒 Giá đã chốt tại thời điểm duyệt + + ) : undefined + } > {k.lines.length === 0 ? (
Phiếu nguồn chưa có NCC trúng thầu.
@@ -431,6 +454,10 @@ export function KhkkDetailPage() { Nhà cung cấp + {/* [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). */} + Hạng mục Giá tham chiếu (phiếu NCC) Giá đề xuất Giá duyệt @@ -441,6 +468,9 @@ export function KhkkDetailPage() { {k.lines.map((l) => ( {l.supplierName ?? '—'} + + {l.tenHangMuc ? l.tenHangMuc : } + {formatVnd(l.peReferenceAmount)} {formatVnd(l.proposedAmount)} {formatVnd(l.approvedAmount)} @@ -452,6 +482,7 @@ export function KhkkDetailPage() { )}
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).
diff --git a/fe-admin/src/pages/khkk/KhkkListPage.tsx b/fe-admin/src/pages/khkk/KhkkListPage.tsx index 18a099a..9ef293a 100644 --- a/fe-admin/src/pages/khkk/KhkkListPage.tsx +++ b/fe-admin/src/pages/khkk/KhkkListPage.tsx @@ -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; ` @@ -172,7 +173,7 @@ export function KhkkListPage() {
- {/* [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. */}
@@ -183,6 +184,10 @@ export function KhkkListPage() { + {/* [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ố. */} + @@ -190,14 +195,14 @@ export function KhkkListPage() { {list.isLoading && ( - )} {!list.isLoading && items.length === 0 && ( - + {/* 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. */} + diff --git a/fe-admin/src/types/khkk.ts b/fe-admin/src/types/khkk.ts index a7b9e34..a99658c 100644 --- a/fe-admin/src/types/khkk.ts +++ b/fe-admin/src/types/khkk.ts @@ -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 = { + 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 { diff --git a/fe-user/src/pages/khkk/KhkkCreatePage.tsx b/fe-user/src/pages/khkk/KhkkCreatePage.tsx index 313b60d..1506a67 100644 --- a/fe-user/src/pages/khkk/KhkkCreatePage.tsx +++ b/fe-user/src/pages/khkk/KhkkCreatePage.tsx @@ -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(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 (
Đang tải danh sách phiếu...
} {!candidates.isLoading && list.length === 0 && (
- 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.
)}
{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 ( +
+ + {/* [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 + )} +
+ )} +
) })} @@ -188,27 +261,64 @@ export function KhkkCreatePage() { > -

Quy trình duyệt

+

Nhóm duyệt & quy trình

-
- - -
- Chỉ hiển thị quy trình loại "Kế hoạch ký kết HĐ" admin đã ghim cho user chọn +
+ {/* [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. */} +
+ + + {groupTaken ? ( +
+ 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. +
+ ) : ( +
+ Mỗi phiếu Duyệt NCC lập được 1 kế hoạch cho MỖI nhóm duyệt +
+ )} +
+ +
+ + +
+ Chỉ hiển thị quy trình loại "Kế hoạch ký kết HĐ" admin đã ghim cho user chọn +
@@ -243,7 +353,12 @@ export function KhkkCreatePage() { - diff --git a/fe-user/src/pages/khkk/KhkkDetailPage.tsx b/fe-user/src/pages/khkk/KhkkDetailPage.tsx index a154832..c8315d4 100644 --- a/fe-user/src/pages/khkk/KhkkDetailPage.tsx +++ b/fe-user/src/pages/khkk/KhkkDetailPage.tsx @@ -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 && ( + + N{k.approvalGroup} + + )} + 🔒 Giá đã chốt tại thời điểm duyệt + + ) : undefined + } > {k.lines.length === 0 ? (
Phiếu nguồn chưa có NCC trúng thầu.
@@ -431,6 +454,10 @@ export function KhkkDetailPage() {
+ {/* [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). */} + @@ -441,6 +468,9 @@ export function KhkkDetailPage() { {k.lines.map((l) => ( + @@ -452,6 +482,7 @@ export function KhkkDetailPage() { )}
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).
diff --git a/fe-user/src/pages/khkk/KhkkListPage.tsx b/fe-user/src/pages/khkk/KhkkListPage.tsx index 18a099a..9ef293a 100644 --- a/fe-user/src/pages/khkk/KhkkListPage.tsx +++ b/fe-user/src/pages/khkk/KhkkListPage.tsx @@ -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; ` @@ -172,7 +173,7 @@ export function KhkkListPage() {
- {/* [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. */}
Dự án Trạng thái Tổng đề xuấtTổng duyệt Người soạn Ngày tạo
+ Đang tải...
+ {k.maKeHoach ?? '—'} + {/* [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 && ( + + N{k.approvalGroup} + + )} @@ -249,6 +266,11 @@ export function KhkkListPage() { )} {formatVnd(k.totalProposedAmount)} + {k.totalApprovedAmount ? formatVnd(k.totalApprovedAmount) : '—'} + {k.drafterFullName ?? '—'} {formatDate(k.createdAt)}
Nhà cung cấpHạng mục Giá tham chiếu (phiếu NCC) Giá đề xuất Giá duyệt
{l.supplierName ?? '—'} + {l.tenHangMuc ? l.tenHangMuc : } + {formatVnd(l.peReferenceAmount)} {formatVnd(l.proposedAmount)} {formatVnd(l.approvedAmount)}
@@ -183,6 +184,10 @@ export function KhkkListPage() { + {/* [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ố. */} + @@ -190,14 +195,14 @@ export function KhkkListPage() { {list.isLoading && ( - )} {!list.isLoading && items.length === 0 && ( - + {/* 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. */} + diff --git a/fe-user/src/types/khkk.ts b/fe-user/src/types/khkk.ts index a7b9e34..a99658c 100644 --- a/fe-user/src/types/khkk.ts +++ b/fe-user/src/types/khkk.ts @@ -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 = { + 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 { diff --git a/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs b/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs index 01f4999..e056ca8 100644 --- a/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs @@ -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). diff --git a/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs b/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs index 797d12c..f082deb 100644 --- a/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs +++ b/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs @@ -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 DossierItems, List Attachments, List LevelOpinions, - List WorkflowSteps); + List 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 WinnerSupplierNames); // [W2 S161 — F-6] FE render chip tên NCC trúng thầu (append cuối) + List 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 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 đủ. /// +/// +/// [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. +/// public record CreateContractSigningPlanCommand( Guid PeId, Guid ApprovalWorkflowId, - string? GhiChu = null) : IRequest; + string? GhiChu = null, + int? ApprovalGroup = null) : IRequest; public class CreateContractSigningPlanCommandValidator : AbstractValidator { @@ -305,6 +345,12 @@ public class CreateContractSigningPlanCommandValidator : AbstractValidator 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Á ============================ +/// +/// [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). +/// public record ListContractSigningPlansQuery( ContractSigningPlanPhase? Phase = null, Guid? ProjectId = null, Guid? PurchaseEvaluationId = null, - bool PendingMe = false) : PagedRequest, IRequest>; + bool PendingMe = false, + int? ApprovalGroup = null) : PagedRequest, IRequest>; public class ListContractSigningPlansQueryHandler(IApplicationDbContext db, ICurrentUser currentUser) : IRequestHandler> @@ -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(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); } } diff --git a/src/Backend/SolutionErp.Domain/ContractSigningPlans/ContractSigningPlan.cs b/src/Backend/SolutionErp.Domain/ContractSigningPlans/ContractSigningPlan.cs index d679eaa..6cfe49a 100644 --- a/src/Backend/SolutionErp.Domain/ContractSigningPlans/ContractSigningPlan.cs +++ b/src/Backend/SolutionErp.Domain/ContractSigningPlans/ContractSigningPlan.cs @@ -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; } diff --git a/src/Backend/SolutionErp.Domain/ContractSigningPlans/ContractSigningPlanLine.cs b/src/Backend/SolutionErp.Domain/ContractSigningPlans/ContractSigningPlanLine.cs index 2d81c4b..c7a9ff7 100644 --- a/src/Backend/SolutionErp.Domain/ContractSigningPlans/ContractSigningPlanLine.cs +++ b/src/Backend/SolutionErp.Domain/ContractSigningPlans/ContractSigningPlanLine.cs @@ -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). diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/ContractSigningPlanConfiguration.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/ContractSigningPlanConfiguration.cs index 2793962..35b62b4 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/ContractSigningPlanConfiguration.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/ContractSigningPlanConfiguration.cs @@ -28,6 +28,17 @@ public class ContractSigningPlanConfiguration : IEntityTypeConfiguration 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); diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/ContractSigningPlanLineConfiguration.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/ContractSigningPlanLineConfiguration.cs index c533a59..920b5db 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/ContractSigningPlanLineConfiguration.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/ContractSigningPlanLineConfiguration.cs @@ -18,11 +18,27 @@ public class ContractSigningPlanLineConfiguration : IEntityTypeConfiguration 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) diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260731085624_AddKhkkApprovalGroupCatalogAndFinalizeRuntime.Designer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260731085624_AddKhkkApprovalGroupCatalogAndFinalizeRuntime.Designer.cs new file mode 100644 index 0000000..94433da --- /dev/null +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260731085624_AddKhkkApprovalGroupCatalogAndFinalizeRuntime.Designer.cs @@ -0,0 +1,7060 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SolutionErp.Infrastructure.Persistence; + +#nullable disable + +namespace SolutionErp.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260731085624_AddKhkkApprovalGroupCatalogAndFinalizeRuntime")] + partial class AddKhkkApprovalGroupCatalogAndFinalizeRuntime + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActivatedAt") + .HasColumnType("datetime2"); + + b.Property("ApplicableType") + .HasColumnType("int"); + + b.Property("CeoApprovalThreshold") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsUserSelectable") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ApplicableType", "IsActive"); + + b.HasIndex("Code", "Version") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AllowApproverDelete") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("AllowApproverEditBudget") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("AllowApproverEditDetails") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("AllowApproverFinalize") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("AllowApproverSkipToFinal") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("AllowReturnOneLevel") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("AllowReturnOneStep") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("AllowReturnToAssignee") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("AllowReturnToDrafter") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ApprovalWorkflowStepId") + .HasColumnType("uniqueidentifier"); + + b.Property("ApproverUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApproverUserId"); + + b.HasIndex("ApprovalWorkflowStepId", "Order"); + + b.ToTable("ApprovalWorkflowLevels", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("ApprovalWorkflowId", "Order"); + + b.ToTable("ApprovalWorkflowSteps", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalGroup") + .HasColumnType("int"); + + b.Property("ApprovalWorkflowId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("CurrentApprovalLevelOrder") + .HasColumnType("int"); + + b.Property("CurrentWorkflowStepIndex") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("DrafterUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("EndedByLevelFinalize") + .HasColumnType("bit"); + + b.Property("GhiChu") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("HoSoLink") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MaKeHoach") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Phase") + .HasColumnType("int"); + + b.Property("ProjectId") + .HasColumnType("uniqueidentifier"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SlaDeadline") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalWorkflowId"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("DrafterUserId"); + + b.HasIndex("MaKeHoach") + .IsUnique() + .HasFilter("[MaKeHoach] IS NOT NULL"); + + b.HasIndex("ProjectId"); + + b.HasIndex("PurchaseEvaluationId"); + + b.HasIndex("ApprovalGroup", "Phase"); + + b.HasIndex("Phase", "IsDeleted"); + + b.ToTable("ContractSigningPlans", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlanApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowLevelId") + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovedAt") + .HasColumnType("datetime2"); + + b.Property("ApprovedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ContractSigningPlanId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Decision") + .HasColumnType("int"); + + b.Property("FromPhase") + .HasColumnType("int"); + + b.Property("ToPhase") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalWorkflowLevelId"); + + b.HasIndex("ContractSigningPlanId", "ApprovedAt"); + + b.ToTable("ContractSigningPlanApprovals", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlanAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ContractSigningPlanDossierItemId") + .HasColumnType("uniqueidentifier"); + + b.Property("ContractSigningPlanId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Note") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Purpose") + .HasColumnType("int"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractSigningPlanDossierItemId"); + + b.HasIndex("ContractSigningPlanId"); + + b.ToTable("ContractSigningPlanAttachments", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlanChangelog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Action") + .HasColumnType("int"); + + b.Property("ContextNote") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ContractSigningPlanId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .HasColumnType("int"); + + b.Property("FieldChangesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("PhaseAtChange") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("ContractSigningPlanId", "CreatedAt"); + + b.HasIndex("ContractSigningPlanId", "EntityType"); + + b.ToTable("ContractSigningPlanChangelogs", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlanDossierItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractSigningPlanId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Kind") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Note") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("TvgsName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TvgsResultAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractSigningPlanId"); + + b.HasIndex("SupplierId"); + + b.ToTable("ContractSigningPlanDossierItems", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlanLevelOpinion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowLevelId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ContractSigningPlanId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignedByFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SignedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalWorkflowLevelId"); + + b.HasIndex("ContractSigningPlanId", "ApprovalWorkflowLevelId") + .IsUnique(); + + b.ToTable("ContractSigningPlanLevelOpinions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlanLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovedAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CatalogEntryId") + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("ContractSigningPlanId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Note") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("PeReferenceAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ProposedAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("SupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("TenHangMuc") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("CatalogEntryId"); + + b.HasIndex("ContractId"); + + b.HasIndex("SupplierId"); + + b.HasIndex("ContractSigningPlanId", "SupplierId", "CatalogEntryId") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("ContractSigningPlanLines", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowId") + .HasColumnType("uniqueidentifier"); + + b.Property("BudgetManualAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("BudgetManualName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("BypassProcurementAndCCM") + .HasColumnType("bit"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("CurrentApprovalLevelOrder") + .HasColumnType("int"); + + b.Property("CurrentWorkflowStepIndex") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("DraftData") + .HasColumnType("nvarchar(max)"); + + b.Property("DrafterUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("GiaTri") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MaHopDong") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("NoiDung") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Phase") + .HasColumnType("int"); + + b.Property("ProjectId") + .HasColumnType("uniqueidentifier"); + + b.Property("RejectedAtStepIndex") + .HasColumnType("int"); + + b.Property("RejectedFromPhase") + .HasColumnType("int"); + + b.Property("SlaDeadline") + .HasColumnType("datetime2"); + + b.Property("SlaWarningSent") + .HasColumnType("bit"); + + b.Property("SupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("TemplateId") + .HasColumnType("uniqueidentifier"); + + b.Property("TenHopDong") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkflowDefinitionId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalWorkflowId"); + + b.HasIndex("MaHopDong") + .IsUnique() + .HasFilter("[MaHopDong] IS NOT NULL"); + + b.HasIndex("ProjectId"); + + b.HasIndex("SlaDeadline"); + + b.HasIndex("SupplierId"); + + b.HasIndex("Phase", "IsDeleted"); + + b.ToTable("Contracts", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovedAt") + .HasColumnType("datetime2"); + + b.Property("ApproverUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Decision") + .HasColumnType("int"); + + b.Property("FromPhase") + .HasColumnType("int"); + + b.Property("ToPhase") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "ApprovedAt"); + + b.ToTable("ContractApprovals", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Purpose") + .HasColumnType("int"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId"); + + b.ToTable("ContractAttachments", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractChangelog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Action") + .HasColumnType("int"); + + b.Property("ContextNote") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .HasColumnType("int"); + + b.Property("FieldChangesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("PhaseAtChange") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "CreatedAt"); + + b.HasIndex("ContractId", "EntityType"); + + b.ToTable("ContractChangelogs", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractCodeSequence", b => + { + b.Property("Prefix") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("LastSeq") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Prefix"); + + b.ToTable("ContractCodeSequences", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Phase") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "CreatedAt"); + + b.ToTable("ContractComments", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractDepartmentApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovedAt") + .HasColumnType("datetime2"); + + b.Property("ApproverRoleSnapshot") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ApproverUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsBypassed") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("PhaseAtApproval") + .HasColumnType("int"); + + b.Property("Stage") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApproverUserId"); + + b.HasIndex("ContractId"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("ContractId", "PhaseAtApproval", "DepartmentId", "Stage") + .IsUnique() + .HasDatabaseName("UX_ContractDeptApprovals_Contract_Phase_Dept_Stage"); + + b.ToTable("ContractDepartmentApprovals", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractLevelOpinion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowLevelId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignedByFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SignedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalWorkflowLevelId"); + + b.HasIndex("ContractId", "ApprovalWorkflowLevelId") + .IsUnique(); + + b.ToTable("ContractLevelOpinions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.DichVuDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DenNgay") + .HasColumnType("datetime2"); + + b.Property("DonGia") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("MaDichVu") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("MoTa") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("TenDichVu") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ThoiGian") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TuNgay") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("DichVuDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.GiaoKhoanDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DonGia") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("KhoiLuong") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("MaCongViec") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("TenCongViec") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ThoiGianHoanThanh") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("YeuCauKyThuat") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("GiaoKhoanDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.MuaBanDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DonGia") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("MaSP") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("MoTa") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("SoLuong") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TenSP") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ThueVAT") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("XuatXu") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("MuaBanDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NguyenTacDvDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DonGiaToiDa") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonGiaToiThieu") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("LoaiDichVu") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("PhamViDichVu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("SLA") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("TenDichVu") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("NguyenTacDvDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NguyenTacNccDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DieuKienGiaoHang") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DieuKienThanhToan") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DonGiaToiDa") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonGiaToiThieu") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("NhomSP") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("TenSP") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("NguyenTacNccDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NhaCungCapDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DonGia") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("MaSP") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("SoLuong") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TenSP") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ThoiGianGiao") + .HasColumnType("datetime2"); + + b.Property("ThongSoKyThuat") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("XuatXu") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("NhaCungCapDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.ThauPhuDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DonGia") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("HangMuc") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("KhoiLuong") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ThoiGianHoanThanh") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("ThauPhuDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActivatedAt") + .HasColumnType("datetime2"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ContractType") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Code", "Version") + .IsUnique(); + + b.HasIndex("ContractType", "IsActive"); + + b.ToTable("WorkflowDefinitions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("Phase") + .HasColumnType("int"); + + b.Property("PositionLevel") + .HasColumnType("int"); + + b.Property("SlaDays") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkflowDefinitionId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("WorkflowDefinitionId", "Order"); + + b.ToTable("WorkflowSteps", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStepApprover", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssignmentValue") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Kind") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkflowStepId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkflowStepId"); + + b.ToTable("WorkflowStepApprovers", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowTypeAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractType") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractType") + .IsUnique(); + + b.ToTable("WorkflowTypeAssignments", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Forms.ContractClause", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("ContractClauses", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Forms.ContractTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractType") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FieldSpec") + .HasColumnType("nvarchar(max)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FormCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Format") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractType"); + + b.HasIndex("FormCode") + .IsUnique(); + + b.ToTable("ContractTemplates", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.Driver", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LicenseClass") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("LicenseNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("Drivers", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeCodeSequence", b => + { + b.Property("Prefix") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("LastSeq") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Prefix"); + + b.ToTable("EmployeeCodeSequences", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DocumentType") + .HasColumnType("int"); + + b.Property("EmployeeProfileId") + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiryDate") + .HasColumnType("date"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FilePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IssueDate") + .HasColumnType("date"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("DocumentType"); + + b.HasIndex("EmployeeProfileId"); + + b.ToTable("EmployeeDocuments", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeEducation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CertificateIssueDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DegreeLevel") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EducationMode") + .HasColumnType("int"); + + b.Property("EmployeeProfileId") + .HasColumnType("uniqueidentifier"); + + b.Property("FromDate") + .HasColumnType("date"); + + b.Property("GradeLevel") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Major") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SchoolName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ToDate") + .HasColumnType("date"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeProfileId"); + + b.ToTable("EmployeeEducations", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeFamilyRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BirthYear") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("CurrentAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EmployeeProfileId") + .HasColumnType("uniqueidentifier"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Occupation") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Relationship") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeProfileId"); + + b.ToTable("EmployeeFamilyRelations", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AcademicTitle") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("AnnualLeaveDays") + .HasColumnType("decimal(5,2)"); + + b.Property("BankAccount") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("BankBranch") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("BankName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("BaseSalary") + .HasColumnType("decimal(18,2)"); + + b.Property("BirthPlace") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("BloodType") + .HasMaxLength(5) + .HasColumnType("nvarchar(5)"); + + b.Property("CommunistPartyJoinDate") + .HasColumnType("date"); + + b.Property("CompensatoryLeaveDays") + .HasColumnType("decimal(5,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EmergencyContactAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EmergencyContactName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EmergencyContactPhone") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("EmployeeCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EmployeeStatus") + .HasColumnType("int"); + + b.Property("EmployeeType") + .HasColumnType("int"); + + b.Property("Ethnicity") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HeightCm") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("date"); + + b.Property("Hometown") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IdCardIssueDate") + .HasColumnType("date"); + + b.Property("IdCardIssuePlace") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IdCardNumber") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("InternalPhone") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("IsCommunistParty") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsTradeUnion") + .HasColumnType("bit"); + + b.Property("IsYouthUnion") + .HasColumnType("bit"); + + b.Property("MaritalStatus") + .HasColumnType("int"); + + b.Property("MedicalRegistrationPlace") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Nationality") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("PassportNumber") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("PermanentAddressText") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PermanentDistrictId") + .HasColumnType("uniqueidentifier"); + + b.Property("PermanentProvinceId") + .HasColumnType("uniqueidentifier"); + + b.Property("PermanentWardId") + .HasColumnType("uniqueidentifier"); + + b.Property("PersonalEmail") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("PhotoUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Qualification") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Religion") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("RemainingLeaveDays") + .HasColumnType("decimal(5,2)"); + + b.Property("ResignDate") + .HasColumnType("date"); + + b.Property("SeniorityLeaveDays") + .HasColumnType("decimal(5,2)"); + + b.Property("SocialInsuranceNumber") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SocialInsuranceStartDate") + .HasColumnType("date"); + + b.Property("StreetAddressPermanent") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("StreetAddressTemporary") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TaxCode") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TemporaryAddressText") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("TemporaryDistrictId") + .HasColumnType("uniqueidentifier"); + + b.Property("TemporaryProvinceId") + .HasColumnType("uniqueidentifier"); + + b.Property("TemporaryWardId") + .HasColumnType("uniqueidentifier"); + + b.Property("TimekeepingCode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TotalSalary") + .HasColumnType("decimal(18,2)"); + + b.Property("TradeUnionJoinDate") + .HasColumnType("date"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("WeightKg") + .HasColumnType("int"); + + b.Property("WorkLocation") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("YouthUnionJoinDate") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeCode") + .IsUnique(); + + b.HasIndex("IsDeleted"); + + b.HasIndex("Phone"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("EmployeeProfiles", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeSkill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EmployeeProfileId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Kind") + .HasColumnType("int"); + + b.Property("LanguageId") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Level") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeProfileId"); + + b.HasIndex("Kind"); + + b.ToTable("EmployeeSkills", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeWorkHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompanyAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EmployeeProfileId") + .HasColumnType("uniqueidentifier"); + + b.Property("FromDate") + .HasColumnType("date"); + + b.Property("Industry") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobDescription") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("JobTitle") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ResignReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ToDate") + .HasColumnType("date"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeProfileId"); + + b.ToTable("EmployeeWorkHistories", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.Holiday", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsPaid") + .HasColumnType("bit"); + + b.Property("IsRecurring") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Year") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Year", "Date") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("Holidays", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.LeaveBalance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AdjustmentDays") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EntitledDays") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LeaveTypeId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UsedDays") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Year") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("LeaveTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "LeaveTypeId", "Year") + .IsUnique(); + + b.ToTable("LeaveBalances", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.LeaveType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DaysPerYear") + .HasColumnType("decimal(5,2)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsPaid") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequiresAttachment") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("LeaveTypes", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.OtPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MaxHoursPerDay") + .HasColumnType("int"); + + b.Property("MaxHoursPerMonth") + .HasColumnType("int"); + + b.Property("MaxHoursPerYear") + .HasColumnType("int"); + + b.Property("MultiplierHoliday") + .HasColumnType("decimal(4,2)"); + + b.Property("MultiplierWeekday") + .HasColumnType("decimal(4,2)"); + + b.Property("MultiplierWeekend") + .HasColumnType("decimal(4,2)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("OtPolicies", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.ShiftPattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BreakMinutes") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EndTime") + .HasColumnType("time"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("StartTime") + .HasColumnType("time"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkDays") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("ShiftPatterns", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.Vehicle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LicensePlate") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SeatCount") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("Vehicles", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b => + { + b.Property("Key") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DisplayLabel") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Icon") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsVisible") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("ParentKey") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Key"); + + b.HasIndex("ParentKey"); + + b.ToTable("MenuItems", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CanCreate") + .HasColumnType("bit"); + + b.Property("CanDelete") + .HasColumnType("bit"); + + b.Property("CanRead") + .HasColumnType("bit"); + + b.Property("CanUpdate") + .HasColumnType("bit"); + + b.Property("MenuKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("MenuKey"); + + b.HasIndex("RoleId", "MenuKey") + .IsUnique(); + + b.ToTable("Permissions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ShortName") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("CanBypassReview") + .HasColumnType("bit"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("Position") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PositionLevel") + .HasColumnType("int"); + + b.Property("RefreshToken") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("RefreshTokenExpiresAt") + .HasColumnType("datetime2"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Catalogs.ContractCatalogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalGroup") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("GroupCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("InitialsChain") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("NduqNote") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SignerRole") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("TenEn") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("TenVi") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalGroup"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("ContractCatalogEntries", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Catalogs.MaterialItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DefaultUnit") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OriginCountry") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Specification") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("MaterialItems", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Catalogs.ServiceItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DefaultUnit") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("ServiceItems", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Catalogs.UnitOfMeasure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("UnitsOfMeasure", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Catalogs.WorkItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DefaultUnit") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("WorkItems", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ManagerUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Note") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ParentId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.HasIndex("ParentId"); + + b.ToTable("Departments", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BudgetTotal") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("Investor") + .HasMaxLength(250) + .HasColumnType("nvarchar(250)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Location") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ManagerUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Note") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Package") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Year") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("Projects", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("AuthorizationNote") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("BankAccount") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ContactPerson") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ContactPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ContactTitle") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Fax") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsPublic") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("LegalRepTitle") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("LegalRepresentative") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("LinkGpkd") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("LinkGuq") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("LinkHsnl") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("MailRecipient") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("MailingAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Note") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("OfficeAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("OwnerPmh") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("PackageCategory") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ReferralSource") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("SecondaryBankAccount") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SourceUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("SourceUpdatedBy") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TaxCode") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0 AND [Code] <> ''"); + + b.HasIndex("Type"); + + b.ToTable("Suppliers", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Href") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ReadAt") + .HasColumnType("datetime2"); + + b.Property("RefId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId", "ReadAt"); + + b.ToTable("Notifications", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.Attendance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AttendanceDate") + .HasColumnType("datetime2"); + + b.Property("CheckInAccuracy") + .HasColumnType("decimal(8,2)"); + + b.Property("CheckInAt") + .HasColumnType("datetime2"); + + b.Property("CheckInLatitude") + .HasColumnType("decimal(10,7)"); + + b.Property("CheckInLongitude") + .HasColumnType("decimal(10,7)"); + + b.Property("CheckOutAccuracy") + .HasColumnType("decimal(8,2)"); + + b.Property("CheckOutAt") + .HasColumnType("datetime2"); + + b.Property("CheckOutLatitude") + .HasColumnType("decimal(10,7)"); + + b.Property("CheckOutLongitude") + .HasColumnType("decimal(10,7)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IpAddressIn") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IpAddressOut") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("OtHours") + .HasColumnType("decimal(5,2)"); + + b.Property("SourceIn") + .HasColumnType("int"); + + b.Property("SourceOut") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkHours") + .HasColumnType("decimal(5,2)"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "AttendanceDate") + .IsUnique(); + + b.ToTable("Attendances", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.ItTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssignedToFullName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("AssignedToUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(5000) + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MaTicket") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("RequesterFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequesterUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Resolution") + .HasMaxLength(5000) + .HasColumnType("nvarchar(max)"); + + b.Property("ResolvedAt") + .HasColumnType("datetime2"); + + b.Property("SlaBreached") + .HasColumnType("bit"); + + b.Property("SlaDueAt") + .HasColumnType("datetime2"); + + b.Property("SlaWarnedSent") + .HasColumnType("bit"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("AssignedToUserId"); + + b.HasIndex("Category"); + + b.HasIndex("MaTicket") + .IsUnique() + .HasFilter("[MaTicket] IS NOT NULL"); + + b.HasIndex("RequesterUserId"); + + b.HasIndex("Status"); + + b.ToTable("ItTickets", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.LeaveRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("CurrentApprovalLevelOrder") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LeaveTypeId") + .HasColumnType("uniqueidentifier"); + + b.Property("MaDonTu") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("NumDays") + .HasColumnType("decimal(5,2)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("RejectedFromStatus") + .HasColumnType("int"); + + b.Property("RequesterFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequesterUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("MaDonTu") + .IsUnique() + .HasFilter("[MaDonTu] IS NOT NULL"); + + b.HasIndex("RequesterUserId"); + + b.HasIndex("Status"); + + b.ToTable("LeaveRequests", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.LeaveRequestLevelOpinion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowLevelId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LeaveRequestId") + .HasColumnType("uniqueidentifier"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignedByFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SignedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalWorkflowLevelId"); + + b.HasIndex("LeaveRequestId", "ApprovalWorkflowLevelId") + .IsUnique(); + + b.ToTable("LeaveRequestLevelOpinions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBooking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BookedByFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("BookedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("EndAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("RoomId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("BookedByUserId"); + + b.HasIndex("RoomId", "StartAt"); + + b.ToTable("MeetingBookings", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBookingAttendee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BookingId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("BookingId", "UserId") + .IsUnique(); + + b.ToTable("MeetingBookingAttendees", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.MeetingRoom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Equipment") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Location") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("MeetingRooms", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.OtRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("CurrentApprovalLevelOrder") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EndTime") + .HasColumnType("time"); + + b.Property("Hours") + .HasColumnType("decimal(5,2)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MaDonTu") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("OtDate") + .HasColumnType("datetime2"); + + b.Property("OtPolicyId") + .HasColumnType("uniqueidentifier"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("RejectedFromStatus") + .HasColumnType("int"); + + b.Property("RequesterFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequesterUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartTime") + .HasColumnType("time"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("MaDonTu") + .IsUnique() + .HasFilter("[MaDonTu] IS NOT NULL"); + + b.HasIndex("RequesterUserId"); + + b.HasIndex("Status"); + + b.ToTable("OtRequests", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.OtRequestLevelOpinion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowLevelId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("OtRequestId") + .HasColumnType("uniqueidentifier"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignedByFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SignedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalWorkflowLevelId"); + + b.HasIndex("OtRequestId", "ApprovalWorkflowLevelId") + .IsUnique(); + + b.ToTable("OtRequestLevelOpinions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.Proposal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AmountEstimate") + .HasColumnType("decimal(18,2)"); + + b.Property("ApprovalWorkflowId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("CurrentApprovalLevelOrder") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(5000) + .HasColumnType("nvarchar(max)"); + + b.Property("DrafterUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MaDeXuat") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("RejectedFromStatus") + .HasColumnType("int"); + + b.Property("SlaDeadline") + .HasColumnType("datetime2"); + + b.Property("SlaWarningSent") + .HasColumnType("bit"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalWorkflowId"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("DrafterUserId"); + + b.HasIndex("MaDeXuat") + .IsUnique() + .HasFilter("[MaDeXuat] IS NOT NULL"); + + b.HasIndex("Status"); + + b.ToTable("Proposals", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.ProposalAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FilePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MimeType") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ProposalId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UploadedByFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UploadedByUserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.ToTable("ProposalAttachments", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.ProposalCodeSequence", b => + { + b.Property("Prefix") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("LastSeq") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Prefix"); + + b.ToTable("ProposalCodeSequences", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.ProposalLevelOpinion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowLevelId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ProposalId") + .HasColumnType("uniqueidentifier"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignedByFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SignedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalWorkflowLevelId"); + + b.HasIndex("ProposalId", "ApprovalWorkflowLevelId") + .IsUnique(); + + b.ToTable("ProposalLevelOpinions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.TravelRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("CurrentApprovalLevelOrder") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Destination") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("EstimatedCost") + .HasColumnType("decimal(18,2)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MaDonTu") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("NumDays") + .HasColumnType("int"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("RejectedFromStatus") + .HasColumnType("int"); + + b.Property("RequesterFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequesterUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("MaDonTu") + .IsUnique() + .HasFilter("[MaDonTu] IS NOT NULL"); + + b.HasIndex("RequesterUserId"); + + b.HasIndex("Status"); + + b.ToTable("TravelRequests", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.TravelRequestLevelOpinion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowLevelId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignedByFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SignedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("TravelRequestId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalWorkflowLevelId"); + + b.HasIndex("TravelRequestId", "ApprovalWorkflowLevelId") + .IsUnique(); + + b.ToTable("TravelRequestLevelOpinions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.VehicleBooking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("CurrentApprovalLevelOrder") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Destination") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("DriverName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EndAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MaDonTu") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("RejectedFromStatus") + .HasColumnType("int"); + + b.Property("RequesterFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequesterUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("VehicleLicense") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VehicleName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("MaDonTu") + .IsUnique() + .HasFilter("[MaDonTu] IS NOT NULL"); + + b.HasIndex("RequesterUserId"); + + b.HasIndex("Status"); + + b.HasIndex("VehicleLicense", "StartAt"); + + b.ToTable("VehicleBookings", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.VehicleBookingLevelOpinion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowLevelId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignedByFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SignedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("VehicleBookingId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalWorkflowLevelId"); + + b.HasIndex("VehicleBookingId", "ApprovalWorkflowLevelId") + .IsUnique(); + + b.ToTable("VehicleBookingLevelOpinions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.WorkflowAppCodeSequence", b => + { + b.Property("Prefix") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("LastSeq") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Prefix"); + + b.ToTable("WorkflowAppCodeSequences", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PeWorkItemBudget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AdjustmentAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CcmNote") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("InitialAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ProAdjustmentAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ProEstimateAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ProInitialAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ProNote") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ProjectId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkItemId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkItemId"); + + b.HasIndex("ProjectId", "WorkItemId") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("PeWorkItemBudgets", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowId") + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovedBudgetCcmAdjustmentAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ApprovedBudgetCcmInitialAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ApprovedBudgetCcmNote") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ApprovedBudgetPrevSelectedCount") + .HasColumnType("int"); + + b.Property("ApprovedBudgetPrevSelectedTotal") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ApprovedBudgetPrevSubmittedCount") + .HasColumnType("int"); + + b.Property("ApprovedBudgetPrevSubmittedTotal") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ApprovedBudgetProAdjustmentAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ApprovedBudgetProInitialAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ApprovedBudgetProNote") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ApprovedBudgetSnapshotAt") + .HasColumnType("datetime2"); + + b.Property("ApprovedPriceAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ApprovedPriceMaxAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ApprovedPriceSource") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("BudgetPeriodAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CcmBudgetPeriodAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("CcmSuggestedPrice") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CcmSuggestedPriceNote") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("CurrentApprovalLevelOrder") + .HasColumnType("int"); + + b.Property("CurrentWorkflowStepIndex") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("DiaDiem") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DrafterUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("EndedByLevelFinalize") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ExpectedRemainingAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("HoSoLink") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsUrgentByCcm") + .HasColumnType("bit"); + + b.Property("IsUrgentByPro") + .HasColumnType("bit"); + + b.Property("MaPhieu") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("MoTa") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("PaymentTerms") + .HasColumnType("nvarchar(max)"); + + b.Property("Phase") + .HasColumnType("int"); + + b.Property("ProSuggestedMaxPrice") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ProSuggestedMinPrice") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ProSuggestedPriceNote") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ProjectId") + .HasColumnType("uniqueidentifier"); + + b.Property("RejectedAtStepIndex") + .HasColumnType("int"); + + b.Property("RejectedFromPhase") + .HasColumnType("int"); + + b.Property("SelectedSupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("SlaDeadline") + .HasColumnType("datetime2"); + + b.Property("SlaWarningSent") + .HasColumnType("bit"); + + b.Property("TenGoiThau") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkItemId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkflowDefinitionId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalWorkflowId"); + + b.HasIndex("ContractId"); + + b.HasIndex("MaPhieu") + .IsUnique() + .HasFilter("[MaPhieu] IS NOT NULL"); + + b.HasIndex("ProjectId"); + + b.HasIndex("SlaDeadline"); + + b.HasIndex("WorkItemId"); + + b.HasIndex("WorkflowDefinitionId"); + + b.HasIndex("Phase", "IsDeleted"); + + b.ToTable("PurchaseEvaluations", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovedAt") + .HasColumnType("datetime2"); + + b.Property("ApproverUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Decision") + .HasColumnType("int"); + + b.Property("FromPhase") + .HasColumnType("int"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("ToPhase") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationId", "ApprovedAt"); + + b.ToTable("PurchaseEvaluationApprovals", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PurchaseEvaluationSupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("Purpose") + .HasColumnType("int"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationId"); + + b.HasIndex("PurchaseEvaluationSupplierId"); + + b.ToTable("PurchaseEvaluationAttachments", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationChangelog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Action") + .HasColumnType("int"); + + b.Property("ContextNote") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .HasColumnType("int"); + + b.Property("FieldChangesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("PhaseAtChange") + .HasColumnType("int"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationId", "CreatedAt"); + + b.HasIndex("PurchaseEvaluationId", "EntityType"); + + b.ToTable("PurchaseEvaluationChangelogs", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationCodeSequence", b => + { + b.Property("Prefix") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastSeq") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Prefix"); + + b.ToTable("PurchaseEvaluationCodeSequences", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDepartmentApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovedAt") + .HasColumnType("datetime2"); + + b.Property("ApproverRoleSnapshot") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ApproverUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsBypassed") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("PhaseAtApproval") + .HasColumnType("int"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Stage") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApproverUserId"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("PurchaseEvaluationId"); + + b.HasIndex("PurchaseEvaluationId", "PhaseAtApproval", "DepartmentId", "Stage") + .IsUnique() + .HasDatabaseName("UX_PEDeptApprovals_PE_Phase_Dept_Stage"); + + b.ToTable("PurchaseEvaluationDepartmentApprovals", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDepartmentOpinion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Kind") + .HasColumnType("int"); + + b.Property("Opinion") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationId", "Kind") + .IsUnique(); + + b.ToTable("PurchaseEvaluationDepartmentOpinions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DonGiaNganSach") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("GroupCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ItemCode") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("KhoiLuongNganSach") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("KhoiLuongThiCong") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("NoiDung") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("ThanhTienNganSach") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationId", "Order"); + + b.ToTable("PurchaseEvaluationDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationLevelOpinion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowLevelId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignedByFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SignedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalWorkflowLevelId"); + + b.HasIndex("PurchaseEvaluationId", "ApprovalWorkflowLevelId") + .IsUnique(); + + b.ToTable("PurchaseEvaluationLevelOpinions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationQuote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BgVat") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ChuaVat") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsSelected") + .HasColumnType("bit"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PurchaseEvaluationDetailId") + .HasColumnType("uniqueidentifier"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PurchaseEvaluationSupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationId"); + + b.HasIndex("PurchaseEvaluationSupplierId"); + + b.HasIndex("PurchaseEvaluationDetailId", "PurchaseEvaluationSupplierId") + .IsUnique(); + + b.ToTable("PurchaseEvaluationQuotes", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationSupplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContactEmail") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ContactName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ContactPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IsWinner") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("PaymentTermText") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId"); + + b.HasIndex("PurchaseEvaluationId", "SupplierId") + .IsUnique(); + + b.ToTable("PurchaseEvaluationSuppliers", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActivatedAt") + .HasColumnType("datetime2"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("EvaluationType") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Code", "Version") + .IsUnique(); + + b.HasIndex("EvaluationType", "IsActive"); + + b.ToTable("PurchaseEvaluationWorkflowDefinitions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("Phase") + .HasColumnType("int"); + + b.Property("PositionLevel") + .HasColumnType("int"); + + b.Property("PurchaseEvaluationWorkflowDefinitionId") + .HasColumnType("uniqueidentifier"); + + b.Property("SlaDays") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("PurchaseEvaluationWorkflowDefinitionId", "Order"); + + b.ToTable("PurchaseEvaluationWorkflowSteps", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStepApprover", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssignmentValue") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Kind") + .HasColumnType("int"); + + b.Property("PurchaseEvaluationWorkflowStepId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationWorkflowStepId"); + + b.ToTable("PurchaseEvaluationWorkflowStepApprovers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("SolutionErp.Domain.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("SolutionErp.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("SolutionErp.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("SolutionErp.Domain.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("SolutionErp.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", b => + { + b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowStep", "Step") + .WithMany("Levels") + .HasForeignKey("ApprovalWorkflowStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ApproverUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowStep", b => + { + b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", "ApprovalWorkflow") + .WithMany("Steps") + .HasForeignKey("ApprovalWorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.Master.Department", null) + .WithMany() + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApprovalWorkflow"); + }); + + modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlan", b => + { + b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", null) + .WithMany() + .HasForeignKey("ApprovalWorkflowId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlanApproval", b => + { + b.HasOne("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlan", "ContractSigningPlan") + .WithMany("Approvals") + .HasForeignKey("ContractSigningPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ContractSigningPlan"); + }); + + modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlanAttachment", b => + { + b.HasOne("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlan", "ContractSigningPlan") + .WithMany("Attachments") + .HasForeignKey("ContractSigningPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ContractSigningPlan"); + }); + + modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlanChangelog", b => + { + b.HasOne("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlan", "ContractSigningPlan") + .WithMany("Changelogs") + .HasForeignKey("ContractSigningPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ContractSigningPlan"); + }); + + modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlanDossierItem", b => + { + b.HasOne("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlan", "ContractSigningPlan") + .WithMany("DossierItems") + .HasForeignKey("ContractSigningPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ContractSigningPlan"); + }); + + modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlanLevelOpinion", b => + { + b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level") + .WithMany() + .HasForeignKey("ApprovalWorkflowLevelId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlan", "ContractSigningPlan") + .WithMany("LevelOpinions") + .HasForeignKey("ContractSigningPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ContractSigningPlan"); + + b.Navigation("Level"); + }); + + modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlanLine", b => + { + b.HasOne("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlan", "ContractSigningPlan") + .WithMany("Lines") + .HasForeignKey("ContractSigningPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ContractSigningPlan"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b => + { + b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", null) + .WithMany() + .HasForeignKey("ApprovalWorkflowId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractApproval", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("Approvals") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractAttachment", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("Attachments") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractChangelog", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("Changelogs") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractComment", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("Comments") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractDepartmentApproval", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("DepartmentApprovals") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractLevelOpinion", b => + { + b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level") + .WithMany() + .HasForeignKey("ApprovalWorkflowLevelId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("LevelOpinions") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + + b.Navigation("Level"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.DichVuDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("DichVuDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.GiaoKhoanDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("GiaoKhoanDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.MuaBanDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("MuaBanDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NguyenTacDvDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("NguyenTacDvDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NguyenTacNccDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("NguyenTacNccDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NhaCungCapDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("NhaCungCapDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.ThauPhuDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("ThauPhuDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b => + { + b.HasOne("SolutionErp.Domain.Master.Department", null) + .WithMany() + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SolutionErp.Domain.Contracts.WorkflowDefinition", "WorkflowDefinition") + .WithMany("Steps") + .HasForeignKey("WorkflowDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WorkflowDefinition"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStepApprover", b => + { + b.HasOne("SolutionErp.Domain.Contracts.WorkflowStep", "Step") + .WithMany("Approvers") + .HasForeignKey("WorkflowStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeDocument", b => + { + b.HasOne("SolutionErp.Domain.Hrm.EmployeeProfile", "EmployeeProfile") + .WithMany("Documents") + .HasForeignKey("EmployeeProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmployeeProfile"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeEducation", b => + { + b.HasOne("SolutionErp.Domain.Hrm.EmployeeProfile", "EmployeeProfile") + .WithMany("Educations") + .HasForeignKey("EmployeeProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmployeeProfile"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeFamilyRelation", b => + { + b.HasOne("SolutionErp.Domain.Hrm.EmployeeProfile", "EmployeeProfile") + .WithMany("FamilyRelations") + .HasForeignKey("EmployeeProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmployeeProfile"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeProfile", b => + { + b.HasOne("SolutionErp.Domain.Identity.User", "User") + .WithOne() + .HasForeignKey("SolutionErp.Domain.Hrm.EmployeeProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeSkill", b => + { + b.HasOne("SolutionErp.Domain.Hrm.EmployeeProfile", "EmployeeProfile") + .WithMany("Skills") + .HasForeignKey("EmployeeProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmployeeProfile"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeWorkHistory", b => + { + b.HasOne("SolutionErp.Domain.Hrm.EmployeeProfile", "EmployeeProfile") + .WithMany("WorkHistories") + .HasForeignKey("EmployeeProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmployeeProfile"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.LeaveBalance", b => + { + b.HasOne("SolutionErp.Domain.Hrm.LeaveType", "LeaveType") + .WithMany() + .HasForeignKey("LeaveTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("LeaveType"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b => + { + b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Parent") + .WithMany("Children") + .HasForeignKey("ParentKey") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.Permission", b => + { + b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Menu") + .WithMany("Permissions") + .HasForeignKey("MenuKey") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.Identity.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Menu"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.User", b => + { + b.HasOne("SolutionErp.Domain.Master.Department", null) + .WithMany() + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.LeaveRequestLevelOpinion", b => + { + b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level") + .WithMany() + .HasForeignKey("ApprovalWorkflowLevelId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.Office.LeaveRequest", "LeaveRequest") + .WithMany("LevelOpinions") + .HasForeignKey("LeaveRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LeaveRequest"); + + b.Navigation("Level"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBooking", b => + { + b.HasOne("SolutionErp.Domain.Office.MeetingRoom", "Room") + .WithMany() + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Room"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBookingAttendee", b => + { + b.HasOne("SolutionErp.Domain.Office.MeetingBooking", "Booking") + .WithMany("Attendees") + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Booking"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.OtRequestLevelOpinion", b => + { + b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level") + .WithMany() + .HasForeignKey("ApprovalWorkflowLevelId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.Office.OtRequest", "OtRequest") + .WithMany("LevelOpinions") + .HasForeignKey("OtRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Level"); + + b.Navigation("OtRequest"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.ProposalAttachment", b => + { + b.HasOne("SolutionErp.Domain.Office.Proposal", "Proposal") + .WithMany("Attachments") + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.ProposalLevelOpinion", b => + { + b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level") + .WithMany() + .HasForeignKey("ApprovalWorkflowLevelId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.Office.Proposal", "Proposal") + .WithMany("LevelOpinions") + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Level"); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.TravelRequestLevelOpinion", b => + { + b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level") + .WithMany() + .HasForeignKey("ApprovalWorkflowLevelId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.Office.TravelRequest", "TravelRequest") + .WithMany("LevelOpinions") + .HasForeignKey("TravelRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Level"); + + b.Navigation("TravelRequest"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.VehicleBookingLevelOpinion", b => + { + b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level") + .WithMany() + .HasForeignKey("ApprovalWorkflowLevelId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.Office.VehicleBooking", "VehicleBooking") + .WithMany("LevelOpinions") + .HasForeignKey("VehicleBookingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Level"); + + b.Navigation("VehicleBooking"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b => + { + b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", null) + .WithMany() + .HasForeignKey("ApprovalWorkflowId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationApproval", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("Approvals") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationAttachment", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("Attachments") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationChangelog", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("Changelogs") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDepartmentApproval", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("DepartmentApprovals") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDepartmentOpinion", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("DepartmentOpinions") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("Details") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationLevelOpinion", b => + { + b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level") + .WithMany() + .HasForeignKey("ApprovalWorkflowLevelId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("LevelOpinions") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Level"); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationQuote", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", "Detail") + .WithMany("Quotes") + .HasForeignKey("PurchaseEvaluationDetailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", null) + .WithMany("Quotes") + .HasForeignKey("PurchaseEvaluationId"); + + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationSupplier", "Supplier") + .WithMany() + .HasForeignKey("PurchaseEvaluationSupplierId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Detail"); + + b.Navigation("Supplier"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationSupplier", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("Suppliers") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", b => + { + b.HasOne("SolutionErp.Domain.Master.Department", null) + .WithMany() + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowDefinition", "Definition") + .WithMany("Steps") + .HasForeignKey("PurchaseEvaluationWorkflowDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Definition"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStepApprover", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", "Step") + .WithMany("Approvers") + .HasForeignKey("PurchaseEvaluationWorkflowStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowStep", b => + { + b.Navigation("Levels"); + }); + + modelBuilder.Entity("SolutionErp.Domain.ContractSigningPlans.ContractSigningPlan", b => + { + b.Navigation("Approvals"); + + b.Navigation("Attachments"); + + b.Navigation("Changelogs"); + + b.Navigation("DossierItems"); + + b.Navigation("LevelOpinions"); + + b.Navigation("Lines"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b => + { + b.Navigation("Approvals"); + + b.Navigation("Attachments"); + + b.Navigation("Changelogs"); + + b.Navigation("Comments"); + + b.Navigation("DepartmentApprovals"); + + b.Navigation("DichVuDetails"); + + b.Navigation("GiaoKhoanDetails"); + + b.Navigation("LevelOpinions"); + + b.Navigation("MuaBanDetails"); + + b.Navigation("NguyenTacDvDetails"); + + b.Navigation("NguyenTacNccDetails"); + + b.Navigation("NhaCungCapDetails"); + + b.Navigation("ThauPhuDetails"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowDefinition", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b => + { + b.Navigation("Approvers"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeProfile", b => + { + b.Navigation("Documents"); + + b.Navigation("Educations"); + + b.Navigation("FamilyRelations"); + + b.Navigation("Skills"); + + b.Navigation("WorkHistories"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b => + { + b.Navigation("Children"); + + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.LeaveRequest", b => + { + b.Navigation("LevelOpinions"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBooking", b => + { + b.Navigation("Attendees"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.OtRequest", b => + { + b.Navigation("LevelOpinions"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.Proposal", b => + { + b.Navigation("Attachments"); + + b.Navigation("LevelOpinions"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.TravelRequest", b => + { + b.Navigation("LevelOpinions"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.VehicleBooking", b => + { + b.Navigation("LevelOpinions"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b => + { + b.Navigation("Approvals"); + + b.Navigation("Attachments"); + + b.Navigation("Changelogs"); + + b.Navigation("DepartmentApprovals"); + + b.Navigation("DepartmentOpinions"); + + b.Navigation("Details"); + + b.Navigation("LevelOpinions"); + + b.Navigation("Quotes"); + + b.Navigation("Suppliers"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", b => + { + b.Navigation("Quotes"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowDefinition", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", b => + { + b.Navigation("Approvers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260731085624_AddKhkkApprovalGroupCatalogAndFinalizeRuntime.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260731085624_AddKhkkApprovalGroupCatalogAndFinalizeRuntime.cs new file mode 100644 index 0000000..acb53b8 --- /dev/null +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260731085624_AddKhkkApprovalGroupCatalogAndFinalizeRuntime.cs @@ -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 + { + /// + 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( + 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( + name: "EndedByLevelFinalize", + table: "ContractSigningPlans", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "CatalogEntryId", + table: "ContractSigningPlanLines", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + 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"); + } + + /// + 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"); + } + } +} diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index 2d039fd..7449db3 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -318,6 +318,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations .ValueGeneratedOnAdd() .HasColumnType("uniqueidentifier"); + b.Property("ApprovalGroup") + .HasColumnType("int"); + b.Property("ApprovalWorkflowId") .HasColumnType("uniqueidentifier"); @@ -345,6 +348,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations b.Property("DrafterUserId") .HasColumnType("uniqueidentifier"); + b.Property("EndedByLevelFinalize") + .HasColumnType("bit"); + b.Property("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("CatalogEntryId") + .HasColumnType("uniqueidentifier"); + b.Property("ContractId") .HasColumnType("uniqueidentifier"); @@ -746,6 +757,10 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations b.Property("SupplierId") .HasColumnType("uniqueidentifier"); + b.Property("TenHangMuc") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + b.Property("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"); diff --git a/src/Backend/SolutionErp.Infrastructure/Services/ContractSigningPlanWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/ContractSigningPlanWorkflowService.cs index a02fa74..a97fa1a 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/ContractSigningPlanWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/ContractSigningPlanWorkflowService.cs @@ -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) diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanApprovalTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanApprovalTests.cs index 2b16675..f6597bf 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanApprovalTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanApprovalTests.cs @@ -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( + "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. // =================================================================== diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanGroupAndCatalogTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanGroupAndCatalogTests.cs new file mode 100644 index 0000000..3012e38 --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanGroupAndCatalogTests.cs @@ -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() + .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 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 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 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 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 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 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> 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 actBad = async () => await ActAsync(f, drafter, pBad, "submit"); + (await actBad.Should().ThrowAsync( + "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 actWrong = async () => await ActAsync(f, drafter, pWrong, "submit"); + (await actWrong.Should().ThrowAsync( + "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 actMixed = async () => await ActAsync(f, drafter, pMixed, "submit"); + await actMixed.Should().ThrowAsync( + "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 dupG2 = async () => + await CreateAsync(f, drafter, seeded.PeId, seeded.WorkflowId, approvalGroup: 2); + (await dupG2.Should().ThrowAsync( + "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 dupG1 = async () => + await CreateAsync(f, drafter, seeded.PeId, seeded.WorkflowId, approvalGroup: 1); + (await dupG1.Should().ThrowAsync()).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 third = async () => + await CreateAsync(f, drafter, seeded.PeId, seeded.WorkflowId, approvalGroup: 3); + (await third.Should().ThrowAsync( + "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( + $"{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( + "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("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 actUpdate = UpdateAsync; + await actUpdate.Should().ThrowAsync( + "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 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 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 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); + } +} diff --git a/tests/SolutionErp.Infrastructure.Tests/Common/ContractSigningPlanSchemaTests.cs b/tests/SolutionErp.Infrastructure.Tests/Common/ContractSigningPlanSchemaTests.cs index a8494dd..dbafe38 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Common/ContractSigningPlanSchemaTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Common/ContractSigningPlanSchemaTests.cs @@ -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( - "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"); } // ===================================================================
Dự án Trạng thái Tổng đề xuấtTổng duyệt Người soạn Ngày tạo
+ Đang tải...
+ {k.maKeHoach ?? '—'} + {/* [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 && ( + + N{k.approvalGroup} + + )} @@ -249,6 +266,11 @@ export function KhkkListPage() { )} {formatVnd(k.totalProposedAmount)} + {k.totalApprovedAmount ? formatVnd(k.totalApprovedAmount) : '—'} + {k.drafterFullName ?? '—'} {formatDate(k.createdAt)}