Added scheduler jobs
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Services.Communications;
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Application.Events;
|
||||
|
||||
public sealed class NonCompliantProjectEvent : IEvent
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.CreateVersion7();
|
||||
|
||||
public string Name { get; set; } = nameof(NonCompliantProjectEvent);
|
||||
|
||||
public DateTimeOffset EnqueueAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public string CorrelationId { get; set; } = "noncompliant-project-check";
|
||||
|
||||
public static NonCompliantProjectEvent Create() => new();
|
||||
}
|
||||
|
||||
public sealed class NonCompliantProjectEventHandler(IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||
ICommunicationsService service) : INotificationHandler<NonCompliantProjectEvent>
|
||||
{
|
||||
public async ValueTask Handle(NonCompliantProjectEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Fetch active/monitored projects with programme, portfolio, and award organisations
|
||||
var projects = await context.Projects
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Programme)
|
||||
.ThenInclude(pr => pr!.Owner)
|
||||
.Include(p => p.Programme)
|
||||
.ThenInclude(pr => pr!.Portfolio)
|
||||
.ThenInclude(po => po!.Organisation)
|
||||
.ThenInclude(o => o!.User)
|
||||
.Include(p => p.Awards)
|
||||
.ThenInclude(a => a.Organisation)
|
||||
.ThenInclude(o => o!.User)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (projects.Count == 0) return;
|
||||
|
||||
// Fetch compliance items and milestones to evaluate compliance according to ConsoleUI rules
|
||||
var complianceItems = await context.ComplianceItems
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var milestones = await context.Milestones
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var complianceByProject = complianceItems.GroupBy(c => c.ProjectId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var milestonesByProject = milestones.GroupBy(m => m.ProjectId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// Load noncompliantprojects.html template for partner organisations
|
||||
var partnerResourceStream = typeof(IDomain).Assembly
|
||||
.GetManifestResourceStream("PostFundManagement.Domain.Templates.noncompliantprojects.html");
|
||||
|
||||
if (partnerResourceStream is null) return;
|
||||
|
||||
using var partnerHtmlTemplateReader = new StreamReader(partnerResourceStream);
|
||||
var partnerTemplate = await partnerHtmlTemplateReader.ReadToEndAsync(cancellationToken);
|
||||
|
||||
// Load noncompliantprojects-prog.html template for programme managers
|
||||
var progResourceStream = typeof(IDomain).Assembly
|
||||
.GetManifestResourceStream("PostFundManagement.Domain.Templates.noncompliantprojects-prog.html");
|
||||
|
||||
string? programmeTemplate = null;
|
||||
if (progResourceStream is not null)
|
||||
{
|
||||
using var progHtmlTemplateReader = new StreamReader(progResourceStream);
|
||||
programmeTemplate = await progHtmlTemplateReader.ReadToEndAsync(cancellationToken);
|
||||
}
|
||||
|
||||
foreach (var project in projects)
|
||||
{
|
||||
complianceByProject.TryGetValue(project.Id, out var pComplianceItems);
|
||||
pComplianceItems ??= [];
|
||||
|
||||
milestonesByProject.TryGetValue(project.Id, out var pMilestones);
|
||||
pMilestones ??= [];
|
||||
|
||||
// ConsoleUI Invariant: A project is 100% compliant if unmetCompCount == 0 && overdueMilestones.Count == 0
|
||||
var unmetCompItems = pComplianceItems.Where(c => c.Status == ComplianceStatus.NotMet).ToList();
|
||||
var overdueMilestones = pMilestones
|
||||
.Where(m => m.Status != ApprovalStatus.Approved && m.DueAt < now)
|
||||
.OrderBy(m => m.DueAt)
|
||||
.ToList();
|
||||
|
||||
var isCompliant = unmetCompItems.Count == 0 && overdueMilestones.Count == 0;
|
||||
if (isCompliant) continue;
|
||||
|
||||
// Compliance rate calculation
|
||||
var totalEvaluated = pComplianceItems.Count + pMilestones.Count;
|
||||
var totalMet = pComplianceItems.Count(c => c.Status == ComplianceStatus.Met || c.Status == ComplianceStatus.Exceeded)
|
||||
+ pMilestones.Count(m => m.Status == ApprovalStatus.Approved);
|
||||
var compliancePercentage = totalEvaluated > 0
|
||||
? (int)Math.Round((double)totalMet / totalEvaluated * 100)
|
||||
: 0;
|
||||
|
||||
// Resolve target partner organisation
|
||||
var organisation = project.Awards.FirstOrDefault()?.Organisation
|
||||
?? project.Programme?.Portfolio?.Organisation;
|
||||
|
||||
if (organisation is null) continue;
|
||||
|
||||
var recipientId = organisation.UserId ?? organisation.CreatedBy;
|
||||
var recipientEmail = organisation.User?.Email ?? organisation.Email;
|
||||
if (string.IsNullOrWhiteSpace(recipientEmail) || recipientId == Guid.Empty) continue;
|
||||
|
||||
// Construct non-compliance table rows
|
||||
var tableRowsBuilder = new StringBuilder();
|
||||
|
||||
foreach (var item in unmetCompItems)
|
||||
{
|
||||
var title = WebUtility.HtmlEncode(item.Title ?? "Statutory Compliance Obligation");
|
||||
var requirements = WebUtility.HtmlEncode(item.Requirements ?? "Verification evidence required");
|
||||
|
||||
tableRowsBuilder.AppendLine($@"
|
||||
<tr style=""border-bottom: 1px solid #e2e8f0;"">
|
||||
<td style=""padding: 10px 14px; font-weight: 600; color: #475569; font-size: 12px;"">Statutory / Legal</td>
|
||||
<td style=""padding: 10px 14px; font-weight: 500; color: #1e293b;"">{title}</td>
|
||||
<td style=""padding: 10px 14px; color: #64748b; font-size: 13px;"">{requirements}</td>
|
||||
<td style=""padding: 10px 14px; font-size: 12px;""><span style=""background-color: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 9999px; font-weight: 600;"">Not Met</span></td>
|
||||
</tr>");
|
||||
}
|
||||
|
||||
foreach (var m in overdueMilestones)
|
||||
{
|
||||
var delay = (int)Math.Max(0, (now - m.DueAt).TotalDays);
|
||||
var mName = WebUtility.HtmlEncode(m.Name ?? "Milestone Deliverable");
|
||||
|
||||
tableRowsBuilder.AppendLine($@"
|
||||
<tr style=""border-bottom: 1px solid #e2e8f0;"">
|
||||
<td style=""padding: 10px 14px; font-weight: 600; color: #d97706; font-size: 12px;"">Milestone SLA</td>
|
||||
<td style=""padding: 10px 14px; font-weight: 500; color: #1e293b;"">{mName}</td>
|
||||
<td style=""padding: 10px 14px; color: #dc2626; font-size: 13px;"">Due: {m.DueAt:yyyy-MM-dd} (+{delay} d)</td>
|
||||
<td style=""padding: 10px 14px; font-size: 12px;""><span style=""background-color: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 9999px; font-weight: 600;"">Overdue</span></td>
|
||||
</tr>");
|
||||
}
|
||||
|
||||
// 1. Dispatch partner audit notice
|
||||
var messageBody = partnerTemplate;
|
||||
messageBody = messageBody.Replace("[OrganisationName]", WebUtility.HtmlEncode(organisation.Name ?? "Partner"));
|
||||
messageBody = messageBody.Replace("[ProjectName]", WebUtility.HtmlEncode(project.Name ?? "Project"));
|
||||
messageBody = messageBody.Replace("[ProgrammeName]", WebUtility.HtmlEncode(project.Programme?.Name ?? "Programme"));
|
||||
messageBody = messageBody.Replace("[PortfolioName]", WebUtility.HtmlEncode(project.Programme?.Portfolio?.Name ?? "Portfolio"));
|
||||
messageBody = messageBody.Replace("[ProjectStartDate]", project.StartedAt.HasValue ? $"{project.StartedAt:yyyy-MM-dd}" : "Not Specified");
|
||||
messageBody = messageBody.Replace("[ProjectEndDate]", project.EndedAt.HasValue ? $"{project.EndedAt:yyyy-MM-dd}" : "Not Specified");
|
||||
messageBody = messageBody.Replace("[ComplianceScore]", $"{compliancePercentage}%");
|
||||
messageBody = messageBody.Replace("[UnmetComplianceCount]", unmetCompItems.Count.ToString());
|
||||
messageBody = messageBody.Replace("[UnmetMilestonesCount]", overdueMilestones.Count.ToString());
|
||||
messageBody = messageBody.Replace("[NonComplianceTable]", tableRowsBuilder.ToString());
|
||||
messageBody = messageBody.Replace("[PortalUrl]", "https://partner.ifri.khongisa.co.za");
|
||||
messageBody = messageBody.Replace("[ConsoleUrl]", "https://console.ifri.khongisa.co.za");
|
||||
messageBody = messageBody.Replace("[Year]", now.Year.ToString());
|
||||
|
||||
var notificationRequest = new CreateNotificationRequest(
|
||||
organisation.Id,
|
||||
recipientId,
|
||||
NotificationPlatform.Email,
|
||||
$"[Audit Notice] Project Non-Compliance: {project.Name}",
|
||||
recipientEmail,
|
||||
messageBody,
|
||||
NotificationStatus.Queued);
|
||||
|
||||
_ = await service.CreateNotificationAsync(notificationRequest, SystemUser, cancellationToken);
|
||||
|
||||
// 2. Dispatch internal escalation to Programme Manager (FR-M07-009 / M10)
|
||||
if (!string.IsNullOrWhiteSpace(programmeTemplate))
|
||||
{
|
||||
var programme = project.Programme;
|
||||
var programmeManager = programme?.Owner;
|
||||
var programmeManagerId = programme?.OwnedBy ?? programmeManager?.Id;
|
||||
var programmeManagerEmail = programmeManager?.Email;
|
||||
|
||||
if (programmeManagerId.HasValue && programmeManagerId.Value != Guid.Empty && !string.IsNullOrWhiteSpace(programmeManagerEmail))
|
||||
{
|
||||
var progMessageBody = programmeTemplate;
|
||||
progMessageBody = progMessageBody.Replace("[ProgrammeManagerName]", WebUtility.HtmlEncode(programmeManagerEmail ?? "Programme Manager"));
|
||||
progMessageBody = progMessageBody.Replace("[ProgrammeName]", WebUtility.HtmlEncode(programme?.Name ?? "Programme"));
|
||||
progMessageBody = progMessageBody.Replace("[PortfolioName]", WebUtility.HtmlEncode(programme?.Portfolio?.Name ?? "Portfolio"));
|
||||
progMessageBody = progMessageBody.Replace("[OrganisationName]", WebUtility.HtmlEncode(organisation.Name ?? "Partner"));
|
||||
progMessageBody = progMessageBody.Replace("[ProjectName]", WebUtility.HtmlEncode(project.Name ?? "Project"));
|
||||
progMessageBody = progMessageBody.Replace("[ProjectStartDate]", project.StartedAt.HasValue ? $"{project.StartedAt:yyyy-MM-dd}" : "Not Specified");
|
||||
progMessageBody = progMessageBody.Replace("[ProjectEndDate]", project.EndedAt.HasValue ? $"{project.EndedAt:yyyy-MM-dd}" : "Not Specified");
|
||||
progMessageBody = progMessageBody.Replace("[TotalLateMilestones]", overdueMilestones.Count.ToString());
|
||||
progMessageBody = progMessageBody.Replace("[TotalLateMiletones]", overdueMilestones.Count.ToString());
|
||||
progMessageBody = progMessageBody.Replace("[MostLateMilestoneName]", WebUtility.HtmlEncode(overdueMilestones.FirstOrDefault()?.Name ?? "N/A"));
|
||||
progMessageBody = progMessageBody.Replace("[MostLateMilestoneDueDate]", overdueMilestones.FirstOrDefault() != null ? $"{overdueMilestones.First().DueAt:yyyy-MM-dd}" : "N/A");
|
||||
progMessageBody = progMessageBody.Replace("[MostLateMilestoneDaysOverdue]", overdueMilestones.FirstOrDefault() != null ? $"{(int)Math.Max(0, (now - overdueMilestones.First().DueAt).TotalDays)}" : "0");
|
||||
progMessageBody = progMessageBody.Replace("[LateMilestonesTable]", tableRowsBuilder.ToString());
|
||||
progMessageBody = progMessageBody.Replace("[ConsoleUrl]", "https://console.ifri.khongisa.co.za");
|
||||
progMessageBody = progMessageBody.Replace("[PortalUrl]", "https://partner.ifri.khongisa.co.za");
|
||||
progMessageBody = progMessageBody.Replace("[Year]", now.Year.ToString());
|
||||
|
||||
var progNotificationRequest = new CreateNotificationRequest(
|
||||
organisation.Id,
|
||||
programmeManagerId.Value,
|
||||
NotificationPlatform.Email,
|
||||
$"[Weekly Audit Escalation] Non-Compliant Project: {project.Name} ({programme?.Name})",
|
||||
programmeManagerEmail,
|
||||
progMessageBody,
|
||||
NotificationStatus.Queued);
|
||||
|
||||
_ = await service.CreateNotificationAsync(progNotificationRequest, SystemUser, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user