[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

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