[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
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:
@ -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).
|
||||
@ -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[] {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 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.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px]">Địa điểm</Label>
|
||||
<Input
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 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.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px]">Địa điểm</Label>
|
||||
<Input
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
];
|
||||
|
||||
@ -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...
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user