diff --git a/docs/changelog/sessions/2026-06-11-S57bis-pe-workitem-perm-golive-prep.md b/docs/changelog/sessions/2026-06-11-S57bis-pe-workitem-perm-golive-prep.md
new file mode 100644
index 0000000..9e525df
--- /dev/null
+++ b/docs/changelog/sessions/2026-06-11-S57bis-pe-workitem-perm-golive-prep.md
@@ -0,0 +1,33 @@
+# S57bis (2026-06-11) — PE gắn Hạng mục công việc (Mig 49) + mở quyền all-role + menu "Cá nhân" + khóa demo user + Harness-4 runtime-VERIFIED
+
+> **Bối cảnh:** sếp chốt qua Zalo 11:02-11:17 (deadline **15:00 cùng ngày**): (1) kiểm tra mapping master data ngoài eoffice; (2) phân quyền TẤT CẢ user thấy Duyệt NCC + cấu hình master data; (3) flow tạo phiếu: *chọn quy trình → chọn dự án → chọn hạng mục công việc → chọn/nhập NCC/TP → chuyển duyệt*, phiếu dạng **"Dự án (năm) – Hạng mục công việc"**; (4) clear dữ liệu cũ. Anh chốt scope qua AskUserQuestion: xóa demo = **CHỈ khóa user sample** · quyền PE = **Xem + Tạo** · hạng mục = **header phiếu, 1 phiếu 1 hạng mục**.
+
+## Việc đã làm
+
+### A — Đối chiếu Excel (3) vs master data S55: ✅ NO-CHANGE
+- Python openpyxl dump 7 sheet → diff vs `scripts/master-import-data.generated.md`: 62 Projects + 71 WorkItems (16VT/30TP/9MEP/16TB) + 3 Suppliers **identical 100%**. 2 sheet hợp đồng = catalog tham khảo, không thuộc DB. Không patch gì.
+
+### C — PE gắn Hạng mục công việc (Mig 49 `AddWorkItemToPurchaseEvaluation`)
+- **Design (🔵 database-agent introspect LocalDB):** `PurchaseEvaluations.WorkItemId Guid?` **scalar loose-Guid + index, KHÔNG FK vật lý** — nhất quán convention PE (ProjectId/SelectedSupplierId đều loose; duy nhất ApprovalWorkflowId có FK). Guard = validator + handler (mirror S43 FK-invariant). WorkItems = catalog GLOBAL (không ProjectId) → 2 dropdown độc lập; "Dự án (năm) – Hạng mục" = chuỗi ghép hiển thị. KHÔNG đụng CodeSequences (mã phiếu giữ `PE/{YYYY}/{A|B}/{Seq}`).
+- **BE:** entity + config index + Mig 49 3-file (AddColumn nullable + CreateIndex, Down reversible) + Create/UpdateDraft command `WorkItemId` + validator `NotEmpty` (create bắt buộc; DB nullable backward-compat 4 phiếu cũ) + FK-guard `AnyAsync(w.Id==x && w.IsActive)` → Conflict + **UpdateDraft null-safe** (`if (request.WorkItemId is not null)` — client không gửi → GIỮ, chống null-hóa bug-class S42) + 3 projection ListItemDto (List/Inbox/CreateContractFrom) LEFT-join WorkItems.
+- **FE ×2 app (logic-identical, PeHeaderForm SHA256 IDENTICAL):** `PeWorkspaceCreateView` select "c. Hạng mục công việc *" sau Dự án (option `[Category] Code — Name`, canSubmit require) + `PeHeaderForm` (select + load existing + PUT/POST gửi workItemId) + `PeDetailTabs` (header subtitle "Dự án **–** Hạng mục" + FormRow display + inline-edit khóa) + types +3 field. Route reuse `/catalogs/work-items` (không endpoint mới).
+- **🟪 Test +12 (`PeWorkItemGuardTests`): 228→240 PASS.** Validator 3 + create-FK-guard 4 + **update-null-safe 5**. Finding: `NotEmpty()` trên `Guid?` không chặn `Guid.Empty` → FK-guard handler bắt (defense-in-depth, locked 2 test).
+
+### B — Mở quyền all-role (extend S57-WIP `SeedAllRolesReviewReadPermissionsAsync`)
+- **Pe_* (11 key: root + 5 leaf × 2 type, build qua factory vì Pe_* leaf KHÔNG nằm `MenuKeys.All` — recon catch):** CanRead+**CanCreate**=true mọi role, **upgrade-only** (row cũ 7 role nâng cờ false→true, KHÔNG hạ, KHÔNG đụng Update/Delete). PeWf_*/AwV2/PeWorkflows GIỮ Admin (prefix `Pe_` không match — ký tự thứ 3).
+- HRM/Office/Personal/Master/Catalogs: CanRead-only skip-existing (S57 giữ nguyên). PE controller `[Authorize]` class-level → mở menu không silent-403 (gotcha #44 không áp).
+
+### D — Khóa 14/16 demo sample user (`LockDemoSampleUsersAsync`)
+- Ungated idempotent NGAY trong DbInitializer (sau SeedDemoUsers + SeedItDepartmentStaff) → tự áp prod khi deploy, bền mọi startup. `IsActive=0 + LockoutEnabled + LockoutEnd=Max + SecurityStamp rotate`.
+- **GIỮ ACTIVE có chủ đích:** `nv.cao` + `nv.truong` (IT helpdesk round-robin pool S52 — khóa nốt SAU khi anh gán user thật vào dept IT, ops-pending S56) + `catalog.manager` (account chức năng).
+
+### S57-WIP ship kèm (từ 06-10, đã nằm working tree)
+- Menu nhóm **"Cá nhân"** (Personal root order 30, mirror Puro) + Chấm công re-parent Off→Personal + HrmConfig re-parent Hrm→Master + HrmDashboard order 1 + Contracts 30→31 + `parentBackfill` idempotent + admin bỏ ẩn Master (đảo S29) + Master write-lock `[Authorize(Roles="Admin,CatalogManager")]` ×3 controller.
+
+### Harness-4 closeout (governance, commit riêng)
+- **Spawn-test 2 chiều post-restart PASS:** H1 tooling-auditor (demote pin) self-report `claude-opus-4-8[1m]` + H2 harvest-curator (promote inherit) self-report `claude-fable-5[1m]` → nấc **RUNTIME-VERIFIED 06-11** (adap-report §2/§5 + STATUS row promoted; `[1m]` 1M-resolve SE tự verify). Email update `2026-06-11-se-to-ai_infra-harness-4-runtime-verified` (honest n=1/chiều; hmw.js executed-file giữ). Lesson env: **CCD cache agent frontmatter — restart CLI mới ăn** (proved 2 data-point 06-10/06-11).
+
+## Multi-agent & lessons
+- **HMW-mode ON** carry từ S55. Fan-out: 2 monitor (kiêm spawn-test) + 2 recon (investigator-codebase catch **Pe_*-leaf-not-in-All** + database-agent design Mig 49) + 2 builder + test-specialist + reviewer ×2.
+- ⚠️ **2 builder return-truncated giữa task** (gotcha #53 — BE chết trước projection-3/migration, FE chết giữa mirror fe-admin) → **em main solo vá cross-stack** (disk/git truth, không tin return): fix CS7036 + CS8019 + Mig 49 + null-safe + mirror 7 edits PeHeaderForm + 3 edits PeDetailTabs ×2 app. Reviewer email-gate đầu cũng mất tích → gộp 1 gate trọn cuối.
+- Excel (3) đối chiếu bằng raw-dump trước parse (tránh lặp data-quality trap S55 MEP-col).
diff --git a/fe-admin/src/components/Layout.tsx b/fe-admin/src/components/Layout.tsx
index d32061d..4fb2a15 100644
--- a/fe-admin/src/components/Layout.tsx
+++ b/fe-admin/src/components/Layout.tsx
@@ -143,16 +143,11 @@ function resolvePath(key: string): string | null {
// Admin side: hide the per-ContractType contract submenu (Ct_*) — that's a
// user-app concern. Keep Wf_* workflow-admin leaves.
-// [Plan CA S29 2026-05-22] cũng hide "Cấu hình danh mục dùng chung" (Master +
-// Catalogs) — đã move sang fe-user/eoffice. Admin vẫn phân quyền role × menu ×
-// CRUD qua /system/permissions (Permission Matrix tự reflect 9 menu key này).
-const ADMIN_HIDDEN_MASTER_KEYS = new Set([
- 'Master', 'Suppliers', 'Projects', 'Departments',
- 'Catalogs', 'CatalogUnits', 'CatalogMaterials', 'CatalogServices', 'CatalogWorkItems',
-])
-
+// [S57] BỎ ẩn "Danh mục" (Master/Catalogs + Cấu hình HRM re-parent vào đây) — gom
+// master data về 1 chỗ cho CẢ admin lẫn nhân viên (đảo S29 hide). Admin nay quản
+// master trực tiếp trên fe-admin; write vẫn khóa Admin/CatalogManager ở BE controller.
function isAdminHidden(key: string): boolean {
- return key.startsWith('Ct_') || ADMIN_HIDDEN_MASTER_KEYS.has(key)
+ return key.startsWith('Ct_')
}
function filterForAdmin(nodes: MenuNode[]): MenuNode[] {
diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx
index 1a75a92..7069e4c 100644
--- a/fe-admin/src/components/pe/PeDetailTabs.tsx
+++ b/fe-admin/src/components/pe/PeDetailTabs.tsx
@@ -187,6 +187,8 @@ export function PeDetailTabs({
{PurchaseEvaluationTypeLabel[evaluation.type]}
·
{evaluation.projectName}
+ {/* S57bis — phiếu dạng "Dự án – Hạng mục công việc" (lời sếp) */}
+ {evaluation.workItemName && <>–{evaluation.workItemName}>}
{evaluation.drafterName && <>·Soạn: {evaluation.drafterName}>}
@@ -667,6 +669,8 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
)}
+ {/* S57bis — Hạng mục công việc (WorkItem master). Phiếu cũ null → "—". */}
+
{(ev.diaDiem || ev.moTa || ev.paymentTerms) && (
{ev.diaDiem &&
Địa điểm: {ev.diaDiem}
}
@@ -694,6 +698,11 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
+
+ {/* S57bis — hạng mục khóa ở inline-edit; đổi qua "Sửa header phiếu" (PeHeaderForm). */}
+
+
+
Number(s.replace(/[^\d]/g, '')) || 0
const formatVndInput = (n: number): string => (n > 0 ? n.toLocaleString('vi-VN') : '')
+// S57bis — Hạng mục công việc (WorkItem master, mirror PeWorkspaceCreateView).
+type WorkItemOption = {
+ id: string
+ code: string
+ name: string
+ category?: string | null
+ isActive?: boolean
+}
+
export function PeHeaderForm({
editId,
defaultType,
@@ -49,10 +58,18 @@ export function PeHeaderForm({
enabled: !!editId,
})
+ // S57bis — list Hạng mục công việc (active only, mirror PeWorkspaceCreateView).
+ const workItems = useQuery({
+ queryKey: ['catalogs', 'work-items'],
+ queryFn: async () => (await api.get('/catalogs/work-items')).data,
+ select: rows => rows.filter(r => r.isActive !== false),
+ })
+
const [form, setForm] = useState({
type: initialType as number,
tenGoiThau: '',
projectId: '',
+ workItemId: '',
diaDiem: '',
moTa: '',
paymentTerms: '',
@@ -82,6 +99,7 @@ export function PeHeaderForm({
type: existing.data.type,
tenGoiThau: existing.data.tenGoiThau,
projectId: existing.data.projectId,
+ workItemId: existing.data.workItemId ?? '', // S57bis — load để PUT gửi lại, tránh null-hóa
diaDiem: existing.data.diaDiem ?? '',
moTa: existing.data.moTa ?? '',
paymentTerms: existing.data.paymentTerms ?? '',
@@ -113,6 +131,7 @@ export function PeHeaderForm({
return api.put(`/purchase-evaluations/${editId}`, {
id: editId,
tenGoiThau: form.tenGoiThau,
+ workItemId: form.workItemId || null, // S57bis — BE null-safe: null = giữ nguyên
diaDiem: form.diaDiem || null,
moTa: form.moTa || null,
paymentTerms: form.paymentTerms || null,
@@ -123,6 +142,7 @@ export function PeHeaderForm({
type: form.type,
tenGoiThau: form.tenGoiThau,
projectId: form.projectId,
+ workItemId: form.workItemId || null, // S57bis — create require (BE validator NotEmpty)
diaDiem: form.diaDiem || null,
moTa: form.moTa || null,
paymentTerms: form.paymentTerms || null,
@@ -187,6 +207,23 @@ export function PeHeaderForm({
+
+ {/* S57bis — phiếu dạng "Dự án – Hạng mục công việc" (sếp chốt). Edit-mode
+ CHO đổi (BE UpdateDraft hỗ trợ); phiếu cũ null → bắt đầu rỗng, không ép. */}
+
+
+
+
@@ -272,7 +309,7 @@ export function PeHeaderForm({
)}
diff --git a/fe-admin/src/components/pe/PeWorkspaceCreateView.tsx b/fe-admin/src/components/pe/PeWorkspaceCreateView.tsx
index 1c742d8..d91e0dc 100644
--- a/fe-admin/src/components/pe/PeWorkspaceCreateView.tsx
+++ b/fe-admin/src/components/pe/PeWorkspaceCreateView.tsx
@@ -24,6 +24,16 @@ import type { Paged, Project } from '@/types/master'
const parseVnd = (s: string): number => Number(s.replace(/[^\d]/g, '')) || 0
const formatVndInput = (n: number): string => (n > 0 ? n.toLocaleString('vi-VN') : '')
+// S57bis — Hạng mục công việc (WorkItem master). Reuse catalog endpoint
+// /catalogs/work-items (cùng query key CatalogsPage + ContractDetailsTab dùng).
+type WorkItemOption = {
+ id: string
+ code: string
+ name: string
+ category?: string | null
+ isActive?: boolean
+}
+
// Preset điều khoản thanh toán phổ biến — user chọn 1 trong list, hoặc "Khác"
// để nhập tay. Save as plain text (không JSON như cũ — code-style không phù
// hợp UI cho end-user). User 2026-05-07 chỉnh.
@@ -55,6 +65,7 @@ export function PeWorkspaceCreateView({
type: defaultType,
tenGoiThau: '',
projectId: '',
+ workItemId: '',
diaDiem: '',
moTa: '',
paymentTerms: '',
@@ -75,6 +86,13 @@ export function PeWorkspaceCreateView({
queryFn: async () => (await api.get<{ items: Project[] }>('/projects', { params: { pageSize: 1000 } })).data.items,
})
+ // S57bis — list Hạng mục công việc (active only — filter client nếu BE trả isActive).
+ const workItems = useQuery({
+ queryKey: ['catalogs', 'work-items'],
+ queryFn: async () => (await api.get
('/catalogs/work-items')).data,
+ select: rows => rows.filter(r => r.isActive !== false),
+ })
+
// Mig 23 — fetch list quy trình duyệt V2 cho User chọn (filter theo
// ApplicableType khớp với defaultType: 1=DuyetNcc / 2=DuyetNccPhuongAn).
// Mig 25 — chỉ hiện workflows admin đã ghim "cho user chọn" (IsUserSelectable=true).
@@ -111,6 +129,7 @@ export function PeWorkspaceCreateView({
type: form.type,
tenGoiThau: form.tenGoiThau,
projectId: form.projectId,
+ workItemId: form.workItemId || null,
diaDiem: form.diaDiem || null,
moTa: form.moTa || null,
paymentTerms: form.paymentTerms || null,
@@ -127,7 +146,7 @@ export function PeWorkspaceCreateView({
onError: e => toast.error(getErrorMessage(e)),
})
- const canSubmit = !!form.tenGoiThau && !!form.projectId && !!form.approvalWorkflowId && !create.isPending
+ const canSubmit = !!form.tenGoiThau && !!form.projectId && !!form.workItemId && !!form.approvalWorkflowId && !create.isPending
return (
@@ -192,6 +211,26 @@ export function PeWorkspaceCreateView({
))}
+
+
+
+ {workItems.data && workItems.data.length === 0 && (
+
+ ⚠ Chưa có hạng mục công việc nào. Vào Danh mục → Hạng mục công việc để tạo trước.
+
+ )}
+
{PurchaseEvaluationTypeLabel[evaluation.type]}
·
{evaluation.projectName}
+ {/* S57bis — phiếu dạng "Dự án – Hạng mục công việc" (lời sếp) */}
+ {evaluation.workItemName && <>–{evaluation.workItemName}>}
{evaluation.drafterName && <>·Soạn: {evaluation.drafterName}>}
@@ -672,6 +674,8 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
)}
+ {/* S57bis — Hạng mục công việc (WorkItem master). Phiếu cũ null → "—". */}
+
{(ev.diaDiem || ev.moTa || ev.paymentTerms) && (
{ev.diaDiem &&
Địa điểm: {ev.diaDiem}
}
@@ -699,6 +703,11 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
+
+ {/* S57bis — hạng mục khóa ở inline-edit; đổi qua "Sửa header phiếu" (PeHeaderForm). */}
+
+
+
Number(s.replace(/[^\d]/g, '')) || 0
const formatVndInput = (n: number): string => (n > 0 ? n.toLocaleString('vi-VN') : '')
+// S57bis — Hạng mục công việc (WorkItem master, mirror PeWorkspaceCreateView).
+type WorkItemOption = {
+ id: string
+ code: string
+ name: string
+ category?: string | null
+ isActive?: boolean
+}
+
export function PeHeaderForm({
editId,
defaultType,
@@ -49,10 +58,18 @@ export function PeHeaderForm({
enabled: !!editId,
})
+ // S57bis — list Hạng mục công việc (active only, mirror PeWorkspaceCreateView).
+ const workItems = useQuery({
+ queryKey: ['catalogs', 'work-items'],
+ queryFn: async () => (await api.get('/catalogs/work-items')).data,
+ select: rows => rows.filter(r => r.isActive !== false),
+ })
+
const [form, setForm] = useState({
type: initialType as number,
tenGoiThau: '',
projectId: '',
+ workItemId: '',
diaDiem: '',
moTa: '',
paymentTerms: '',
@@ -82,6 +99,7 @@ export function PeHeaderForm({
type: existing.data.type,
tenGoiThau: existing.data.tenGoiThau,
projectId: existing.data.projectId,
+ workItemId: existing.data.workItemId ?? '', // S57bis — load để PUT gửi lại, tránh null-hóa
diaDiem: existing.data.diaDiem ?? '',
moTa: existing.data.moTa ?? '',
paymentTerms: existing.data.paymentTerms ?? '',
@@ -113,6 +131,7 @@ export function PeHeaderForm({
return api.put(`/purchase-evaluations/${editId}`, {
id: editId,
tenGoiThau: form.tenGoiThau,
+ workItemId: form.workItemId || null, // S57bis — BE null-safe: null = giữ nguyên
diaDiem: form.diaDiem || null,
moTa: form.moTa || null,
paymentTerms: form.paymentTerms || null,
@@ -123,6 +142,7 @@ export function PeHeaderForm({
type: form.type,
tenGoiThau: form.tenGoiThau,
projectId: form.projectId,
+ workItemId: form.workItemId || null, // S57bis — create require (BE validator NotEmpty)
diaDiem: form.diaDiem || null,
moTa: form.moTa || null,
paymentTerms: form.paymentTerms || null,
@@ -187,6 +207,23 @@ export function PeHeaderForm({
+
+ {/* S57bis — phiếu dạng "Dự án – Hạng mục công việc" (sếp chốt). Edit-mode
+ CHO đổi (BE UpdateDraft hỗ trợ); phiếu cũ null → bắt đầu rỗng, không ép. */}
+
+
+
+
@@ -272,7 +309,7 @@ export function PeHeaderForm({
)}
diff --git a/fe-user/src/components/pe/PeWorkspaceCreateView.tsx b/fe-user/src/components/pe/PeWorkspaceCreateView.tsx
index 8c99897..53ce82a 100644
--- a/fe-user/src/components/pe/PeWorkspaceCreateView.tsx
+++ b/fe-user/src/components/pe/PeWorkspaceCreateView.tsx
@@ -24,6 +24,16 @@ import type { Paged, Project } from '@/types/master'
const parseVnd = (s: string): number => Number(s.replace(/[^\d]/g, '')) || 0
const formatVndInput = (n: number): string => (n > 0 ? n.toLocaleString('vi-VN') : '')
+// S57bis — Hạng mục công việc (WorkItem master). Reuse catalog endpoint
+// /catalogs/work-items (cùng query key CatalogsPage + ContractDetailsTab dùng).
+type WorkItemOption = {
+ id: string
+ code: string
+ name: string
+ category?: string | null
+ isActive?: boolean
+}
+
// Preset điều khoản thanh toán phổ biến — user chọn 1 trong list, hoặc "Khác"
// để nhập tay. Save as plain text (không JSON như cũ — code-style không phù
// hợp UI cho end-user). User 2026-05-07 chỉnh.
@@ -55,6 +65,7 @@ export function PeWorkspaceCreateView({
type: defaultType,
tenGoiThau: '',
projectId: '',
+ workItemId: '',
diaDiem: '',
moTa: '',
paymentTerms: '',
@@ -75,6 +86,13 @@ export function PeWorkspaceCreateView({
queryFn: async () => (await api.get<{ items: Project[] }>('/projects', { params: { pageSize: 1000 } })).data.items,
})
+ // S57bis — list Hạng mục công việc (active only — filter client nếu BE trả isActive).
+ const workItems = useQuery({
+ queryKey: ['catalogs', 'work-items'],
+ queryFn: async () => (await api.get
('/catalogs/work-items')).data,
+ select: rows => rows.filter(r => r.isActive !== false),
+ })
+
// Mig 23 — fetch list quy trình duyệt V2 (filter ApplicableType khớp defaultType).
// Mig 25 — chỉ hiện workflows admin đã ghim "cho user chọn" (IsUserSelectable=true).
const approvalWorkflows = useQuery({
@@ -110,6 +128,7 @@ export function PeWorkspaceCreateView({
type: form.type,
tenGoiThau: form.tenGoiThau,
projectId: form.projectId,
+ workItemId: form.workItemId || null,
diaDiem: form.diaDiem || null,
moTa: form.moTa || null,
paymentTerms: form.paymentTerms || null,
@@ -126,7 +145,7 @@ export function PeWorkspaceCreateView({
onError: e => toast.error(getErrorMessage(e)),
})
- const canSubmit = !!form.tenGoiThau && !!form.projectId && !!form.approvalWorkflowId && !create.isPending
+ const canSubmit = !!form.tenGoiThau && !!form.projectId && !!form.workItemId && !!form.approvalWorkflowId && !create.isPending
return (
@@ -190,6 +209,26 @@ export function PeWorkspaceCreateView({
))}
+
+
+
+ {workItems.data && workItems.data.length === 0 && (
+
+ ⚠ Chưa có hạng mục công việc nào. Vào Danh mục → Hạng mục công việc để tạo trước.
+
+ )}
+
> Get(Guid id, CancellationToken ct)
=> Ok(await mediator.Send(new GetDepartmentQuery(id), ct));
+ // [S57] Master write khóa Admin+CatalogManager (đọc mở cho mọi role; chống sửa/xóa
+ // Phòng ban qua API khi mở quyền xem cho toàn bộ nhân viên).
+ [Authorize(Roles = "Admin,CatalogManager")]
[HttpPost]
public async Task
> Create([FromBody] CreateDepartmentCommand cmd, CancellationToken ct)
{
@@ -29,6 +32,7 @@ public class DepartmentsController(IMediator mediator) : ControllerBase
return CreatedAtAction(nameof(Get), new { id }, new { id });
}
+ [Authorize(Roles = "Admin,CatalogManager")]
[HttpPut("{id:guid}")]
public async Task Update(Guid id, [FromBody] UpdateDepartmentCommand cmd, CancellationToken ct)
{
@@ -37,6 +41,7 @@ public class DepartmentsController(IMediator mediator) : ControllerBase
return NoContent();
}
+ [Authorize(Roles = "Admin,CatalogManager")]
[HttpDelete("{id:guid}")]
public async Task Delete(Guid id, CancellationToken ct)
{
diff --git a/src/Backend/SolutionErp.Api/Controllers/ProjectsController.cs b/src/Backend/SolutionErp.Api/Controllers/ProjectsController.cs
index 2ad38cf..fe16ad1 100644
--- a/src/Backend/SolutionErp.Api/Controllers/ProjectsController.cs
+++ b/src/Backend/SolutionErp.Api/Controllers/ProjectsController.cs
@@ -22,6 +22,9 @@ public class ProjectsController(IMediator mediator) : ControllerBase
public async Task> Get(Guid id, CancellationToken ct)
=> Ok(await mediator.Send(new GetProjectQuery(id), ct));
+ // [S57] Master write khóa Admin+CatalogManager (đọc mở cho mọi role; chống sửa/xóa
+ // 62 dự án production qua API khi mở quyền xem cho toàn bộ nhân viên).
+ [Authorize(Roles = "Admin,CatalogManager")]
[HttpPost]
public async Task> Create([FromBody] CreateProjectCommand cmd, CancellationToken ct)
{
@@ -29,6 +32,7 @@ public class ProjectsController(IMediator mediator) : ControllerBase
return CreatedAtAction(nameof(Get), new { id }, new { id });
}
+ [Authorize(Roles = "Admin,CatalogManager")]
[HttpPut("{id:guid}")]
public async Task Update(Guid id, [FromBody] UpdateProjectCommand cmd, CancellationToken ct)
{
@@ -37,6 +41,7 @@ public class ProjectsController(IMediator mediator) : ControllerBase
return NoContent();
}
+ [Authorize(Roles = "Admin,CatalogManager")]
[HttpDelete("{id:guid}")]
public async Task Delete(Guid id, CancellationToken ct)
{
diff --git a/src/Backend/SolutionErp.Api/Controllers/SuppliersController.cs b/src/Backend/SolutionErp.Api/Controllers/SuppliersController.cs
index 79abf70..72d7b7a 100644
--- a/src/Backend/SolutionErp.Api/Controllers/SuppliersController.cs
+++ b/src/Backend/SolutionErp.Api/Controllers/SuppliersController.cs
@@ -29,6 +29,9 @@ public class SuppliersController(IMediator mediator) : ControllerBase
public async Task> Get(Guid id, CancellationToken ct)
=> Ok(await mediator.Send(new GetSupplierQuery(id), ct));
+ // [S57] Master write khóa Admin+CatalogManager (đọc mở cho mọi role review/test;
+ // chống nhân viên sửa/xóa NCC production qua API khi menu hiện cho toàn bộ phận).
+ [Authorize(Roles = "Admin,CatalogManager")]
[HttpPost]
public async Task> Create([FromBody] CreateSupplierCommand cmd, CancellationToken ct)
{
@@ -36,6 +39,7 @@ public class SuppliersController(IMediator mediator) : ControllerBase
return CreatedAtAction(nameof(Get), new { id }, new { id });
}
+ [Authorize(Roles = "Admin,CatalogManager")]
[HttpPut("{id:guid}")]
public async Task Update(Guid id, [FromBody] UpdateSupplierCommand cmd, CancellationToken ct)
{
@@ -44,6 +48,7 @@ public class SuppliersController(IMediator mediator) : ControllerBase
return NoContent();
}
+ [Authorize(Roles = "Admin,CatalogManager")]
[HttpDelete("{id:guid}")]
public async Task Delete(Guid id, CancellationToken ct)
{
diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/CreateContractFromEvaluationFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/CreateContractFromEvaluationFeatures.cs
index 21e41b0..229148e 100644
--- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/CreateContractFromEvaluationFeatures.cs
+++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/CreateContractFromEvaluationFeatures.cs
@@ -139,11 +139,14 @@ public class ListApprovedPurchaseEvaluationsQueryHandler(IApplicationDbContext d
from u in uj.DefaultIfEmpty()
join d in db.Departments.AsNoTracking() on e.DepartmentId equals d.Id into dj
from d in dj.DefaultIfEmpty()
+ join wi in db.WorkItems.AsNoTracking() on e.WorkItemId equals wi.Id into wij
+ from wi in wij.DefaultIfEmpty()
where e.Phase == PurchaseEvaluationPhase.DaDuyet && e.ContractId == null
orderby e.CreatedAt descending
select new PurchaseEvaluationListItemDto(
e.Id, e.MaPhieu, e.TenGoiThau, e.Type, e.Phase,
e.ProjectId, p.Name,
+ e.WorkItemId, wi != null ? wi.Name : null, wi != null ? wi.Code : null,
e.SelectedSupplierId, s != null ? s.Name : null,
e.ContractId, e.SlaDeadline, e.CreatedAt, e.UpdatedAt,
e.DrafterUserId, u != null ? u.FullName : null,
diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs
index f0e0eb3..daacb30 100644
--- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs
+++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs
@@ -12,6 +12,11 @@ public record PurchaseEvaluationListItemDto(
PurchaseEvaluationPhase Phase,
Guid ProjectId,
string ProjectName,
+ // [Mig 49 S57bis] Hạng mục công việc — loose-Guid resolve LEFT join WorkItems
+ // (mirror ProjectName). Nullable cho 4 phiếu cũ chưa gắn hạng mục.
+ Guid? WorkItemId,
+ string? WorkItemName,
+ string? WorkItemCode,
Guid? SelectedSupplierId,
string? SelectedSupplierName,
Guid? ContractId,
@@ -200,6 +205,10 @@ public record PurchaseEvaluationDetailBundleDto(
string? MoTa,
Guid ProjectId,
string ProjectName,
+ // [Mig 49 S57bis] Hạng mục công việc — loose-Guid resolve giống ProjectName.
+ Guid? WorkItemId,
+ string? WorkItemName,
+ string? WorkItemCode,
Guid? DepartmentId,
string? DepartmentName,
Guid? DrafterUserId,
diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs
index 69d686e..1a3b14b 100644
--- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs
+++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs
@@ -7,7 +7,6 @@ using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Common.Models;
using SolutionErp.Application.PurchaseEvaluations.Dtos;
using SolutionErp.Application.PurchaseEvaluations.Services;
-using SolutionErp.Domain.ApprovalWorkflowsV2; // Plan E V2 strict scope query
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.PurchaseEvaluations;
@@ -27,7 +26,8 @@ public record CreatePurchaseEvaluationCommand(
Guid? BudgetId,
string? BudgetManualName,
decimal? BudgetManualAmount,
- Guid? ApprovalWorkflowId = null) : IRequest; // [Mig 23] User chọn quy trình duyệt V2 lúc tạo
+ Guid? ApprovalWorkflowId = null, // [Mig 23] User chọn quy trình duyệt V2 lúc tạo
+ Guid? WorkItemId = null) : IRequest; // [Mig 49 S57bis] Hạng mục công việc — flow create PHẢI chọn (validator NotEmpty)
public class CreatePurchaseEvaluationCommandValidator : AbstractValidator
{
@@ -36,6 +36,12 @@ public class CreatePurchaseEvaluationCommandValidator : AbstractValidator x.Type).IsInEnum();
RuleFor(x => x.TenGoiThau).NotEmpty().MaximumLength(500);
RuleFor(x => x.ProjectId).NotEmpty();
+ // [Mig 49 S57bis] Sếp yêu cầu flow create PHẢI chọn hạng mục công việc.
+ // DB cột nullable chỉ để backward-compat 4 phiếu cũ — create mới bắt buộc.
+ // FK-exists check trong handler → ConflictException (validator sync, không
+ // async-db; mirror S43 CreateLeaveRequestHandler FK-invariant guard).
+ RuleFor(x => x.WorkItemId).NotEmpty()
+ .WithMessage("Phải chọn hạng mục công việc.");
RuleFor(x => x.DiaDiem).MaximumLength(500);
RuleFor(x => x.MoTa).MaximumLength(2000);
RuleFor(x => x.BudgetManualName).MaximumLength(200);
@@ -54,6 +60,17 @@ public class CreatePurchaseEvaluationCommandHandler(
_ = await db.Projects.FirstOrDefaultAsync(p => p.Id == request.ProjectId, ct)
?? throw new NotFoundException("Project", request.ProjectId);
+ // [Mig 49 S57bis] FK-invariant guard hạng mục công việc (mirror S43
+ // CreateLeaveRequestHandler). Validator đã NotEmpty → ở đây WorkItemId
+ // chắc chắn có value; check tồn tại + đang hoạt động.
+ if (request.WorkItemId is Guid wiId)
+ {
+ var wiOk = await db.WorkItems.AsNoTracking()
+ .AnyAsync(w => w.Id == wiId && w.IsActive, ct);
+ if (!wiOk)
+ throw new ConflictException("Hạng mục công việc không tồn tại hoặc ngưng hoạt động.");
+ }
+
var activeWfId = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking()
.Where(w => w.EvaluationType == request.Type && w.IsActive)
.Select(w => (Guid?)w.Id)
@@ -97,6 +114,7 @@ public class CreatePurchaseEvaluationCommandHandler(
Phase = PurchaseEvaluationPhase.DangSoanThao,
TenGoiThau = request.TenGoiThau,
ProjectId = request.ProjectId,
+ WorkItemId = request.WorkItemId, // Mig 49 S57bis
DepartmentId = request.DepartmentId,
DiaDiem = request.DiaDiem,
MoTa = request.MoTa,
@@ -175,7 +193,8 @@ public record UpdatePurchaseEvaluationDraftCommand(
Guid? BudgetId,
string? BudgetManualName,
decimal? BudgetManualAmount,
- Guid? ApprovalWorkflowId = null) : IRequest; // [Mig 23] cho User đổi quy trình khi sửa Nháp
+ Guid? ApprovalWorkflowId = null, // [Mig 23] cho User đổi quy trình khi sửa Nháp
+ Guid? WorkItemId = null) : IRequest; // [Mig 49 S57bis] cho User đổi hạng mục công việc khi sửa Nháp/Trả lại
public class UpdatePurchaseEvaluationDraftCommandHandler(
IApplicationDbContext db,
@@ -218,6 +237,15 @@ public class UpdatePurchaseEvaluationDraftCommandHandler(
throw new ConflictException("Chỉ link được ngân sách đã duyệt.");
}
+ // [Mig 49 S57bis] FK-invariant guard hạng mục nếu đổi (mirror S43).
+ if (request.WorkItemId is Guid wiId && wiId != entity.WorkItemId)
+ {
+ var wiOk = await db.WorkItems.AsNoTracking()
+ .AnyAsync(w => w.Id == wiId && w.IsActive, ct);
+ if (!wiOk)
+ throw new ConflictException("Hạng mục công việc không tồn tại hoặc ngưng hoạt động.");
+ }
+
entity.TenGoiThau = request.TenGoiThau;
entity.DiaDiem = request.DiaDiem;
entity.MoTa = request.MoTa;
@@ -226,6 +254,11 @@ public class UpdatePurchaseEvaluationDraftCommandHandler(
entity.BudgetManualName = request.BudgetManualName;
entity.BudgetManualAmount = request.BudgetManualAmount;
entity.ApprovalWorkflowId = request.ApprovalWorkflowId; // Mig 23 — User đổi quy trình
+ // Mig 49 S57bis — null-safe: CHỈ đổi hạng mục khi client gửi giá trị.
+ // Client cũ / PeDetailTabs inline-edit không gửi field này → GIỮ nguyên
+ // (tránh null-hóa mất hạng mục vừa chọn lúc create — bug-class S42 picker).
+ if (request.WorkItemId is not null)
+ entity.WorkItemId = request.WorkItemId;
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
@@ -469,6 +502,7 @@ public class ListPurchaseEvaluationsQueryHandler(
ListPurchaseEvaluationsQuery request, CancellationToken ct)
{
// Plan AG4: JOIN Users + Departments LEFT (cả 2 nullable theo PE entity).
+ // Mig 49 S57bis: LEFT join WorkItems (loose-Guid nullable, mirror Project).
var q = from e in db.PurchaseEvaluations.AsNoTracking()
join p in db.Projects.AsNoTracking() on e.ProjectId equals p.Id
join s in db.Suppliers.AsNoTracking() on e.SelectedSupplierId equals s.Id into sj
@@ -477,7 +511,9 @@ public class ListPurchaseEvaluationsQueryHandler(
from u in uj.DefaultIfEmpty()
join d in db.Departments.AsNoTracking() on e.DepartmentId equals d.Id into dj
from d in dj.DefaultIfEmpty()
- select new { e, p, s, u, d };
+ join wi in db.WorkItems.AsNoTracking() on e.WorkItemId equals wi.Id into wij
+ from wi in wij.DefaultIfEmpty()
+ select new { e, p, s, u, d, wi };
// IDOR strict (Plan E S22 — Session 21 +1): non-admin chỉ thấy phiếu khi:
// 1. là Drafter (mình tạo)
@@ -531,6 +567,7 @@ public class ListPurchaseEvaluationsQueryHandler(
.Select(x => new PurchaseEvaluationListItemDto(
x.e.Id, x.e.MaPhieu, x.e.TenGoiThau, x.e.Type, x.e.Phase,
x.e.ProjectId, x.p.Name,
+ x.e.WorkItemId, x.wi != null ? x.wi.Name : null, x.wi != null ? x.wi.Code : null,
x.e.SelectedSupplierId, x.s != null ? x.s.Name : null,
x.e.ContractId, x.e.SlaDeadline, x.e.CreatedAt, x.e.UpdatedAt,
x.e.DrafterUserId, x.u != null ? x.u.FullName : null,
@@ -595,6 +632,7 @@ public class GetMyPurchaseEvaluationInboxQueryHandler(
: await ResolveV2InboxIdsAsync(userId, ct);
// Plan AG4: JOIN Users + Departments LEFT (mirror ListPurchaseEvaluations).
+ // Mig 49 S57bis: LEFT join WorkItems (loose-Guid nullable, mirror List).
var q = from e in db.PurchaseEvaluations.AsNoTracking()
join p in db.Projects.AsNoTracking() on e.ProjectId equals p.Id
join s in db.Suppliers.AsNoTracking() on e.SelectedSupplierId equals s.Id into sj
@@ -603,8 +641,10 @@ public class GetMyPurchaseEvaluationInboxQueryHandler(
from u in uj.DefaultIfEmpty()
join d in db.Departments.AsNoTracking() on e.DepartmentId equals d.Id into dj
from d in dj.DefaultIfEmpty()
+ join wi in db.WorkItems.AsNoTracking() on e.WorkItemId equals wi.Id into wij
+ from wi in wij.DefaultIfEmpty()
where eligiblePhases.Contains(e.Phase) || v2InboxIds.Contains(e.Id)
- select new { e, p, s, u, d };
+ select new { e, p, s, u, d, wi };
if (request.Type is not null) q = q.Where(x => x.e.Type == request.Type);
if (request.ApprovalWorkflowId is not null)
@@ -616,6 +656,7 @@ public class GetMyPurchaseEvaluationInboxQueryHandler(
.Select(x => new PurchaseEvaluationListItemDto(
x.e.Id, x.e.MaPhieu, x.e.TenGoiThau, x.e.Type, x.e.Phase,
x.e.ProjectId, x.p.Name,
+ x.e.WorkItemId, x.wi != null ? x.wi.Name : null, x.wi != null ? x.wi.Code : null,
x.e.SelectedSupplierId, x.s != null ? x.s.Name : null,
x.e.ContractId, x.e.SlaDeadline, x.e.CreatedAt, x.e.UpdatedAt,
x.e.DrafterUserId, x.u != null ? x.u.FullName : null,
@@ -707,6 +748,8 @@ public class GetPurchaseEvaluationQueryHandler(
}
var project = await db.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == e.ProjectId, ct);
+ // [Mig 49 S57bis] Resolve hạng mục công việc giống Project (loose-Guid).
+ var workItem = e.WorkItemId is null ? null : await db.WorkItems.AsNoTracking().FirstOrDefaultAsync(w => w.Id == e.WorkItemId, ct);
var department = e.DepartmentId is null ? null : await db.Departments.AsNoTracking().FirstOrDefaultAsync(d => d.Id == e.DepartmentId, ct);
var selectedSupplier = e.SelectedSupplierId is null ? null : await db.Suppliers.AsNoTracking().FirstOrDefaultAsync(s => s.Id == e.SelectedSupplierId, ct);
@@ -918,6 +961,7 @@ public class GetPurchaseEvaluationQueryHandler(
return new PurchaseEvaluationDetailBundleDto(
e.Id, e.MaPhieu, e.Type, e.Phase, e.TenGoiThau, e.DiaDiem, e.MoTa,
e.ProjectId, project?.Name ?? "",
+ e.WorkItemId, workItem?.Name, workItem?.Code,
e.DepartmentId, department?.Name,
e.DrafterUserId, e.DrafterUserId is Guid d && users.TryGetValue(d, out var dn) ? dn : null,
e.SelectedSupplierId, selectedSupplier?.Name,
diff --git a/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs b/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs
index 429b834..09f80ad 100644
--- a/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs
+++ b/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs
@@ -121,10 +121,16 @@ public static class MenuKeys
public const string OffDonTuTravel = "Off_DonTu_Travel"; // Đơn công tác
public const string OffDatXe = "Off_DatXe"; // Đặt xe công
public const string OffItTicket = "Off_ItTicket"; // Ticket CNTT helpdesk
- public const string OffChamCong = "Off_ChamCong"; // Chấm công GPS (G-P1)
+ public const string OffChamCong = "Off_ChamCong"; // Chấm công GPS (G-P1) — [S57] re-parent Off → Personal
public const string OffAttendanceReport = "Off_AttendanceReport"; // Báo cáo chấm công (P11-E, admin)
public const string HrmDashboard = "Hrm_Dashboard"; // Dashboard HRM (G-H3)
+ // ============================================================
+ // [S57] Nhóm "Cá nhân" — mirror layout Puro (NAMGROUP). Root group cho mục
+ // cá nhân nhân viên. "Chấm công" (Off_ChamCong) re-parent Off → Personal.
+ // ============================================================
+ public const string Personal = "Personal"; // root group "Cá nhân"
+
public static readonly string[] PurchaseEvaluationTypeCodes =
["DuyetNcc", "DuyetNccPhuongAn"];
@@ -157,6 +163,7 @@ public static class MenuKeys
OffDeXuat, OffDeXuatList, OffDeXuatCreate, OffDeXuatInbox, // Phase 10.3 G-O3 — Đề xuất
OffDonTu, OffDonTuLeave, OffDonTuOt, OffDonTuTravel, // Phase 10.3 G-O4 — Đơn từ
OffDatXe, OffItTicket, OffChamCong, OffAttendanceReport, HrmDashboard, // Phase 10.3-10.4 — G-O5/G-O6/G-P1/G-H3 + P11-E report
+ Personal, // [S57] Cá nhân (Puro grouping — Chấm công re-parent)
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
];
diff --git a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluation.cs b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluation.cs
index 96680db..07dad26 100644
--- a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluation.cs
+++ b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluation.cs
@@ -13,6 +13,7 @@ public class PurchaseEvaluation : AuditableEntity
public string TenGoiThau { get; set; } = string.Empty; // "Cung cấp bê tông"
public Guid ProjectId { get; set; } // Dự án (FK Projects)
+ public Guid? WorkItemId { get; set; } // [Mig 49 S57bis] Hạng mục công việc — scalar loose-Guid (KHÔNG navigation, KHÔNG FK vật lý — convention PE giống ProjectId/SelectedSupplierId). DB nullable cho 4 phiếu cũ; flow create mới validator NotEmpty.
public Guid? DepartmentId { get; set; }
public Guid? DrafterUserId { get; set; } // QS/NV.PB soạn
public string? DiaDiem { get; set; } // Lô K, KCN Lộc An...
diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs
index c6b3a54..739839a 100644
--- a/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs
+++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs
@@ -25,6 +25,9 @@ public class PurchaseEvaluationConfiguration : IEntityTypeConfiguration x.MaPhieu).IsUnique().HasFilter("[MaPhieu] IS NOT NULL");
b.HasIndex(x => new { x.Phase, x.IsDeleted });
b.HasIndex(x => x.ProjectId);
+ // [Mig 49 S57bis] WorkItemId scalar loose-Guid — index lọc query, KHÔNG
+ // HasOne/FK vật lý (convention PE: chỉ ApprovalWorkflowId có FK).
+ b.HasIndex(x => x.WorkItemId);
b.HasIndex(x => x.SlaDeadline);
b.HasIndex(x => x.WorkflowDefinitionId);
b.HasIndex(x => x.ApprovalWorkflowId);
diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs
index d4b449a..808d176 100644
--- a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs
+++ b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs
@@ -92,6 +92,10 @@ public static class DbInitializer
// cho round-robin auto-assign ticket. PHẢI sau SeedDemoUsersAsync (reconcile dept
// trước → method này override về IT). Infrastructure data (NOT gated DemoSeed).
await SeedItDepartmentStaffAsync(db, userManager, logger);
+ // [S57bis 2026-06-11] Khóa 14 demo sample user (sếp yêu cầu clear dữ liệu cũ —
+ // anh chốt scope CHỈ user). PHẢI sau SeedDemoUsers + SeedItDepartmentStaff
+ // (chạy sau cùng mỗi startup → khóa BỀN, seed fix-drift không resurrect được).
+ await LockDemoSampleUsersAsync(userManager, logger);
// Plan B G-H1 (Mig 34 S33 2026-05-26) — seed EmployeeProfile 1-1 với
// mọi user @solutions.com.vn. Idempotent. NOT gated DemoSeed flag
// (infrastructure data, mirror Mig 32 SeedSampleContractWorkflowV2
@@ -1537,6 +1541,47 @@ public static class DbInitializer
// Default password: User@123456 (warn log để rotate prod).
private const string DemoUserPassword = "User@123456";
+ // [S57bis 2026-06-11] Khóa 14/16 demo sample user — sếp yêu cầu "clear dữ liệu cũ",
+ // anh chốt scope: CHỈ khóa user demo (GIỮ phiếu/HĐ/NCC/dự án demo). Ungated idempotent
+ // (mirror SeedRealMasterDataAsync philosophy): chạy mọi startup → khóa BỀN kể cả ai
+ // re-activate nhầm. GIỮ ACTIVE có chủ đích:
+ // - nv.cao + nv.truong : IT helpdesk round-robin pool (S52 P11-D) — khóa nốt SAU KHI
+ // anh gán ≥1 user thật vào Phòng CNTT (ops-pending S56), tránh helpdesk chết hẳn.
+ // - catalog.manager : account chức năng quản danh mục dùng chung (Plan CA S29).
+ // Muốn mở lại 1 user có chủ đích → gỡ email khỏi list này + admin re-activate.
+ private static async Task LockDemoSampleUsersAsync(
+ UserManager userManager, ILogger logger)
+ {
+ string[] emails =
+ [
+ "bod.huynh@solutions.com.vn", "bod.le@solutions.com.vn", "bod.tran@solutions.com.vn",
+ "pm.nguyen@solutions.com.vn", "pm.le@solutions.com.vn",
+ "ccm.tran@solutions.com.vn", "pro.pham@solutions.com.vn", "fin.do@solutions.com.vn",
+ "act.vu@solutions.com.vn", "equ.bui@solutions.com.vn", "hra.dang@solutions.com.vn",
+ "qs.hoang@solutions.com.vn", "qs.ngo@solutions.com.vn", "nv.dinh@solutions.com.vn",
+ ];
+
+ var locked = 0;
+ foreach (var email in emails)
+ {
+ var user = await userManager.FindByEmailAsync(email);
+ if (user is null) continue;
+ if (!user.IsActive && user.LockoutEnd == DateTimeOffset.MaxValue) continue; // đã khóa — idempotent skip
+
+ user.IsActive = false;
+ user.LockoutEnabled = true;
+ user.LockoutEnd = DateTimeOffset.MaxValue;
+ await userManager.UpdateAsync(user);
+ // Rotate SecurityStamp → vô hiệu refresh-token flow của account bị khóa
+ // (JWT đang sống tự hết hạn ≤1h theo expiry).
+ await userManager.UpdateSecurityStampAsync(user);
+ locked++;
+ }
+
+ if (locked > 0)
+ logger.LogInformation("Locked {Count} demo sample users (S57bis clear-old-data)", locked);
+ }
+
private static async Task SeedDemoUsersAsync(
ApplicationDbContext db, UserManager userManager, ILogger logger)
{
@@ -1729,7 +1774,8 @@ public static class DbInitializer
(MenuKeys.CatalogMaterials,"Vật tư / SP", MenuKeys.Catalogs, 2, "Package"),
(MenuKeys.CatalogServices, "Dịch vụ", MenuKeys.Catalogs, 3, "Wrench"),
(MenuKeys.CatalogWorkItems,"Hạng mục công việc", MenuKeys.Catalogs, 4, "ListChecks"),
- (MenuKeys.Contracts, "Hợp đồng", null, 30, "FileText"),
+ // [S57] Order 30→31: nhường slot 30 cho nhóm "Cá nhân" (đứng ngay sau Văn phòng số = 29, mirror Puro).
+ (MenuKeys.Contracts, "Hợp đồng", null, 31, "FileText"),
(MenuKeys.Forms, "Biểu mẫu", null, 40, "FileSpreadsheet"),
(MenuKeys.Reports, "Báo cáo", null, 50, "BarChart3"),
(MenuKeys.System, "Hệ thống", null, 90, "Settings"),
@@ -1751,13 +1797,17 @@ public static class DbInitializer
(MenuKeys.BudgetList, "Danh sách", MenuKeys.Budgets, 1, "List"),
(MenuKeys.BudgetCreate, "Thao tác", MenuKeys.Budgets, 2, "Plus"),
(MenuKeys.BudgetPending, "Duyệt", MenuKeys.Budgets, 3, "CheckCircle2"),
- // Module Nhân sự (Phase 10.1 G-H1 — Mig 34 S33). 1 root + 1 leaf
- // Phase 1 minimal. Phase 1.5 + G-H2/G-H3 thêm Config/Dashboard.
+ // Module Nhân sự (Phase 10.1 G-H1 — Mig 34 S33). Root operational HR.
+ // [S57] "Cấu hình HRM" re-parent sang "Danh mục" (Master) — gom config 1 chỗ.
+ // Hrm còn: Dashboard(1) → Hồ sơ(2), Dashboard đầu nhóm (khớp Puro).
(MenuKeys.Hrm, "Nhân sự", null, 28, "UserCircle"),
- (MenuKeys.HrmHoSo, "Hồ sơ Nhân sự", MenuKeys.Hrm, 1, "ContactRound"),
+ (MenuKeys.HrmHoSo, "Hồ sơ Nhân sự", MenuKeys.Hrm, 2, "ContactRound"),
- // Phase 10.2 G-H2 (Mig 35 — S34). Sub-group "Cấu hình HRM" + 4 catalog leaf.
- (MenuKeys.HrmConfig, "Cấu hình HRM", MenuKeys.Hrm, 2, "Settings2"),
+ // Phase 10.2 G-H2 (Mig 35 — S34). Sub-group "Cấu hình HRM" + 6 catalog leaf.
+ // [S57] parent Hrm → Master: nằm dưới "Danh mục" (order 25, sau Catalogs) để gom
+ // toàn bộ config/catalog 1 chỗ. 6 leaf bên dưới giữ parent=HrmConfig nên theo cùng.
+ // DB cũ propagate qua parentBackfill bên dưới (main upsert chỉ re-set Order).
+ (MenuKeys.HrmConfig, "Cấu hình HRM", MenuKeys.Master, 25, "Settings2"),
(MenuKeys.HrmConfigLeaveTypes, "Loại phép", MenuKeys.HrmConfig, 1, "CalendarOff"),
(MenuKeys.HrmConfigHolidays, "Ngày lễ", MenuKeys.HrmConfig, 2, "PartyPopper"),
(MenuKeys.HrmConfigShifts, "Ca làm việc", MenuKeys.HrmConfig, 3, "Clock"),
@@ -1787,10 +1837,15 @@ public static class DbInitializer
(MenuKeys.OffDonTuTravel, "Công tác", MenuKeys.OffDonTu, 3, "Plane"),
(MenuKeys.OffDatXe, "Đặt xe công", MenuKeys.Off, 5, "Car"),
(MenuKeys.OffItTicket, "Ticket CNTT", MenuKeys.Off, 6, "Ticket"),
- (MenuKeys.OffChamCong, "Chấm công", MenuKeys.Off, 7, "Fingerprint"),
- (MenuKeys.OffAttendanceReport, "Báo cáo chấm công", MenuKeys.Off, 8, "FileBarChart"),
- // Phase 10.4 G-H3 — Dashboard NS dưới root Hrm.
- (MenuKeys.HrmDashboard, "Dashboard NS", MenuKeys.Hrm, 3, "BarChart3"),
+ // [S57] "Báo cáo chấm công" giữ ở Văn phòng số (báo cáo admin, order 7 — lấp chỗ Chấm công rời đi).
+ (MenuKeys.OffAttendanceReport, "Báo cáo chấm công", MenuKeys.Off, 7, "FileBarChart"),
+ // [S57] Nhóm "Cá nhân" (mirror Puro). Root order 30 = ngay sau Văn phòng số (29).
+ // "Chấm công" re-parent Off → Personal; với DB cũ propagate qua parentBackfill bên dưới
+ // (main upsert chỉ re-set Order, KHÔNG đụng ParentKey).
+ (MenuKeys.Personal, "Cá nhân", null, 30, "UserRound"),
+ (MenuKeys.OffChamCong, "Chấm công", MenuKeys.Personal, 1, "Fingerprint"),
+ // Phase 10.4 G-H3 — Dashboard NS dưới root Hrm. [S57] Order 1 = đầu nhóm (khớp Puro).
+ (MenuKeys.HrmDashboard, "Dashboard NS", MenuKeys.Hrm, 1, "BarChart3"),
};
// Per-type sub-menu under Contracts: 1 group + 3 leaves each
@@ -1895,6 +1950,30 @@ public static class DbInitializer
logger.LogInformation("Backfilled {Count} menu labels", updatedLabels);
}
+ // [S57] Re-parent backfill — chuyển node sang group khác trên DB cũ. Main
+ // upsert phía trên CHỈ re-set Order, KHÔNG đụng ParentKey (xem comment trên),
+ // nên đổi nhóm phải update ParentKey riêng. Idempotent. "Chấm công" Off → Cá nhân.
+ var parentBackfill = new Dictionary
+ {
+ [MenuKeys.OffChamCong] = MenuKeys.Personal, // [S57] Chấm công → Cá nhân
+ [MenuKeys.HrmConfig] = MenuKeys.Master, // [S57] Cấu hình HRM → Danh mục (gom config 1 chỗ)
+ };
+ var reparented = 0;
+ foreach (var (key, expectedParent) in parentBackfill)
+ {
+ var item = await db.MenuItems.FirstOrDefaultAsync(m => m.Key == key);
+ if (item != null && item.ParentKey != expectedParent)
+ {
+ item.ParentKey = expectedParent;
+ reparented++;
+ }
+ }
+ if (reparented > 0)
+ {
+ await db.SaveChangesAsync();
+ logger.LogInformation("Re-parented {Count} menu items", reparented);
+ }
+
// Backfill WorkflowDefinition name cho B (Phương Án → Giải pháp rename).
var wfB = await db.PurchaseEvaluationWorkflowDefinitions
.FirstOrDefaultAsync(w => w.Code == "QT-DN-B" && w.Version == 1);
@@ -1944,6 +2023,109 @@ public static class DbInitializer
// (Master/Suppliers/Projects/Departments + 4 Catalogs leaf). Admin gán role
// cho user nào cần CRUD danh mục sau khi move FE từ admin → eoffice.
await SeedCatalogManagerPermissionsAsync(db, roleManager, logger);
+
+ // [S57] Mở quyền XEM (Read-only) cho TẤT CẢ role để mọi bộ phận review/góp ý
+ // các module HRM + Văn phòng số + Danh mục (master). KHÔNG đụng Duyệt NCC
+ // (Pe_*/PeWf_*/AwV2 — sắp go-live, giữ phân quyền cũ), Contracts/Budgets/System.
+ await SeedAllRolesReviewReadPermissionsAsync(db, roleManager, logger);
+ }
+
+ // [S57] Cấp CanRead (CHỈ xem) cho MỌI role trên menu HRM + Office + Master để mọi
+ // bộ phận nhân viên thấy + review/góp ý. Additive idempotent → KHÔNG xóa quyền
+ // sẵn có. Write vẫn khóa ở controller (Master: Admin+CatalogManager;
+ // HRM-config/Catalogs/MeetingRoom: Admin).
+ //
+ // [S57bis] Mở Duyệt NCC (Pe_*) cho MỌI role: anh chốt "Xem + Tạo".
+ // - Key HRM/Office/Master/Catalogs : CanRead-only, skip-existing (giữ nguyên).
+ // - Key Pe_* : CanRead=true + CanCreate=true.
+ // Idempotent UPGRADE-ONLY: row Pe_* đã tồn tại (Pe defaults cũ seed 7 role)
+ // mà CanRead HOẶC CanCreate=false → NÂNG đúng 2 cờ đó lên true. KHÔNG hạ +
+ // KHÔNG đụng CanUpdate/CanDelete (additive — không phá quyền admin đã chỉnh
+ // cao hơn). Row chưa có → tạo mới CanRead+CanCreate=true, Update/Delete=false.
+ private static async Task SeedAllRolesReviewReadPermissionsAsync(
+ ApplicationDbContext db, RoleManager roleManager, ILogger logger)
+ {
+ // Scope read-only = HRM (Hrm*) + Office (Off*) + Personal + Master + Catalogs.
+ // [S57bis] +Pe_* (Duyệt NCC) — semantics riêng read+create xử lý bên dưới.
+ // Loại trừ tự nhiên (không match prefix): PeWf_* (4th char 'W' ≠ '_'),
+ // AwV2_*, Ct_*, Bg_*, Wf_*, System keys.
+ static bool InReviewScope(string key) =>
+ key.StartsWith("Hrm") || key.StartsWith("Off") || key == MenuKeys.Personal ||
+ key.StartsWith("Catalog") || key == MenuKeys.Master ||
+ key == MenuKeys.Suppliers || key == MenuKeys.Projects || key == MenuKeys.Departments ||
+ key.StartsWith("Pe_");
+
+ // Phân biệt key Pe_* (read+create) vs read-only. Pe_* match cờ thứ-3 '_'
+ // → "PeWf_*"/"PeWorkflows" KHÔNG match (loại admin Designer).
+ static bool IsPeKey(string key) => key.StartsWith("Pe_");
+
+ // MenuKeys.All chứa root PurchaseEvaluations nhưng KHÔNG chứa Pe_* leaf
+ // (sinh động qua factory). Build leaf giống SeedPurchaseEvaluationPermissionDefaultsAsync
+ // để upgrade đúng row Pe_* thật trong DB (1 root + 5 leaf × 2 type).
+ var peKeys = new List { MenuKeys.PurchaseEvaluations };
+ foreach (var typeCode in MenuKeys.PurchaseEvaluationTypeCodes)
+ {
+ peKeys.Add(MenuKeys.PurchaseEvaluationGroup(typeCode));
+ peKeys.Add(MenuKeys.PurchaseEvaluationWorkflowView(typeCode));
+ peKeys.Add(MenuKeys.PurchaseEvaluationList(typeCode));
+ peKeys.Add(MenuKeys.PurchaseEvaluationCreate(typeCode));
+ peKeys.Add(MenuKeys.PurchaseEvaluationPending(typeCode));
+ }
+
+ var reviewKeys = MenuKeys.All.Where(InReviewScope)
+ .Concat(peKeys)
+ .Distinct()
+ .ToArray();
+ var roles = await roleManager.Roles.ToListAsync();
+
+ // Load full rows (cần mutate CanRead/CanCreate cho Pe_* upgrade path).
+ var existingRows = (await db.Permissions
+ .Where(p => reviewKeys.Contains(p.MenuKey))
+ .ToListAsync())
+ .ToDictionary(p => (p.RoleId, p.MenuKey));
+
+ var added = 0;
+ var upgraded = 0;
+ foreach (var role in roles)
+ {
+ foreach (var key in reviewKeys)
+ {
+ var isPe = IsPeKey(key);
+ if (existingRows.TryGetValue((role.Id, key), out var row))
+ {
+ // [S57bis] Pe_* upgrade-only: nâng CanRead/CanCreate nếu đang false.
+ if (isPe)
+ {
+ var changed = false;
+ if (!row.CanRead) { row.CanRead = true; changed = true; }
+ if (!row.CanCreate) { row.CanCreate = true; changed = true; }
+ if (changed) upgraded++;
+ }
+ // Key non-Pe: skip-existing (giữ nguyên như cũ).
+ continue;
+ }
+
+ db.Permissions.Add(new Permission
+ {
+ RoleId = role.Id,
+ MenuKey = key,
+ CanRead = true,
+ CanCreate = isPe, // [S57bis] Pe_* được Tạo; còn lại read-only
+ CanUpdate = false,
+ CanDelete = false,
+ });
+ added++;
+ }
+ }
+
+ if (added > 0 || upgraded > 0)
+ {
+ await db.SaveChangesAsync();
+ logger.LogInformation(
+ "Seeded all-roles review perms: {Added} added + {Upgraded} upgraded (Pe_* read+create) " +
+ "({Keys} keys × {Roles} roles)",
+ added, upgraded, reviewKeys.Length, roles.Count);
+ }
}
// [Plan CA S29 2026-05-22] Permission defaults cho role CatalogManager.
diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260611044424_AddWorkItemToPurchaseEvaluation.Designer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260611044424_AddWorkItemToPurchaseEvaluation.Designer.cs
new file mode 100644
index 0000000..672f7ad
--- /dev/null
+++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260611044424_AddWorkItemToPurchaseEvaluation.Designer.cs
@@ -0,0 +1,6538 @@
+//
+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("20260611044424_AddWorkItemToPurchaseEvaluation")]
+ partial class AddWorkItemToPurchaseEvaluation
+ {
+ ///
+ 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("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("AllowApproverEditBudget")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bit")
+ .HasDefaultValue(false);
+
+ b.Property("AllowApproverEditDetails")
+ .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.Budgets.Budget", 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("DepartmentId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Description")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("DrafterUserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("IsDeleted")
+ .HasColumnType("bit");
+
+ b.Property("MaNganSach")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("NamNganSach")
+ .HasColumnType("int");
+
+ b.Property("Phase")
+ .HasColumnType("int");
+
+ b.Property("ProjectId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("RejectedFromPhase")
+ .HasColumnType("int");
+
+ b.Property("SlaDeadline")
+ .HasColumnType("datetime2");
+
+ b.Property("SlaWarningSent")
+ .HasColumnType("bit");
+
+ b.Property("TenNganSach")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("TongNganSach")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MaNganSach")
+ .IsUnique()
+ .HasFilter("[MaNganSach] IS NOT NULL");
+
+ b.HasIndex("NamNganSach");
+
+ b.HasIndex("ProjectId");
+
+ b.HasIndex("SlaDeadline");
+
+ b.HasIndex("Phase", "IsDeleted");
+
+ b.ToTable("Budgets", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetApproval", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ApprovedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("ApproverUserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("BudgetId")
+ .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("ToPhase")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BudgetId", "ApprovedAt");
+
+ b.ToTable("BudgetApprovals", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetChangelog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Action")
+ .HasColumnType("int");
+
+ b.Property("BudgetId")
+ .HasColumnType("uniqueidentifier");
+
+ 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("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("BudgetId", "CreatedAt");
+
+ b.HasIndex("BudgetId", "EntityType");
+
+ b.ToTable("BudgetChangelogs", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetDepartmentApproval", 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("BudgetId")
+ .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("Stage")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApproverUserId");
+
+ b.HasIndex("BudgetId");
+
+ b.HasIndex("DepartmentId");
+
+ b.HasIndex("BudgetId", "PhaseAtApproval", "DepartmentId", "Stage")
+ .IsUnique()
+ .HasDatabaseName("UX_BudgetDeptApprovals_Budget_Phase_Dept_Stage");
+
+ b.ToTable("BudgetDepartmentApprovals", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetDetail", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("BudgetId")
+ .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")
+ .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("KhoiLuong")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("NoiDung")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("Order")
+ .HasColumnType("int");
+
+ 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("BudgetId", "Order");
+
+ b.ToTable("BudgetDetails", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ApprovalWorkflowId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("BudgetId")
+ .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("BudgetId");
+
+ 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