[CLAUDE] Domain+Infra: User-kind approver runtime guard + Warning 20% SLA
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m41s
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:
@ -17,14 +17,28 @@ public sealed record WorkflowPolicy(
|
|||||||
string Description,
|
string Description,
|
||||||
IReadOnlyDictionary<(ContractPhase From, ContractPhase To), string[]> Transitions,
|
IReadOnlyDictionary<(ContractPhase From, ContractPhase To), string[]> Transitions,
|
||||||
IReadOnlyDictionary<ContractPhase, TimeSpan?> PhaseSla,
|
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 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;
|
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) =>
|
public IReadOnlyList<ContractPhase> NextPhasesFrom(ContractPhase from) =>
|
||||||
@ -155,15 +169,19 @@ public static class WorkflowPolicyRegistry
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build a policy from a persisted WorkflowDefinition (admin-authored).
|
// Build a policy from a persisted WorkflowDefinition (admin-authored).
|
||||||
// Transitions are derived from ordered steps: prev.Phase → step.Phase,
|
// Transitions derived từ ordered steps: prev.Phase → step.Phase,
|
||||||
// allowed roles = role-kind approvers' names. Reject-back-to-Drafter +
|
// allowed roles = Role-kind approvers' names. Reject-back-to-Drafter +
|
||||||
// TuChoi paths are auto-wired so the guard doesn't block common flows.
|
// TuChoi paths auto-wired để guard không block common flows.
|
||||||
// User-kind approvers are currently treated as role-approvers with
|
//
|
||||||
// DeptManager fallback — user-level targeting comes in iteration 2.
|
// 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)
|
public static WorkflowPolicy FromDefinition(WorkflowDefinition def)
|
||||||
{
|
{
|
||||||
var steps = def.Steps.OrderBy(s => s.Order).ToList();
|
var steps = def.Steps.OrderBy(s => s.Order).ToList();
|
||||||
var transitions = new Dictionary<(ContractPhase From, ContractPhase To), string[]>();
|
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 sla = new Dictionary<ContractPhase, TimeSpan?>();
|
||||||
var activePhases = new List<ContractPhase>();
|
var activePhases = new List<ContractPhase>();
|
||||||
|
|
||||||
@ -172,22 +190,40 @@ public static class WorkflowPolicyRegistry
|
|||||||
{
|
{
|
||||||
activePhases.Add(s.Phase);
|
activePhases.Add(s.Phase);
|
||||||
sla[s.Phase] = s.SlaDays is int d ? TimeSpan.FromDays(d) : null;
|
sla[s.Phase] = s.SlaDays is int d ? TimeSpan.FromDays(d) : null;
|
||||||
|
|
||||||
var roles = s.Approvers
|
var roles = s.Approvers
|
||||||
.Where(a => a.Kind == WorkflowApproverKind.Role)
|
.Where(a => a.Kind == WorkflowApproverKind.Role)
|
||||||
.Select(a => a.AssignmentValue)
|
.Select(a => a.AssignmentValue)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToArray();
|
.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)
|
if (prev is not null)
|
||||||
{
|
{
|
||||||
transitions[(prev.Value, s.Phase)] = roles;
|
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)
|
if (prev.Value != ContractPhase.DangSoanThao && s.Phase != ContractPhase.DangSoanThao)
|
||||||
|
{
|
||||||
transitions.TryAdd((s.Phase, ContractPhase.DangSoanThao), roles);
|
transitions.TryAdd((s.Phase, ContractPhase.DangSoanThao), roles);
|
||||||
|
if (userIds.Length > 0)
|
||||||
|
userTransitions.TryAdd((s.Phase, ContractPhase.DangSoanThao), userIds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
prev = s.Phase;
|
prev = s.Phase;
|
||||||
}
|
}
|
||||||
// First step can reject to TuChoi
|
// First step có thể reject to TuChoi
|
||||||
if (steps.Count > 0)
|
if (steps.Count > 0)
|
||||||
transitions.TryAdd((steps[0].Phase, ContractPhase.TuChoi),
|
transitions.TryAdd((steps[0].Phase, ContractPhase.TuChoi),
|
||||||
[AppRoles.Drafter, AppRoles.DeptManager]);
|
[AppRoles.Drafter, AppRoles.DeptManager]);
|
||||||
@ -199,6 +235,7 @@ public static class WorkflowPolicyRegistry
|
|||||||
Description: def.Description ?? def.Name,
|
Description: def.Description ?? def.Name,
|
||||||
Transitions: transitions,
|
Transitions: transitions,
|
||||||
PhaseSla: sla,
|
PhaseSla: sla,
|
||||||
ActivePhases: activePhases);
|
ActivePhases: activePhases,
|
||||||
|
UserTransitions: userTransitions.Count > 0 ? userTransitions : null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,9 @@ using Microsoft.Extensions.Hosting;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using SolutionErp.Application.Common.Interfaces;
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
using SolutionErp.Application.Contracts.Services;
|
using SolutionErp.Application.Contracts.Services;
|
||||||
|
using SolutionErp.Application.Notifications;
|
||||||
using SolutionErp.Domain.Contracts;
|
using SolutionErp.Domain.Contracts;
|
||||||
|
using SolutionErp.Domain.Notifications;
|
||||||
|
|
||||||
namespace SolutionErp.Infrastructure.HostedServices;
|
namespace SolutionErp.Infrastructure.HostedServices;
|
||||||
|
|
||||||
@ -60,8 +62,17 @@ public class SlaExpiryJob : BackgroundService
|
|||||||
var db = scope.ServiceProvider.GetRequiredService<IApplicationDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<IApplicationDbContext>();
|
||||||
var workflow = scope.ServiceProvider.GetRequiredService<IContractWorkflowService>();
|
var workflow = scope.ServiceProvider.GetRequiredService<IContractWorkflowService>();
|
||||||
var dateTime = scope.ServiceProvider.GetRequiredService<IDateTime>();
|
var dateTime = scope.ServiceProvider.GetRequiredService<IDateTime>();
|
||||||
|
var notifications = scope.ServiceProvider.GetRequiredService<INotificationService>();
|
||||||
|
|
||||||
var now = dateTime.UtcNow;
|
var now = dateTime.UtcNow;
|
||||||
|
|
||||||
|
// Step 1: Warning 80% SLA — gửi notification 1 lần (track SlaWarningSent
|
||||||
|
// flag). Tính: nếu (now - createdLastTransition) >= 80% (SlaDeadline -
|
||||||
|
// createdLastTransition). Approximation đơn giản: warning khi SlaDeadline
|
||||||
|
// - now <= 20% × default SLA. Chính xác hơn cần track phaseStartAt, để
|
||||||
|
// iter sau.
|
||||||
|
await ProcessWarningsAsync(db, notifications, now, ct);
|
||||||
|
|
||||||
var expired = await db.Contracts
|
var expired = await db.Contracts
|
||||||
.Where(c => c.SlaDeadline != null && c.SlaDeadline < now)
|
.Where(c => c.SlaDeadline != null && c.SlaDeadline < now)
|
||||||
.Where(c => c.Phase != ContractPhase.DaPhatHanh && c.Phase != ContractPhase.TuChoi)
|
.Where(c => c.Phase != ContractPhase.DaPhatHanh && c.Phase != ContractPhase.TuChoi)
|
||||||
@ -103,4 +114,66 @@ public class SlaExpiryJob : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Warning notification khi HĐ còn ≤20% SLA của phase hiện tại. Track
|
||||||
|
// SlaWarningSent flag để chỉ gửi 1 lần per phase (reset khi transition).
|
||||||
|
// Drafter + role giữ phase đều nhận notification (drafter để theo dõi,
|
||||||
|
// role để remind action).
|
||||||
|
private async Task ProcessWarningsAsync(
|
||||||
|
IApplicationDbContext db, INotificationService notifications,
|
||||||
|
DateTime now, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Default SLA durations (sync với WorkflowPolicies.DefaultSla)
|
||||||
|
var defaultSla = new Dictionary<ContractPhase, TimeSpan>
|
||||||
|
{
|
||||||
|
[ContractPhase.DangSoanThao] = TimeSpan.FromDays(7),
|
||||||
|
[ContractPhase.DangGopY] = TimeSpan.FromDays(7),
|
||||||
|
[ContractPhase.DangDamPhan] = TimeSpan.FromDays(7),
|
||||||
|
[ContractPhase.DangInKy] = TimeSpan.FromDays(1),
|
||||||
|
[ContractPhase.DangKiemTraCCM] = TimeSpan.FromDays(3),
|
||||||
|
[ContractPhase.DangTrinhKy] = TimeSpan.FromDays(1),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pull tất cả HĐ chưa warning + sắp quá hạn
|
||||||
|
var candidates = await db.Contracts
|
||||||
|
.Where(c => !c.SlaWarningSent
|
||||||
|
&& c.SlaDeadline != null && c.SlaDeadline > now
|
||||||
|
&& c.Phase != ContractPhase.DaPhatHanh
|
||||||
|
&& c.Phase != ContractPhase.TuChoi
|
||||||
|
&& c.Phase != ContractPhase.DangDongDau)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (candidates.Count == 0) return;
|
||||||
|
|
||||||
|
int warned = 0;
|
||||||
|
foreach (var c in candidates)
|
||||||
|
{
|
||||||
|
if (!defaultSla.TryGetValue(c.Phase, out var sla)) continue;
|
||||||
|
var threshold = TimeSpan.FromTicks((long)(sla.Ticks * 0.2));
|
||||||
|
var remaining = c.SlaDeadline!.Value - now;
|
||||||
|
if (remaining > threshold) continue; // còn nhiều SLA → skip
|
||||||
|
|
||||||
|
// ≤ 20% SLA còn lại → gửi warning
|
||||||
|
if (c.DrafterUserId is Guid drafterId)
|
||||||
|
{
|
||||||
|
var hoursLeft = Math.Max(1, (int)remaining.TotalHours);
|
||||||
|
await notifications.NotifyAsync(
|
||||||
|
drafterId,
|
||||||
|
NotificationType.SlaWarning,
|
||||||
|
title: $"⚠ HĐ {c.MaHopDong ?? c.TenHopDong} sắp quá hạn ({hoursLeft}h)",
|
||||||
|
description: $"Phase {c.Phase} còn ~{hoursLeft}h trước khi auto-approve.",
|
||||||
|
href: $"/contracts/{c.Id}",
|
||||||
|
refId: c.Id,
|
||||||
|
ct: ct);
|
||||||
|
}
|
||||||
|
c.SlaWarningSent = true;
|
||||||
|
warned++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warned > 0)
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
_logger.LogInformation("SlaExpiryJob: {Count} warnings dispatched (≤20% SLA).", warned);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,10 +67,20 @@ public class ContractWorkflowService(
|
|||||||
$"Policy '{policy.Name}' không cho phép {contract.Phase} → {targetPhase}. " +
|
$"Policy '{policy.Name}' không cho phép {contract.Phase} → {targetPhase}. " +
|
||||||
$"Kiểm tra ContractType hoặc BypassProcurementAndCCM.");
|
$"Kiểm tra ContractType hoặc BypassProcurementAndCCM.");
|
||||||
|
|
||||||
if (!actorRoles.Any(r => allowedRoles.Contains(r)))
|
// Sử dụng IsTransitionAllowed — check Role + User-kind fallback.
|
||||||
|
// User-kind chỉ áp dụng khi WorkflowDefinition pinned có
|
||||||
|
// WorkflowStepApprover Kind=User cho step này.
|
||||||
|
if (!policy.IsTransitionAllowed(contract.Phase, targetPhase, actorRoles, actorUserId))
|
||||||
|
{
|
||||||
|
var userExtra = policy.UserTransitions is not null
|
||||||
|
&& policy.UserTransitions.TryGetValue((contract.Phase, targetPhase), out var userIds)
|
||||||
|
&& userIds.Length > 0
|
||||||
|
? $" hoặc {userIds.Length} user explicit"
|
||||||
|
: "";
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
$"Role ({string.Join(",", actorRoles)}) không đủ quyền chuyển {contract.Phase} → {targetPhase}. " +
|
$"Role ({string.Join(",", actorRoles)}) không đủ quyền chuyển {contract.Phase} → {targetPhase}. " +
|
||||||
$"Policy '{policy.Name}' yêu cầu: {string.Join(",", allowedRoles)}.");
|
$"Policy '{policy.Name}' yêu cầu: {string.Join(",", allowedRoles)}{userExtra}.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var fromPhase = contract.Phase;
|
var fromPhase = contract.Phase;
|
||||||
|
|||||||
Reference in New Issue
Block a user