[CLAUDE] PurchaseEvaluation: PE gắn Hạng mục công việc (Mig 49) + mở quyền Pe all-role + menu Cá nhân + khóa 14 demo user
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m24s

Sếp chốt deadline 15:00 (Zalo 11:02-11:17): flow tạo phiếu chọn quy trình → dự án → HẠNG MỤC → NCC/TP; phiếu dạng «Dự án – Hạng mục»; all-user thấy Duyệt NCC + master config; clear data cũ.

- Mig 49 AddWorkItemToPurchaseEvaluation: PE.WorkItemId Guid? loose-Guid + index (KHÔNG FK vật lý — convention PE, database-agent design). Validator NotEmpty (create) + FK-guard AnyAsync(IsActive) → Conflict + UpdateDraft NULL-SAFE (client không gửi → giữ, chống null-hóa bug-class S42). 3 projection ListItemDto LEFT-join WorkItems.
- FE ×2 app: PeWorkspaceCreateView select «c. Hạng mục *» + PeHeaderForm (load existing + PUT gửi lại, SHA256 IDENTICAL) + PeDetailTabs (header «Dự án – Hạng mục» + FormRow + inline khóa) + types. Route reuse /catalogs/work-items.
- Perm: SeedAllRolesReviewReadPermissionsAsync extend Pe_* 11 key (factory — Pe leaf không nằm All) CanRead+CanCreate upgrade-only mọi role; PeWf_*/AwV2 GIỮ Admin. HRM/Office/Master/Catalogs CanRead (S57). Master write-lock Admin,CatalogManager ×3 controller.
- Menu «Cá nhân» (Personal root 30, mirror Puro) + Chấm công re-parent + HrmConfig→Master + parentBackfill idempotent + admin bỏ ẩn Master (đảo S29).
- LockDemoSampleUsersAsync: khóa 14/16 sample (GIỮ nv.cao+nv.truong IT-pool + catalog.manager) — ungated idempotent, IsActive=0+Lockout+SecurityStamp rotate.
- Tests +12 PeWorkItemGuardTests (validator/FK-guard/null-safe) → 240 PASS. npm ×2 + BE 0W/0E.
- Excel (3) đối chiếu: 62/71/3 identical S55 — no data change.
- Gate: em main evidence-checklist (2 reviewer-spawn die-0-byte — resume-kill; backstop 12 guard-test + authz-key/role-string/Mig-49 evidence-lệnh).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-11 12:13:26 +07:00
parent 17b23a418a
commit dd117b749c
26 changed files with 7461 additions and 29 deletions

View File

@ -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).

View File

@ -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[] {

View File

@ -187,6 +187,8 @@ export function PeDetailTabs({
<span>{PurchaseEvaluationTypeLabel[evaluation.type]}</span>
<span>·</span>
<span>{evaluation.projectName}</span>
{/* S57bis — phiếu dạng "Dự án Hạng mục công việc" (lời sếp) */}
{evaluation.workItemName && <><span></span><span>{evaluation.workItemName}</span></>}
{evaluation.drafterName && <><span>·</span><span>Soạn: {evaluation.drafterName}</span></>}
</div>
</div>
@ -667,6 +669,8 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
)}
</div>
<FormRow label="b. Dự án" value={ev.projectName} />
{/* S57bis — Hạng mục công việc (WorkItem master). Phiếu cũ null → "—". */}
<FormRow label="c. Hạng mục công việc" value={ev.workItemName ? `${ev.workItemCode ? `${ev.workItemCode}` : ''}${ev.workItemName}` : '—'} />
{(ev.diaDiem || ev.moTa || ev.paymentTerms) && (
<div className="mt-3 rounded bg-slate-50 px-3 py-2 text-[12px] text-slate-600">
{ev.diaDiem && <div><span className="text-slate-400">Đa điểm:</span> {ev.diaDiem}</div>}
@ -694,6 +698,11 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
<Label className="text-[11px]">b. Dự án (khóa)</Label>
<Input value={ev.projectName} disabled className="bg-slate-100" />
</div>
<div className="md:col-span-2">
{/* S57bis — hạng mục khóa ở inline-edit; đổi qua "Sửa header phiếu" (PeHeaderForm). */}
<Label className="text-[11px]">c. Hạng mục công việc (khóa)</Label>
<Input value={ev.workItemName ? `${ev.workItemCode ? `${ev.workItemCode}` : ''}${ev.workItemName}` : '—'} disabled className="bg-slate-100" />
</div>
<div>
<Label className="text-[11px]">Đa điểm</Label>
<Input

View File

@ -24,6 +24,15 @@ 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, 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<WorkItemOption[]>('/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({
</Select>
</div>
<div>
{/* 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. */}
<Label>Hạng mục công việc {!editId && '*'}</Label>
<Select
value={form.workItemId}
onChange={e => setForm({ ...form, workItemId: e.target.value })}
>
<option value="">{editId ? '— (giữ nguyên / chưa gắn)' : '-- Chọn --'}</option>
{workItems.data?.map(w => (
<option key={w.id} value={w.id}>
{w.category ? `[${w.category}] ` : ''}{w.code} {w.name}
</option>
))}
</Select>
</div>
<div>
<div className="mb-1.5 flex items-center justify-between">
<Label className="mb-0">Ngân sách (đi chiếu chi phí)</Label>
@ -272,7 +309,7 @@ export function PeHeaderForm({
)}
<Button
onClick={() => mut.mutate()}
disabled={!form.tenGoiThau || !form.projectId || mut.isPending}
disabled={!form.tenGoiThau || !form.projectId || (!editId && !form.workItemId) || mut.isPending}
>
{editId ? 'Lưu' : 'Tạo phiếu'}
</Button>

View File

@ -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<WorkItemOption[]>('/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 (
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
@ -192,6 +211,26 @@ export function PeWorkspaceCreateView({
))}
</Select>
</div>
<div className="md:col-span-2">
<Label className="text-[11px]">c. Hạng mục công việc *</Label>
<Select
value={form.workItemId}
onChange={e => setForm({ ...form, workItemId: e.target.value })}
required
>
<option value=""> Chọn hạng mục công việc </option>
{workItems.data?.map(w => (
<option key={w.id} value={w.id}>
{w.category ? `[${w.category}] ` : ''}{w.code} {w.name}
</option>
))}
</Select>
{workItems.data && workItems.data.length === 0 && (
<p className="mt-1 text-[11px] text-amber-700">
Chưa 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.
</p>
)}
</div>
<div>
<Label className="text-[11px]">Đa điểm</Label>
<Input

View File

@ -67,6 +67,8 @@ export const MenuKeys = {
// P11-E (S52) — Báo cáo chấm công (admin-only leaf dưới Văn phòng số)
OffAttendanceReport: 'Off_AttendanceReport',
HrmDashboard: 'Hrm_Dashboard',
// [S57] Nhóm "Cá nhân" (mirror Puro) — Chấm công re-parent Off → Personal
Personal: 'Personal',
} as const
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]

View File

@ -113,6 +113,9 @@ export type PeListItem = {
phase: number
projectId: string
projectName: string
// S57bis — Hạng mục công việc (nullable cho phiếu cũ). BE list DTO trả workItemName.
workItemId: string | null
workItemName: string | null
selectedSupplierId: string | null
selectedSupplierName: string | null
contractId: string | null
@ -389,6 +392,11 @@ export type PeDetailBundle = {
moTa: string | null
projectId: string
projectName: string
// S57bis — Hạng mục công việc (WorkItem master) gắn header phiếu. Nullable cho
// phiếu cũ tạo trước khi field này tồn tại. BE PE DTO trả 3 field này.
workItemId: string | null
workItemName: string | null
workItemCode: string | null
departmentId: string | null
departmentName: string | null
drafterUserId: string | null

View File

@ -191,6 +191,8 @@ export function PeDetailTabs({
<span>{PurchaseEvaluationTypeLabel[evaluation.type]}</span>
<span>·</span>
<span>{evaluation.projectName}</span>
{/* S57bis — phiếu dạng "Dự án Hạng mục công việc" (lời sếp) */}
{evaluation.workItemName && <><span></span><span>{evaluation.workItemName}</span></>}
{evaluation.drafterName && <><span>·</span><span>Soạn: {evaluation.drafterName}</span></>}
</div>
</div>
@ -672,6 +674,8 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
)}
</div>
<FormRow label="b. Dự án" value={ev.projectName} />
{/* S57bis — Hạng mục công việc (WorkItem master). Phiếu cũ null → "—". */}
<FormRow label="c. Hạng mục công việc" value={ev.workItemName ? `${ev.workItemCode ? `${ev.workItemCode}` : ''}${ev.workItemName}` : '—'} />
{(ev.diaDiem || ev.moTa || ev.paymentTerms) && (
<div className="mt-3 rounded bg-slate-50 px-3 py-2 text-[12px] text-slate-600">
{ev.diaDiem && <div><span className="text-slate-400">Đa điểm:</span> {ev.diaDiem}</div>}
@ -699,6 +703,11 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
<Label className="text-[11px]">b. Dự án (khóa)</Label>
<Input value={ev.projectName} disabled className="bg-slate-100" />
</div>
<div className="md:col-span-2">
{/* S57bis — hạng mục khóa ở inline-edit; đổi qua "Sửa header phiếu" (PeHeaderForm). */}
<Label className="text-[11px]">c. Hạng mục công việc (khóa)</Label>
<Input value={ev.workItemName ? `${ev.workItemCode ? `${ev.workItemCode}` : ''}${ev.workItemName}` : '—'} disabled className="bg-slate-100" />
</div>
<div>
<Label className="text-[11px]">Đa điểm</Label>
<Input

View File

@ -24,6 +24,15 @@ 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, 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<WorkItemOption[]>('/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({
</Select>
</div>
<div>
{/* 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. */}
<Label>Hạng mục công việc {!editId && '*'}</Label>
<Select
value={form.workItemId}
onChange={e => setForm({ ...form, workItemId: e.target.value })}
>
<option value="">{editId ? '— (giữ nguyên / chưa gắn)' : '-- Chọn --'}</option>
{workItems.data?.map(w => (
<option key={w.id} value={w.id}>
{w.category ? `[${w.category}] ` : ''}{w.code} {w.name}
</option>
))}
</Select>
</div>
<div>
<div className="mb-1.5 flex items-center justify-between">
<Label className="mb-0">Ngân sách (đi chiếu chi phí)</Label>
@ -272,7 +309,7 @@ export function PeHeaderForm({
)}
<Button
onClick={() => mut.mutate()}
disabled={!form.tenGoiThau || !form.projectId || mut.isPending}
disabled={!form.tenGoiThau || !form.projectId || (!editId && !form.workItemId) || mut.isPending}
>
{editId ? 'Lưu' : 'Tạo phiếu'}
</Button>

View File

@ -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<WorkItemOption[]>('/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 (
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
@ -190,6 +209,26 @@ export function PeWorkspaceCreateView({
))}
</Select>
</div>
<div className="md:col-span-2">
<Label className="text-[11px]">c. Hạng mục công việc *</Label>
<Select
value={form.workItemId}
onChange={e => setForm({ ...form, workItemId: e.target.value })}
required
>
<option value=""> Chọn hạng mục công việc </option>
{workItems.data?.map(w => (
<option key={w.id} value={w.id}>
{w.category ? `[${w.category}] ` : ''}{w.code} {w.name}
</option>
))}
</Select>
{workItems.data && workItems.data.length === 0 && (
<p className="mt-1 text-[11px] text-amber-700">
Chưa 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.
</p>
)}
</div>
<div>
<Label className="text-[11px]">Đa điểm</Label>
<Input

View File

@ -65,6 +65,8 @@ export const MenuKeys = {
OffItTicket: 'Off_ItTicket',
OffChamCong: 'Off_ChamCong',
HrmDashboard: 'Hrm_Dashboard',
// [S57] Nhóm "Cá nhân" (mirror Puro) — Chấm công re-parent Off → Personal
Personal: 'Personal',
} as const
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]

View File

@ -112,6 +112,9 @@ export type PeListItem = {
phase: number
projectId: string
projectName: string
// S57bis — Hạng mục công việc (nullable cho phiếu cũ). BE list DTO trả workItemName.
workItemId: string | null
workItemName: string | null
selectedSupplierId: string | null
selectedSupplierName: string | null
contractId: string | null
@ -386,6 +389,11 @@ export type PeDetailBundle = {
moTa: string | null
projectId: string
projectName: string
// S57bis — Hạng mục công việc (WorkItem master) gắn header phiếu. Nullable cho
// phiếu cũ tạo trước khi field này tồn tại. BE PE DTO trả 3 field này.
workItemId: string | null
workItemName: string | null
workItemCode: string | null
departmentId: string | null
departmentName: string | null
drafterUserId: string | null

View File

@ -22,6 +22,9 @@ public class DepartmentsController(IMediator mediator) : ControllerBase
public async Task<ActionResult<DepartmentDto>> 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<ActionResult<Guid>> 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<IActionResult> 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<IActionResult> Delete(Guid id, CancellationToken ct)
{

View File

@ -22,6 +22,9 @@ public class ProjectsController(IMediator mediator) : ControllerBase
public async Task<ActionResult<ProjectDto>> 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<ActionResult<Guid>> 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<IActionResult> 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<IActionResult> Delete(Guid id, CancellationToken ct)
{

View File

@ -29,6 +29,9 @@ public class SuppliersController(IMediator mediator) : ControllerBase
public async Task<ActionResult<SupplierDto>> 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<ActionResult<Guid>> 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<IActionResult> 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<IActionResult> Delete(Guid id, CancellationToken ct)
{

View File

@ -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,

View File

@ -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,

View File

@ -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<Guid>; // [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<Guid>; // [Mig 49 S57bis] Hạng mục công việc — flow create PHẢI chọn (validator NotEmpty)
public class CreatePurchaseEvaluationCommandValidator : AbstractValidator<CreatePurchaseEvaluationCommand>
{
@ -36,6 +36,12 @@ public class CreatePurchaseEvaluationCommandValidator : AbstractValidator<Create
RuleFor(x => 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,

View File

@ -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] nhân (Puro grouping Chấm công re-parent)
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
];

View File

@ -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...

View File

@ -25,6 +25,9 @@ public class PurchaseEvaluationConfiguration : IEntityTypeConfiguration<Purchase
b.HasIndex(x => 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);

View File

@ -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<User> 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<User> 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<string, string?>
{
[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<Role> 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<string> { 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.

View File

@ -0,0 +1,38 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddWorkItemToPurchaseEvaluation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "WorkItemId",
table: "PurchaseEvaluations",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluations_WorkItemId",
table: "PurchaseEvaluations",
column: "WorkItemId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_PurchaseEvaluations_WorkItemId",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "WorkItemId",
table: "PurchaseEvaluations");
}
}
}

View File

@ -4942,6 +4942,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("WorkItemId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("WorkflowDefinitionId")
.HasColumnType("uniqueidentifier");
@ -4961,6 +4964,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.HasIndex("SlaDeadline");
b.HasIndex("WorkItemId");
b.HasIndex("WorkflowDefinitionId");
b.HasIndex("Phase", "IsDeleted");

View File

@ -0,0 +1,369 @@
using FluentValidation;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.PurchaseEvaluations;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Domain.Master.Catalogs;
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;
using SolutionErp.Infrastructure.Tests.Services; // NoOpNotificationService (reuse internal helper)
namespace SolutionErp.Infrastructure.Tests.Application;
// [Mig 49 S57bis] PE gắn WorkItemId (hạng mục công việc) — loose-Guid scalar (KHÔNG
// FK vật lý, KHÔNG navigation — convention PE giống ProjectId/SelectedSupplierId).
// Test guard 3 trục theo CODE đã land (single source of truth):
// 1. CreatePurchaseEvaluationCommandValidator — RuleFor(WorkItemId).NotEmpty()
// 2. CreatePurchaseEvaluationCommandHandler — FK-invariant guard
// db.WorkItems.AnyAsync(w.Id==x && w.IsActive) → ConflictException
// 3. UpdatePurchaseEvaluationDraftCommandHandler — null-safe partial update
// (request.WorkItemId is null → GIỮ entity.WorkItemId; bug-class S42 picker
// null-hoá từ client cũ / inline-edit không gửi field).
//
// Create handler 4 dep (db + ICurrentUser + workflow svc + codeGen) instantiate
// thật trên SQLite (codeGen Serializable-tx = non-issue SQLite — proven S52).
// UpdateDraft handler chỉ 2 dep (db + ICurrentUser) → nhẹ.
public class PeWorkItemGuardTests
{
private sealed class FakeCurrentUser : ICurrentUser
{
public Guid? UserId { get; init; } = Guid.NewGuid();
public string? Email { get; init; } = "drafter@test.local";
public string? FullName { get; init; } = "Drafter Test";
public IReadOnlyList<string> Roles { get; init; } = new[] { AppRoles.Drafter };
public bool IsAuthenticated => UserId is not null;
}
// Build full Create handler stack từ IdentityFixture (db + um cho workflow svc).
private static CreatePurchaseEvaluationCommandHandler BuildCreateHandler(
TestApplicationDbContext db, UserManager<User> um, ICurrentUser currentUser)
{
var dt = new FixedDateTime(new DateTime(2026, 6, 11, 0, 0, 0, DateTimeKind.Utc));
var notify = new NoOpNotificationService();
var workflow = new PurchaseEvaluationWorkflowService(db, dt, notify, um);
var codeGen = new PurchaseEvaluationCodeGenerator(db, dt);
return new CreatePurchaseEvaluationCommandHandler(db, currentUser, workflow, codeGen);
}
private static async Task<Project> SeedProjectAsync(TestApplicationDbContext db)
{
var p = new Project { Id = Guid.NewGuid(), Code = "PRJ-WI", Name = "Dự án test WorkItem" };
db.Projects.Add(p);
await db.SaveChangesAsync(CancellationToken.None);
return p;
}
private static async Task<WorkItem> SeedWorkItemAsync(
TestApplicationDbContext db, string code, bool isActive = true)
{
var wi = new WorkItem
{
Id = Guid.NewGuid(),
Code = code,
Name = "Hạng mục " + code,
IsActive = isActive,
};
db.WorkItems.Add(wi);
await db.SaveChangesAsync(CancellationToken.None);
return wi;
}
private static CreatePurchaseEvaluationCommand BuildCreateCommand(
Guid projectId, Guid? workItemId)
=> new(
Type: PurchaseEvaluationType.DuyetNcc,
TenGoiThau: "Gói thầu test",
ProjectId: projectId,
DepartmentId: null,
DiaDiem: null,
MoTa: null,
PaymentTerms: null,
BudgetId: null,
BudgetManualName: null,
BudgetManualAmount: null,
ApprovalWorkflowId: null,
WorkItemId: workItemId);
// ============================================================
// 1. VALIDATOR — RuleFor(WorkItemId).NotEmpty()
// ============================================================
[Fact]
public void Validator_WorkItemIdNull_IsInvalid_WithErrorOnWorkItemId()
{
var validator = new CreatePurchaseEvaluationCommandValidator();
var cmd = BuildCreateCommand(Guid.NewGuid(), workItemId: null);
var result = validator.Validate(cmd);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == nameof(CreatePurchaseEvaluationCommand.WorkItemId));
}
[Fact]
public void Validator_WorkItemIdEmptyGuid_PassesValidator_ButHandlerFkGuardCatches()
{
// ⚠️ SPEC-DRIFT NOTE (test theo CODE — S34 rule): WorkItemId là `Guid?`
// (nullable). FluentValidation 7.2 `NotEmpty()` trên nullable-Guid chỉ check
// != default(Guid?) == null — KHÔNG check Guid.Empty. Nên Guid.Empty (non-null)
// PASS validator. Đây KHÔNG phải lỗ hổng thực tế: create handler FK-guard
// `db.WorkItems.AnyAsync(w.Id==Guid.Empty && w.IsActive)` luôn false → throw
// ConflictException. Defense-in-depth: validator chặn null, handler chặn
// bogus/empty/inactive. Test này LOCK behavior hiện tại để nếu ai đổi sang
// non-nullable Guid hoặc thêm GuidExtensions.NotEmpty → đỏ → review chủ đích.
var validator = new CreatePurchaseEvaluationCommandValidator();
var cmd = BuildCreateCommand(Guid.NewGuid(), workItemId: Guid.Empty);
var result = validator.Validate(cmd);
result.Errors.Should().NotContain(
e => e.PropertyName == nameof(CreatePurchaseEvaluationCommand.WorkItemId),
"NotEmpty() trên Guid? chỉ bắt null, KHÔNG bắt Guid.Empty — handler FK-guard mới chặn");
}
[Fact]
public void Validator_WorkItemIdPresent_NoErrorOnWorkItemId()
{
// Chỉ assert rule WorkItemId pass — command còn lại đã hợp lệ ở BuildCreateCommand
// nên result.IsValid=true; nhưng narrow assert vào property để test đúng rule này.
var validator = new CreatePurchaseEvaluationCommandValidator();
var cmd = BuildCreateCommand(Guid.NewGuid(), workItemId: Guid.NewGuid());
var result = validator.Validate(cmd);
result.Errors.Should().NotContain(e => e.PropertyName == nameof(CreatePurchaseEvaluationCommand.WorkItemId));
result.IsValid.Should().BeTrue();
}
// ============================================================
// 2. CREATE HANDLER — FK-invariant guard
// ============================================================
[Fact]
public async Task Create_WorkItemBogusGuid_ThrowsConflict()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var project = await SeedProjectAsync(db);
var handler = BuildCreateHandler(db, um, new FakeCurrentUser());
// WorkItemId không tồn tại trong WorkItems → guard fail.
var cmd = BuildCreateCommand(project.Id, workItemId: Guid.NewGuid());
var act = async () => await handler.Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*Hạng mục công việc không tồn tại hoặc ngưng hoạt động*");
}
[Fact]
public async Task Create_WorkItemEmptyGuid_ThrowsConflict_HandlerDefenseInDepth()
{
// Chứng minh claim ở Validator_WorkItemIdEmptyGuid_PassesValidator: validator
// cho Guid.Empty qua, nhưng handler FK-guard (`is Guid wiId` true cho Empty +
// AnyAsync false) chặn → ConflictException. Defense-in-depth thực sự hoạt động.
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var project = await SeedProjectAsync(db);
var handler = BuildCreateHandler(db, um, new FakeCurrentUser());
var cmd = BuildCreateCommand(project.Id, workItemId: Guid.Empty);
var act = async () => await handler.Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*Hạng mục công việc không tồn tại hoặc ngưng hoạt động*");
}
[Fact]
public async Task Create_WorkItemInactive_ThrowsConflict()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var project = await SeedProjectAsync(db);
var inactive = await SeedWorkItemAsync(db, "WI-INACTIVE", isActive: false);
var handler = BuildCreateHandler(db, um, new FakeCurrentUser());
// WorkItem tồn tại nhưng IsActive=false → guard (w.IsActive) loại → Conflict.
var cmd = BuildCreateCommand(project.Id, workItemId: inactive.Id);
var act = async () => await handler.Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*Hạng mục công việc không tồn tại hoặc ngưng hoạt động*");
}
[Fact]
public async Task Create_WorkItemActive_Succeeds_AndPersistsWorkItemId()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var project = await SeedProjectAsync(db);
var active = await SeedWorkItemAsync(db, "WI-ACTIVE", isActive: true);
var handler = BuildCreateHandler(db, um, new FakeCurrentUser());
var cmd = BuildCreateCommand(project.Id, workItemId: active.Id);
var newId = await handler.Handle(cmd, CancellationToken.None);
var saved = await db.PurchaseEvaluations.FindAsync(newId);
saved.Should().NotBeNull();
saved!.WorkItemId.Should().Be(active.Id, "create persist đúng WorkItemId đã chọn");
saved.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao);
}
// ============================================================
// 3. UPDATE DRAFT — null-safe partial update (bug-class S42 picker)
// ============================================================
private static UpdatePurchaseEvaluationDraftCommand BuildUpdateCommand(
Guid peId, Guid? workItemId)
=> new(
Id: peId,
TenGoiThau: "Gói thầu sửa",
DiaDiem: null,
MoTa: null,
PaymentTerms: null,
BudgetId: null,
BudgetManualName: null,
BudgetManualAmount: null,
ApprovalWorkflowId: null,
WorkItemId: workItemId);
private static UpdatePurchaseEvaluationDraftCommandHandler BuildUpdateHandler(
TestApplicationDbContext db, ICurrentUser currentUser)
=> new(db, currentUser);
// Phiếu Nháp có sẵn WorkItemId = w1 (state sau Create).
private static async Task<PurchaseEvaluation> SeedDraftPeAsync(
TestApplicationDbContext db, Guid projectId, Guid workItemId, string code = "PE-WI-UPD")
{
var pe = new PurchaseEvaluation
{
Id = Guid.NewGuid(),
Type = PurchaseEvaluationType.DuyetNcc,
Phase = PurchaseEvaluationPhase.DangSoanThao,
MaPhieu = code,
TenGoiThau = "Gói thầu gốc",
ProjectId = projectId,
WorkItemId = workItemId,
DrafterUserId = Guid.NewGuid(),
};
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
return pe;
}
[Fact]
public async Task UpdateDraft_WorkItemIdNull_KeepsExistingW1_NotNullified()
{
// ⭐ Trục QUAN TRỌNG NHẤT (bug-class S42): client cũ / inline-edit KHÔNG gửi
// WorkItemId → request.WorkItemId=null → handler GIỮ NGUYÊN w1, KHÔNG null-hoá.
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var w1 = await SeedWorkItemAsync(db, "WI-1");
var pe = await SeedDraftPeAsync(db, project.Id, w1.Id);
var handler = BuildUpdateHandler(db, new FakeCurrentUser());
var cmd = BuildUpdateCommand(pe.Id, workItemId: null);
await handler.Handle(cmd, CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.FindAsync(pe.Id);
reloaded!.WorkItemId.Should().Be(w1.Id, "null request KHÔNG được null-hoá hạng mục đã chọn");
reloaded.TenGoiThau.Should().Be("Gói thầu sửa", "field khác vẫn update bình thường");
}
[Fact]
public async Task UpdateDraft_WorkItemIdNewActiveW2_ChangesToW2()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var w1 = await SeedWorkItemAsync(db, "WI-1");
var w2 = await SeedWorkItemAsync(db, "WI-2");
var pe = await SeedDraftPeAsync(db, project.Id, w1.Id);
var handler = BuildUpdateHandler(db, new FakeCurrentUser());
var cmd = BuildUpdateCommand(pe.Id, workItemId: w2.Id);
await handler.Handle(cmd, CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.FindAsync(pe.Id);
reloaded!.WorkItemId.Should().Be(w2.Id, "đổi sang hạng mục W2 active hợp lệ");
}
[Fact]
public async Task UpdateDraft_WorkItemIdBogus_ThrowsConflict_AndKeepsW1()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var w1 = await SeedWorkItemAsync(db, "WI-1");
var pe = await SeedDraftPeAsync(db, project.Id, w1.Id);
var handler = BuildUpdateHandler(db, new FakeCurrentUser());
// Đổi sang Guid không tồn tại → guard (wiId != entity.WorkItemId) fail → Conflict.
var cmd = BuildUpdateCommand(pe.Id, workItemId: Guid.NewGuid());
var act = async () => await handler.Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*Hạng mục công việc không tồn tại hoặc ngưng hoạt động*");
// Side-effect assert: throw TRƯỚC SaveChanges → entity row giữ nguyên w1.
// Re-read AsNoTracking để tránh đọc instance đã bị mutate trong tracker
// (defensive — guard throw trước khi gán nên thực tế chưa mutate, nhưng
// AsNoTracking đảm bảo verify DB-truth không phải change-tracker state).
var reloaded = await db.PurchaseEvaluations.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == pe.Id);
reloaded!.WorkItemId.Should().Be(w1.Id, "bogus WorkItemId không được persist, giữ w1");
}
[Fact]
public async Task UpdateDraft_WorkItemIdInactive_ThrowsConflict()
{
// WorkItem tồn tại nhưng IsActive=false (vd master bị ngưng sau khi pick) → đổi
// sang nó cũng bị guard (w.IsActive) chặn.
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var w1 = await SeedWorkItemAsync(db, "WI-1");
var inactive = await SeedWorkItemAsync(db, "WI-INACTIVE-UPD", isActive: false);
var pe = await SeedDraftPeAsync(db, project.Id, w1.Id);
var handler = BuildUpdateHandler(db, new FakeCurrentUser());
var cmd = BuildUpdateCommand(pe.Id, workItemId: inactive.Id);
var act = async () => await handler.Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*Hạng mục công việc không tồn tại hoặc ngưng hoạt động*");
}
[Fact]
public async Task UpdateDraft_WorkItemIdSameAsExisting_NoGuardLookup_Succeeds()
{
// request.WorkItemId == entity.WorkItemId (w1) → guard (wiId != entity.WorkItemId)
// SKIP lookup → vẫn success kể cả nếu w1 sau đó bị inactive (no re-validate khi
// không đổi). Giữ w1.
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var w1 = await SeedWorkItemAsync(db, "WI-1");
var pe = await SeedDraftPeAsync(db, project.Id, w1.Id);
var handler = BuildUpdateHandler(db, new FakeCurrentUser());
var cmd = BuildUpdateCommand(pe.Id, workItemId: w1.Id);
await handler.Handle(cmd, CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.FindAsync(pe.Id);
reloaded!.WorkItemId.Should().Be(w1.Id);
}
}