From f3bea3c6165841d1d8118f9fe6c9b2275e242a7c Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Fri, 8 May 2026 13:20:51 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20Workflow:=20Max=203=20c=E1=BA=A5p/b?= =?UTF-8?q?=C6=B0=E1=BB=9Bc=20+=20N=20NV/c=E1=BA=A5p=20+=20sequential=20ga?= =?UTF-8?q?ting=20(V2=20UAT=20iter=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: "tối đa 3 cấp (không có cấp 4)" — không phải bắt buộc 3. Mỗi cấp = N NV (add bao nhiêu cũng được). Quy trình chạy theo số cấp thật sự cấu hình (1/2/3). C2 chưa thao tác được khi C1 chưa có NV. Convention DB: nhiều `ApprovalWorkflowLevel` row cùng Order = same Cấp, mỗi row = 1 NV. Service iterate group by Order; trong cùng cấp = OR-of-N (1 NV duyệt → cấp pass). BE — Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs: - Validator strict: - Order ∈ {1, 2, 3} (`MaxLevelsPerStep`) - Sequential gating: HaveSequentialOrders → 1 / 1+2 / 1+2+3, KHÔNG cho 2 (thiếu 1) hoặc 1+3 (thiếu 2) - HaveNoDuplicateApproverInSameLevel: 1 NV không thêm 2 lần cùng cấp - Schema KHÔNG đổi (giữ ApprovalWorkflowLevel.ApproverUserId 1-1). - Handler không đổi — auto handle multiple rows cùng Order. FE — ApprovalWorkflowsV2Page.tsx rewrite Levels section: - Type EditStep.levels → levelEntries: { order: 1|2|3; approverUserId }[] flat list (group by order trong render). - 3 SECTION CỐ ĐỊNH C1/C2/C3 trong Designer: - Mỗi section: header "Cấp N" + count NV + nút "+ Thêm NV" - List rows mỗi NV với Select dropdown filtered theo Phòng + Trash - C2 disabled (opacity-60) khi C1 empty. C3 disabled khi C2 empty. - Tooltip "+ Thêm NV": "Cấp k-1 phải có ≥1 NV trước" - Add NV: dropdown chỉ NV thuộc Phòng + chưa được thêm cùng cấp (no duplicate same level). - Xóa NV: chặn xóa NV cuối Cấp k nếu Cấp k+1 còn entries (toast error "Hãy xóa hết NV ở Cấp k+1 trước khi rỗng Cấp k"). - Đổi Phòng → clear toàn bộ levelEntries (NV cũ không thuộc Phòng mới). - DefinitionCard read-only: group s.levels by Order → render mỗi cấp là 1 row với badge "Cấp N" + list NV bên dưới. - Save validate: Phòng required + Cấp 1 ≥1 NV + sequential + NV thuộc đúng Phòng (defensive double-check). Verify: dotnet build BE OK · 77 test pass · npm build fe-admin OK. Logic Service PE/Contract chưa wire schema mới — vẫn pin Mig 21 legacy. --- .../pages/system/ApprovalWorkflowsV2Page.tsx | 357 ++++++++++++------ .../ApprovalWorkflowV2AdminFeatures.cs | 37 +- 2 files changed, 267 insertions(+), 127 deletions(-) diff --git a/fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx b/fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx index aacff6d..4db6a7d 100644 --- a/fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx +++ b/fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx @@ -1,16 +1,21 @@ // Quy trình duyệt MỚI (Mig 22 — Session 17, 2026-05-08). // Schema riêng UAT trước khi drop legacy. Cấu trúc: // Quy trình (Mã + Tên + ApplicableType) -// Bước 1 — Phòng A (FIXED 3 cấp) -// Cấp 1 — NV X thuộc Phòng A -// Cấp 2 — NV Y thuộc Phòng A -// Cấp 3 — NV Z thuộc Phòng A +// Bước 1 — Phòng A +// Cấp 1 (N NV duyệt) +// Cấp 2 (N NV duyệt) +// Cấp 3 (N NV duyệt) // -// Iteration 2 (UAT feedback 2026-05-08): -// - LOCK CỨNG 3 cấp/bước (không Add/Remove/Move). Logic backend giữ nguyên -// iterate Levels OrderBy Order — chỉ giới hạn UI. -// - Cùng 1 phòng có thể chọn 3 NV cùng cấp (không bắt khác cấp). -// - Select NV CHỈ filter theo Phòng đã chọn (đổi Phòng → reset 3 approver). +// Iteration 3 (UAT feedback 2026-05-08): +// - TỐI ĐA 3 cấp/bước (không có cấp 4). 1/2/3 cấp đều OK — quy trình +// chạy theo số cấp thật sự có. +// - MỖI CẤP CÓ N NV: convention multiple Level rows cùng Order = same Cấp. +// BE iterate group by Order; trong cùng cấp = OR-of-N approvers. +// - 3 section cố định C1/C2/C3 trong UI. Mỗi section có nút "+ Thêm NV" +// + Trash xóa từng NV. +// - Sequential gating: C2 disabled khi C1 chưa có NV. C3 disabled khi C2 +// chưa có NV. Chặn xóa NV cuối C1 khi C2/C3 còn entries. +// - Select NV CHỈ filter theo Phòng đã chọn (đổi Phòng → clear approvers). import { useMemo, useState, type FormEvent } from 'react' import { useParams } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' @@ -65,14 +70,16 @@ type TypeSummaryDto = { history: DefinitionDto[] } -type EditLevel = { name: string; approverUserId: string } -type EditStep = { name: string; departmentId: string | null; levels: EditLevel[] } +type LevelOrder = 1 | 2 | 3 +type EditLevelEntry = { order: LevelOrder; approverUserId: string } +type EditStep = { name: string; departmentId: string | null; levelEntries: EditLevelEntry[] } type ApproverUser = { id: string; fullName: string; email: string; departmentId: string | null } -// Lock cứng 3 cấp/bước per UAT 2026-05-08. -// Logic BE iterate Levels OrderBy Order vẫn cho phép N cấp — đây chỉ là giới hạn UI. -const FIXED_LEVELS_PER_STEP = 3 +// Tối đa 3 cấp/bước per UAT iter 3 (2026-05-08). Quy trình chạy theo số cấp +// thật sự có (1 / 2 / 3). Mỗi cấp có N NV (multiple Level rows cùng Order). +const MAX_LEVELS_PER_STEP = 3 +const LEVEL_ORDERS: LevelOrder[] = [1, 2, 3] // FE typeCode → BE int (giống MenuKeys ApplicableType) const TYPE_CODE_TO_INT: Record = { @@ -86,38 +93,23 @@ const DEFAULT_CODE_BY_TYPE: Record = { 3: 'QT-HD-V2-001', } -function makeEmptyLevels(): EditLevel[] { - return Array.from({ length: FIXED_LEVELS_PER_STEP }, (_, i) => ({ - name: `Cấp ${i + 1}`, - approverUserId: '', - })) -} - function makeEmptyStep(stepNo: number, deptId: string | null = null): EditStep { return { name: `Phòng ${stepNo}`, departmentId: deptId, - levels: makeEmptyLevels(), + levelEntries: [], } } -// Pad/truncate về đúng FIXED_LEVELS_PER_STEP cấp khi clone version cũ -// (phòng khi DB có data legacy >3 hoặc <3 levels). +// Clone existing definition: filter Order ∈ {1,2,3}, drop entries vượt giới hạn. function copyFromDefinition(d: DefinitionDto): EditStep[] { - return d.steps.map(s => { - const levels = s.levels.slice(0, FIXED_LEVELS_PER_STEP).map((l, i) => ({ - name: l.name ?? `Cấp ${i + 1}`, - approverUserId: l.approverUserId, - })) - while (levels.length < FIXED_LEVELS_PER_STEP) { - levels.push({ name: `Cấp ${levels.length + 1}`, approverUserId: '' }) - } - return { - name: s.name, - departmentId: s.departmentId, - levels, - } - }) + return d.steps.map(s => ({ + name: s.name, + departmentId: s.departmentId, + levelEntries: s.levels + .filter(l => l.order >= 1 && l.order <= MAX_LEVELS_PER_STEP) + .map(l => ({ order: l.order as LevelOrder, approverUserId: l.approverUserId })), + })) } // Filter NV theo Phòng. Nếu Phòng = null → fallback all (chưa chọn phòng). @@ -127,6 +119,16 @@ function usersForDept(all: ApproverUser[] | undefined, deptId: string | null): A return all.filter(u => u.departmentId === deptId) } +function entriesAtLevel(step: EditStep, order: LevelOrder): EditLevelEntry[] { + return step.levelEntries.filter(e => e.order === order) +} + +// Cấp k được phép thao tác khi Cấp k-1 có ≥1 NV (gating sequential). +function isLevelEnabled(step: EditStep, order: LevelOrder): boolean { + if (order === 1) return step.departmentId !== null + return entriesAtLevel(step, (order - 1) as LevelOrder).length > 0 +} + export function ApprovalWorkflowsV2Page() { const qc = useQueryClient() const { typeCode } = useParams<{ typeCode?: string }>() @@ -322,25 +324,39 @@ function DefinitionCard({ )} -
    + {/* Group by Order — 1 cấp có N NV */} +
      {s.levels.length === 0 ? (
    • Chưa có cấp duyệt
    • ) : ( - s.levels.map(l => ( -
    • - - C{l.order} - - {l.name || `Cấp ${l.order}`} - - - {l.approverUserName ?? l.approverUserId} - - {l.approverEmail && ( - ({l.approverEmail}) - )} -
    • - )) + Array.from( + s.levels.reduce((map, l) => { + const arr = map.get(l.order) ?? [] + arr.push(l) + map.set(l.order, arr) + return map + }, new Map()).entries(), + ) + .sort(([a], [b]) => a - b) + .map(([order, group]) => ( +
    • + + Cấp {order} + +
      + {group.map(l => ( +
      + + {l.approverUserName ?? l.approverUserId} + + {l.approverEmail && ( + ({l.approverEmail}) + )} +
      + ))} +
      +
    • + )) )}
    @@ -404,20 +420,26 @@ function Designer({ const save = useMutation({ mutationFn: async () => { - // Validate: mỗi Bước phải có Phòng + 3 cấp đầy đủ + NV thuộc đúng Phòng. + // Validate per Bước: + // - Phòng required + // - Cấp 1 phải có ≥1 NV (sequential gating đảm bảo nếu C1 empty thì C2/C3 cũng empty) + // - All approver thuộc đúng Phòng (defensive double-check) for (const s of steps) { if (!s.departmentId) { throw new Error(`Bước "${s.name}" chưa chọn Phòng.`) } - if (s.levels.length !== FIXED_LEVELS_PER_STEP) { - throw new Error(`Bước "${s.name}" phải có đúng ${FIXED_LEVELS_PER_STEP} cấp.`) + if (entriesAtLevel(s, 1).length === 0) { + throw new Error(`Bước "${s.name}" chưa có NV ở Cấp 1.`) } - for (const l of s.levels) { - if (!l.approverUserId) { - throw new Error(`Bước "${s.name}" còn cấp chưa chọn nhân viên duyệt.`) + // Sequential gating đã enforce ở UI nhưng kiểm tra lại + if (entriesAtLevel(s, 2).length === 0 && entriesAtLevel(s, 3).length > 0) { + throw new Error(`Bước "${s.name}": Cấp 3 chỉ thao tác được khi Cấp 2 có NV.`) + } + for (const e of s.levelEntries) { + if (!e.approverUserId) { + throw new Error(`Bước "${s.name}": có dòng cấp chưa chọn NV.`) } - // Defensive: Select đã filter, nhưng double-check để tránh bypass UI. - const u = usersList.data?.find(x => x.id === l.approverUserId) + const u = usersList.data?.find(x => x.id === e.approverUserId) if (u && u.departmentId !== s.departmentId) { throw new Error(`Bước "${s.name}": NV "${u.fullName}" không thuộc Phòng đã chọn.`) } @@ -432,10 +454,12 @@ function Designer({ order: i + 1, name: s.name, departmentId: s.departmentId, - levels: s.levels.map((l, j) => ({ - order: j + 1, - name: l.name || null, - approverUserId: l.approverUserId, + // Mỗi entry → 1 Level row. Multiple rows cùng Order = same Cấp với + // N approvers (BE iterate group by Order). + levels: s.levelEntries.map(e => ({ + order: e.order, + name: `Cấp ${e.order}`, + approverUserId: e.approverUserId, })), })), }) @@ -499,7 +523,7 @@ function Designer({
    - {/* Levels — LOCK 3 cấp/bước. NV filter theo Phòng đã chọn. */} -
    -
    - - Cấp duyệt (cố định {FIXED_LEVELS_PER_STEP} cấp) - - {!s.departmentId && ( - - ⚠ Chọn Phòng để load NV - - )} -
    - {s.levels.map((l, li) => { + {/* Levels — 3 section cố định C1/C2/C3. Mỗi cấp có N NV. + Sequential gating: Cấp k chỉ active khi Cấp k-1 có ≥1 NV. */} +
    + {!s.departmentId && ( +
    + ⚠ Chọn Phòng để bắt đầu cấu hình cấp duyệt. +
    + )} + {s.departmentId && usersForDept(usersList.data, s.departmentId).length === 0 && ( +
    + ⚠ Phòng này chưa có nhân viên. Vào /system/users gán NV vào Phòng trước, sau đó quay lại. +
    + )} + + {LEVEL_ORDERS.map(order => { + const entries = entriesAtLevel(s, order) + const enabled = isLevelEnabled(s, order) const filteredUsers = usersForDept(usersList.data, s.departmentId) + // NV còn khả dụng (chưa được dùng ở cấp này — cùng cấp không trùng NV) + const usedInThisLevel = new Set(entries.map(e => e.approverUserId)) + const availableUsers = filteredUsers.filter(u => !usedInThisLevel.has(u.id)) + + // Disable Add: Phòng chưa chọn / cấp trước chưa có NV / hết NV available + const addDisabled = !enabled || availableUsers.length === 0 + return ( -
    - - C{li + 1} - - - setSteps(steps.map((x, i) => - i === idx ? { ...x, levels: x.levels.map((y, j) => (j === li ? { ...y, name: e.target.value } : y)) } : x, - )) - } - placeholder={`Cấp ${li + 1}`} - className="h-7 max-w-[120px] text-xs" - /> - +
    +
    +
    + + Cấp {order} + + + {entries.length === 0 + ? (enabled ? 'Chưa có NV' : `Hoàn tất Cấp ${order - 1} trước`) + : `${entries.length} NV duyệt`} + +
    + +
    + + {entries.length > 0 && ( +
    + {entries.map((entry, ei) => { + // Tính index trong levelEntries gốc để update + const globalIdx = s.levelEntries.findIndex( + x => x === entry, + ) + // NV available cho dropdown: filtered + chính NV đang chọn (giữ option hiện tại) + const dropdownUsers = filteredUsers.filter( + u => u.id === entry.approverUserId || !usedInThisLevel.has(u.id), + ) + return ( +
    + #{ei + 1} + + +
    + ) + })} +
    + )}
    ) })} - {s.departmentId && usersForDept(usersList.data, s.departmentId).length === 0 && ( -
    - ⚠ Phòng này chưa có nhân viên. Vào /system/users gán NV vào Phòng trước, sau đó quay lại chọn duyệt. -
    - )}
    ))} @@ -643,9 +750,9 @@ function Designer({
    - Quy tắc: mỗi Bước cố định {FIXED_LEVELS_PER_STEP} Cấp duyệt thuộc cùng Phòng (Cấp 1 → Cấp 2 → Cấp 3 tuần tự, - hoặc cùng cấp tùy tên gọi — logic vẫn iterate Order asc). Hết Cấp ở Bước hiện tại → sang Bước kế. - Mỗi Cấp = 1 NV cụ thể trong Phòng (KHÔNG OR-of-many). Hết tất cả Bước = Đã duyệt. + Quy tắc: mỗi Bước có 1 Phòng + tối đa {MAX_LEVELS_PER_STEP} Cấp. Quy trình chạy theo số cấp thật sự có + (1 / 2 / 3 cấp đều OK). Trong cùng 1 Cấp có thể có nhiều NV — chỉ cần 1 NV duyệt là cấp đó pass + (OR-of-N). Tuần tự: Cấp 1 → Cấp 2 → Cấp 3 → Bước kế. Hết tất cả Bước = Đã duyệt.
    diff --git a/src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs b/src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs index 6a7314f..82f885b 100644 --- a/src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs +++ b/src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs @@ -180,6 +180,13 @@ public record CreateAwDefinitionCommand( public class CreateAwDefinitionCommandValidator : AbstractValidator { + // Convention Mig 22 + UAT iter 2 (2026-05-08): + // Tối đa 3 Cấp/Bước (Order ∈ {1,2,3}). Mỗi Cấp có N approver (multiple + // Level rows cùng Order = same Cấp, mỗi row = 1 NV). Sequential gating: + // có Cấp k yêu cầu Cấp k-1 đã có ≥1 NV. Không duplicate (Order, UserId) + // cùng 1 Bước. + public const int MaxLevelsPerStep = 3; + public CreateAwDefinitionCommandValidator() { RuleFor(x => x.ApplicableType).Must(t => Enum.IsDefined(typeof(ApprovalWorkflowApplicableType), t)) @@ -199,13 +206,39 @@ public class CreateAwDefinitionCommandValidator : AbstractValidator s.Levels).ChildRules(level => { - level.RuleFor(l => l.Order).GreaterThanOrEqualTo(1); + level.RuleFor(l => l.Order).InclusiveBetween(1, MaxLevelsPerStep) + .WithMessage($"Cấp duyệt chỉ trong khoảng 1..{MaxLevelsPerStep}."); level.RuleFor(l => l.Name).MaximumLength(200); level.RuleFor(l => l.ApproverUserId).NotEmpty() - .WithMessage("Cấp duyệt phải chỉ định 1 nhân viên cụ thể."); + .WithMessage("Mỗi dòng cấp phải chỉ định 1 NV duyệt."); }); + // Sequential gating + no-duplicate (Order, UserId). + step.RuleFor(s => s.Levels).Must(HaveSequentialOrders) + .WithMessage($"Cấp duyệt phải tuần tự từ 1 (có Cấp k cần Cấp k-1). Tối đa {MaxLevelsPerStep} cấp."); + step.RuleFor(s => s.Levels).Must(HaveNoDuplicateApproverInSameLevel) + .WithMessage("Một NV không được duyệt hai lần trong cùng một Cấp."); }); } + + // Cho phép 1 / 1+2 / 1+2+3 — KHÔNG cho 2 (thiếu 1) hoặc 1+3 (thiếu 2). + private static bool HaveSequentialOrders(List levels) + { + if (levels.Count == 0) return false; + var orders = levels.Select(l => l.Order).Distinct().OrderBy(o => o).ToList(); + if (orders.Count == 0 || orders.Count > MaxLevelsPerStep) return false; + for (int i = 0; i < orders.Count; i++) + { + if (orders[i] != i + 1) return false; + } + return true; + } + + private static bool HaveNoDuplicateApproverInSameLevel(List levels) + { + return levels + .GroupBy(l => new { l.Order, l.ApproverUserId }) + .All(g => g.Count() == 1); + } } public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)