[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

@ -4,7 +4,9 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Contracts.Services;
using SolutionErp.Application.Notifications;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Notifications;
namespace SolutionErp.Infrastructure.HostedServices;
@ -60,8 +62,17 @@ public class SlaExpiryJob : BackgroundService
var db = scope.ServiceProvider.GetRequiredService<IApplicationDbContext>();
var workflow = scope.ServiceProvider.GetRequiredService<IContractWorkflowService>();
var dateTime = scope.ServiceProvider.GetRequiredService<IDateTime>();
var notifications = scope.ServiceProvider.GetRequiredService<INotificationService>();
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
.Where(c => c.SlaDeadline != null && c.SlaDeadline < now)
.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);
}
}
}

View File

@ -67,10 +67,20 @@ public class ContractWorkflowService(
$"Policy '{policy.Name}' không cho phép {contract.Phase} → {targetPhase}. " +
$"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(
$"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;