[CLAUDE] Domain+Infra: User-kind approver runtime guard + Warning 20% SLA
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m41s

## User-kind approver guard

Trước: WorkflowDefinition Designer cho admin pick User cụ thể vào step
approver, nhưng runtime guard bỏ qua (User-kind treat như DeptManager
fallback per skill doc).

Bây giờ: enable đầy đủ. WorkflowPolicy + UserTransitions parallel dict
(default null cho hardcoded Standard/SkipCcm, populated qua
FromDefinition khi WorkflowStepApprover Kind=User).

IsTransitionAllowed signature update: (from, to, actorRoles, actorUserId?)
- Check Role first (existing behavior)
- Fallback User-kind: actorUserId.ToString() có trong UserTransitions[(from,to)]?

ContractWorkflowService.TransitionAsync dùng IsTransitionAllowed thay
inline check. Error message thêm "{N} user explicit" nếu policy có
User-kind approvers cho transition đó.

FromDefinition cũng update: nếu step CHỈ có User-kind (không Role),
không fallback DeptManager nữa — guard sẽ check user-level. Chỉ
fallback DeptManager nếu step thiếu cả 2.

## Warning 20% SLA

SlaExpiryJob.ProcessWarningsAsync mới — chạy trước ProcessAsync
(auto-approve quá hạn):
- Pull Contracts WHERE !SlaWarningSent && SlaDeadline > now &&
  Phase NOT IN (DaPhatHanh, TuChoi, DangDongDau)
- Per phase, threshold = 20% × default SLA (vd Soạn thảo 7 ngày → 33.6h
  remaining trigger warning; In ký 1 ngày → 4.8h)
- Compute remaining = SlaDeadline - now; nếu remaining <= threshold
  + còn slot → notify Drafter via INotificationService
- Set SlaWarningSent = true để chỉ warning 1 lần per phase (reset trong
  TransitionAsync khi chuyển phase mới)
- NotificationType.SlaWarning (đã có trong enum) + title icon ⚠

## Build

dotnet build BE pass (0 error)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-23 15:11:34 +07:00
parent 8bc9565127
commit 4edcd588d8
3 changed files with 134 additions and 14 deletions

View File

@ -17,14 +17,28 @@ public sealed record WorkflowPolicy(
string Description,
IReadOnlyDictionary<(ContractPhase From, ContractPhase To), string[]> Transitions,
IReadOnlyDictionary<ContractPhase, TimeSpan?> PhaseSla,
IReadOnlyList<ContractPhase> ActivePhases)
IReadOnlyList<ContractPhase> ActivePhases,
// User-kind approvers per transition — ngoài Role check, nếu actor's UserId
// có trong list này → cũng pass guard. Default empty cho 2 policy hardcoded
// (Standard/SkipCcm), populated qua FromDefinition khi WorkflowStepApprover
// có Kind=User.
IReadOnlyDictionary<(ContractPhase From, ContractPhase To), string[]>? UserTransitions = null)
{
public bool HasPhase(ContractPhase phase) => ActivePhases.Contains(phase);
public bool IsTransitionAllowed(ContractPhase from, ContractPhase to, IReadOnlyList<string> actorRoles)
public bool IsTransitionAllowed(
ContractPhase from, ContractPhase to,
IReadOnlyList<string> actorRoles, Guid? actorUserId = null)
{
if (!Transitions.TryGetValue((from, to), out var roles)) return false;
return actorRoles.Any(r => roles.Contains(r));
if (actorRoles.Any(r => roles.Contains(r))) return true;
// User-kind fallback: nếu actor user ID match explicit user approver
if (actorUserId is null) return false;
if (UserTransitions is null) return false;
if (!UserTransitions.TryGetValue((from, to), out var userIds)) return false;
var userIdStr = actorUserId.Value.ToString();
return userIds.Contains(userIdStr);
}
public IReadOnlyList<ContractPhase> NextPhasesFrom(ContractPhase from) =>
@ -155,15 +169,19 @@ public static class WorkflowPolicyRegistry
}
// Build a policy from a persisted WorkflowDefinition (admin-authored).
// Transitions are derived from ordered steps: prev.Phase → step.Phase,
// allowed roles = role-kind approvers' names. Reject-back-to-Drafter +
// TuChoi paths are auto-wired so the guard doesn't block common flows.
// User-kind approvers are currently treated as role-approvers with
// DeptManager fallback — user-level targeting comes in iteration 2.
// Transitions derived từ ordered steps: prev.Phase → step.Phase,
// allowed roles = Role-kind approvers' names. Reject-back-to-Drafter +
// TuChoi paths auto-wired để guard không block common flows.
//
// User-kind approvers (iter 2): populate UserTransitions parallel dict —
// ContractWorkflowService check Role first, fallback User-kind nếu role
// không match. Cho phép admin gán cụ thể "chỉ user X được duyệt" ngoài
// role-based.
public static WorkflowPolicy FromDefinition(WorkflowDefinition def)
{
var steps = def.Steps.OrderBy(s => s.Order).ToList();
var transitions = new Dictionary<(ContractPhase From, ContractPhase To), string[]>();
var userTransitions = new Dictionary<(ContractPhase From, ContractPhase To), string[]>();
var sla = new Dictionary<ContractPhase, TimeSpan?>();
var activePhases = new List<ContractPhase>();
@ -172,22 +190,40 @@ public static class WorkflowPolicyRegistry
{
activePhases.Add(s.Phase);
sla[s.Phase] = s.SlaDays is int d ? TimeSpan.FromDays(d) : null;
var roles = s.Approvers
.Where(a => a.Kind == WorkflowApproverKind.Role)
.Select(a => a.AssignmentValue)
.Distinct()
.ToArray();
if (roles.Length == 0) roles = [AppRoles.DeptManager];
// Nếu step không có Role nào nhưng CÓ User-kind, không fallback
// DeptManager nữa — leave roles empty, guard sẽ check user-level.
// Chỉ fallback DeptManager nếu step thiếu cả 2 (cấu hình broken).
var hasUserKind = s.Approvers.Any(a => a.Kind == WorkflowApproverKind.User);
if (roles.Length == 0 && !hasUserKind) roles = [AppRoles.DeptManager];
var userIds = s.Approvers
.Where(a => a.Kind == WorkflowApproverKind.User)
.Select(a => a.AssignmentValue)
.Distinct()
.ToArray();
if (prev is not null)
{
transitions[(prev.Value, s.Phase)] = roles;
// Reject path back to Drafter (common pattern from QT docx)
if (userIds.Length > 0) userTransitions[(prev.Value, s.Phase)] = userIds;
// Reject path back to Drafter (common pattern QT docx)
if (prev.Value != ContractPhase.DangSoanThao && s.Phase != ContractPhase.DangSoanThao)
{
transitions.TryAdd((s.Phase, ContractPhase.DangSoanThao), roles);
if (userIds.Length > 0)
userTransitions.TryAdd((s.Phase, ContractPhase.DangSoanThao), userIds);
}
}
prev = s.Phase;
}
// First step can reject to TuChoi
// First step có thể reject to TuChoi
if (steps.Count > 0)
transitions.TryAdd((steps[0].Phase, ContractPhase.TuChoi),
[AppRoles.Drafter, AppRoles.DeptManager]);
@ -199,6 +235,7 @@ public static class WorkflowPolicyRegistry
Description: def.Description ?? def.Name,
Transitions: transitions,
PhaseSla: sla,
ActivePhases: activePhases);
ActivePhases: activePhases,
UserTransitions: userTransitions.Count > 0 ? userTransitions : null);
}
}