[CLAUDE] Workflow: Max 3 cấp/bước + N NV/cấp + sequential gating (V2 UAT iter 2)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m16s

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.
This commit is contained in:
pqhuy1987
2026-05-08 13:20:51 +07:00
parent 9712778929
commit f3bea3c616
2 changed files with 267 additions and 127 deletions

View File

@ -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<string, number> = {
@ -86,38 +93,23 @@ const DEFAULT_CODE_BY_TYPE: Record<number, string> = {
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 {
return d.steps.map(s => ({
name: s.name,
departmentId: s.departmentId,
levels,
}
})
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,23 +324,37 @@ function DefinitionCard({
</span>
)}
</div>
<ul className="mt-2 ml-9 space-y-1">
{/* Group by Order — 1 cấp có N NV */}
<ul className="mt-2 ml-9 space-y-1.5">
{s.levels.length === 0 ? (
<li className="text-[11px] italic text-slate-400">Chưa cấp duyệt</li>
) : (
s.levels.map(l => (
<li key={l.id} className="flex items-center gap-2 text-xs">
<span className="rounded-full bg-violet-100 px-2 py-0.5 font-mono text-[10px] font-bold text-violet-700">
C{l.order}
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<number, LevelDto[]>()).entries(),
)
.sort(([a], [b]) => a - b)
.map(([order, group]) => (
<li key={order} className="flex items-start gap-2 text-xs">
<span className="mt-0.5 shrink-0 rounded-full bg-violet-100 px-2 py-0.5 font-mono text-[10px] font-bold text-violet-700">
Cấp {order}
</span>
<span className="text-slate-700">{l.name || `Cấp ${l.order}`}</span>
<span className="text-slate-400"></span>
<div className="flex-1 space-y-0.5">
{group.map(l => (
<div key={l.id} className="flex items-center gap-1.5">
<span className="font-medium text-slate-800">
{l.approverUserName ?? l.approverUserId}
</span>
{l.approverEmail && (
<span className="text-[10px] text-slate-400">({l.approverEmail})</span>
)}
</div>
))}
</div>
</li>
))
)}
@ -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.`)
}
// Defensive: Select đã filter, nhưng double-check để tránh bypass UI.
const u = usersList.data?.find(x => x.id === l.approverUserId)
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.`)
}
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({
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
<div className="flex items-center justify-between">
<Label>
Các bước duyệt mỗi bước = 1 Phòng × <span className="font-bold text-violet-700">cố đnh 3 cấp NV</span> ({steps.length} bước)
Các bước duyệt mỗi bước = 1 Phòng × <span className="font-bold text-violet-700">tối đa {MAX_LEVELS_PER_STEP} cấp NV</span> ({steps.length} bước)
</Label>
<Button
type="button"
@ -532,10 +556,10 @@ function Designer({
<Select
value={s.departmentId ?? ''}
onChange={e => {
// Đổi Phòng → reset 3 approver vì NV cũ có thể không thuộc Phòng mới.
// Đổi Phòng → clear hết approvers vì NV cũ có thể không thuộc Phòng mới.
const newDeptId = e.target.value || null
setSteps(steps.map((x, i) => (i === idx
? { ...x, departmentId: newDeptId, levels: x.levels.map(l => ({ ...l, approverUserId: '' })) }
? { ...x, departmentId: newDeptId, levelEntries: [] }
: x)))
}}
required
@ -577,65 +601,148 @@ function Designer({
</div>
</div>
{/* Levels — LOCK 3 cấp/bước. NV filter theo Phòng đã chọn. */}
<div className="mt-2 ml-9 space-y-1.5 border-l-2 border-violet-200 pl-3">
<div className="flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-wide text-violet-700">
Cấp duyệt (cố đnh {FIXED_LEVELS_PER_STEP} cấp)
</span>
{/* 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. */}
<div className="mt-2 ml-9 space-y-2 border-l-2 border-violet-200 pl-3">
{!s.departmentId && (
<span className="rounded bg-amber-50 px-2 py-0.5 text-[10px] font-medium text-amber-700">
Chọn Phòng đ load NV
</span>
)}
<div className="rounded bg-amber-50 px-2 py-1 text-[11px] font-medium text-amber-700">
Chọn Phòng đ bắt đu cấu hình cấp duyệt.
</div>
{s.levels.map((l, li) => {
)}
{s.departmentId && usersForDept(usersList.data, s.departmentId).length === 0 && (
<div className="rounded border border-dashed border-amber-300 bg-amber-50 px-2 py-1.5 text-[11px] italic text-amber-700">
Phòng này chưa nhân viên. Vào /system/users gán NV vào Phòng trước, sau đó quay lại.
</div>
)}
{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 (
<div key={li} className="flex items-center gap-1.5">
<span className="rounded-full bg-violet-100 px-2 py-1 font-mono text-[10px] font-bold text-violet-700">
C{li + 1}
<div
key={order}
className={`rounded-md border p-2 ${
enabled
? 'border-violet-200 bg-white'
: 'border-slate-200 bg-slate-50/50 opacity-60'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="rounded-full bg-violet-100 px-2 py-0.5 font-mono text-[10px] font-bold text-violet-700">
Cấp {order}
</span>
<Input
value={l.name}
onChange={e =>
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"
/>
<Select
value={l.approverUserId}
onChange={e =>
<span className="text-[11px] text-slate-500">
{entries.length === 0
? (enabled ? 'Chưa có NV' : `Hoàn tất Cấp ${order - 1} trước`)
: `${entries.length} NV duyệt`}
</span>
</div>
<button
type="button"
disabled={addDisabled}
onClick={() => {
if (availableUsers.length === 0) return
const firstUser = availableUsers[0]
setSteps(steps.map((x, i) =>
i === idx
? { ...x, levels: x.levels.map((y, j) => (j === li ? { ...y, approverUserId: e.target.value } : y)) }
? { ...x, levelEntries: [...x.levelEntries, { order, approverUserId: firstUser.id }] }
: x,
))
}}
className="rounded bg-violet-50 px-2 py-1 text-[11px] font-medium text-violet-700 hover:bg-violet-100 disabled:cursor-not-allowed disabled:opacity-40"
title={
!enabled
? `Cấp ${order - 1} phải có ≥1 NV trước`
: availableUsers.length === 0
? 'Hết NV khả dụng (đã thêm hết hoặc Phòng không còn NV)'
: 'Thêm NV duyệt vào cấp này'
}
className="h-7 flex-1 text-xs"
disabled={!s.departmentId}
>
<option value="">
{s.departmentId ? '— Chọn NV duyệt —' : '— Chọn Phòng trước —'}
</option>
{filteredUsers.map(u => (
+ Thêm NV
</button>
</div>
{entries.length > 0 && (
<div className="mt-1.5 space-y-1">
{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 (
<div key={ei} className="flex items-center gap-1.5">
<span className="text-[10px] text-slate-400">#{ei + 1}</span>
<Select
value={entry.approverUserId}
onChange={e => {
const newId = e.target.value
setSteps(steps.map((x, i) =>
i === idx
? {
...x,
levelEntries: x.levelEntries.map((y, j) =>
j === globalIdx ? { ...y, approverUserId: newId } : y,
),
}
: x,
))
}}
className="h-7 flex-1 text-xs"
>
{dropdownUsers.map(u => (
<option key={u.id} value={u.id}>
{u.fullName} ({u.email})
</option>
))}
</Select>
<button
type="button"
onClick={() => {
// Chặn xóa NV cuối Cấp 1 nếu Cấp 2/3 còn entries (gating)
if (entries.length === 1) {
const nextLevelHasEntries =
order < MAX_LEVELS_PER_STEP &&
entriesAtLevel(s, (order + 1) as LevelOrder).length > 0
if (nextLevelHasEntries) {
toast.error(
`Hãy xóa hết NV ở Cấp ${order + 1} trước khi rỗng Cấp ${order}.`,
)
return
}
}
setSteps(steps.map((x, i) =>
i === idx
? { ...x, levelEntries: x.levelEntries.filter((_, j) => j !== globalIdx) }
: x,
))
}}
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-red-50 hover:text-red-600"
title="Xóa NV khỏi cấp này"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
)
})}
{s.departmentId && usersForDept(usersList.data, s.departmentId).length === 0 && (
<div className="rounded border border-dashed border-amber-300 bg-amber-50 px-2 py-1.5 text-[11px] italic text-amber-700">
Phòng này chưa 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.
</div>
)}
</div>
)
})}
</div>
</div>
))}
</div>
@ -643,9 +750,9 @@ function Designer({
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800">
<GitBranch className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<div>
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
(1 / 2 / 3 cấp đu OK). Trong cùng 1 Cấp thể nhiều NV chỉ cần 1 NV duyệt 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.
</div>
</div>
</form>

View File

@ -180,6 +180,13 @@ public record CreateAwDefinitionCommand(
public class CreateAwDefinitionCommandValidator : AbstractValidator<CreateAwDefinitionCommand>
{
// 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<CreateAwDefi
.WithMessage("Mỗi bước phải có ít nhất 1 cấp duyệt.");
step.RuleForEach(s => 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<CreateAwLevelInput> 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<CreateAwLevelInput> levels)
{
return levels
.GroupBy(l => new { l.Order, l.ApproverUserId })
.All(g => g.Count() == 1);
}
}
public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)