diff --git a/src/Backend/SolutionErp.Domain/Contracts/WorkflowPolicy.cs b/src/Backend/SolutionErp.Domain/Contracts/WorkflowPolicy.cs index 273eb10..4122450 100644 --- a/src/Backend/SolutionErp.Domain/Contracts/WorkflowPolicy.cs +++ b/src/Backend/SolutionErp.Domain/Contracts/WorkflowPolicy.cs @@ -17,14 +17,28 @@ public sealed record WorkflowPolicy( string Description, IReadOnlyDictionary<(ContractPhase From, ContractPhase To), string[]> Transitions, IReadOnlyDictionary PhaseSla, - IReadOnlyList ActivePhases) + IReadOnlyList 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 actorRoles) + public bool IsTransitionAllowed( + ContractPhase from, ContractPhase to, + IReadOnlyList 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 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(); var activePhases = new List(); @@ -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); } } diff --git a/src/Backend/SolutionErp.Infrastructure/HostedServices/SlaExpiryJob.cs b/src/Backend/SolutionErp.Infrastructure/HostedServices/SlaExpiryJob.cs index 79e92f3..ae0e08b 100644 --- a/src/Backend/SolutionErp.Infrastructure/HostedServices/SlaExpiryJob.cs +++ b/src/Backend/SolutionErp.Infrastructure/HostedServices/SlaExpiryJob.cs @@ -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(); var workflow = scope.ServiceProvider.GetRequiredService(); var dateTime = scope.ServiceProvider.GetRequiredService(); + var notifications = scope.ServiceProvider.GetRequiredService(); 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.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); + } + } } diff --git a/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs index ded9ea7..45c1a31 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs @@ -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;