[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

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