From 04b5656fd1cfc5cfdf94be07982d89527b30224a Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 28 Oct 2026 18:04:52 +0200 Subject: [PATCH] Added scheduler jobs --- .../Events/LateProjectReminderEvent.cs | 153 +++++++++--- .../Events/NonCompliantProjectEvent.cs | 221 ++++++++++++++++++ .../Extensions/Constants.cs | 3 +- .../PostFundManagement.Domain.csproj | 2 + .../Templates/lateprojectsmilestones.html | 188 +++++++++++++++ .../Templates/noncompliantprojects-prog.html | 195 ++++++++++++++++ .../Templates/noncompliantprojects.html | 199 ++++++++++++++++ PostFundManagement.Jobs/JobsWorker.cs | 7 +- 8 files changed, 937 insertions(+), 31 deletions(-) create mode 100644 PostFundManagement.Application/Events/NonCompliantProjectEvent.cs create mode 100644 PostFundManagement.Domain/Templates/noncompliantprojects-prog.html diff --git a/PostFundManagement.Application/Events/LateProjectReminderEvent.cs b/PostFundManagement.Application/Events/LateProjectReminderEvent.cs index 50a252a..5113ec9 100644 --- a/PostFundManagement.Application/Events/LateProjectReminderEvent.cs +++ b/PostFundManagement.Application/Events/LateProjectReminderEvent.cs @@ -19,7 +19,7 @@ public sealed class LateProjectEvent : IEvent public static LateProjectEvent Create() => new(); } -public sealed class LateProjectEventHandler(IDbContextFactory contextFactory, +public sealed class LateProjectEventHandler(IDbContextFactory contextFactory, ICommunicationsService service) : INotificationHandler { readonly int lateDays = 3; @@ -28,50 +28,149 @@ public sealed class LateProjectEventHandler(IDbContextFactory 0 ? now.AddDays(-lateDays) : now; + + // Query overdue milestones that are not yet approved + // Execute ToListAsync before GroupBy so EF Core does not fail SQL translation + var overdueMilestones = await context.Milestones .AsNoTracking() - .Include(i => i.Project!.Programme!.Portfolio!.Organisation!.User) - .Where(m => (DateTime.UtcNow - m.Project!.StartedAt!).Value.TotalDays < lateDays) - .OrderBy(o => o.CreatedAt) - .GroupBy(g => g.ProjectId) - .AsSplitQuery() + .Include(i => i.Project) + .ThenInclude(p => p!.Programme) + .ThenInclude(pr => pr!.Owner) + .Include(i => i.Project) + .ThenInclude(p => p!.Programme) + .ThenInclude(pr => pr!.Portfolio) + .ThenInclude(po => po!.Organisation) + .ThenInclude(o => o!.User) + .Where(m => m.Status != ApprovalStatus.Approved && m.DueAt <= overdueCutoff) + .OrderBy(o => o.DueAt) .ToListAsync(cancellationToken); - if (lateMilestones is null) return; + if (overdueMilestones.Count == 0) return; - using var htmlTemplateReader = new StreamReader(Assembly.GetAssembly(typeof(IDomain))? - .GetManifestResourceStream("PostFundManagement.Domain.Templates.lateprojectsmilestones.html")!); + var lateMilestonesByProject = overdueMilestones.GroupBy(g => g.ProjectId); - var template = await htmlTemplateReader.ReadToEndAsync(cancellationToken); + var partnerResourceStream = typeof(IDomain).Assembly + .GetManifestResourceStream("PostFundManagement.Domain.Templates.lateprojectsmilestones.html"); - foreach (var group in lateMilestones) + if (partnerResourceStream is null) return; + + using var partnerHtmlTemplateReader = new StreamReader(partnerResourceStream); + var partnerTemplate = await partnerHtmlTemplateReader.ReadToEndAsync(cancellationToken); + + var programmeResourceStream = typeof(IDomain).Assembly + .GetManifestResourceStream("PostFundManagement.Domain.Templates.noncompliantprojects-prog.html"); + + string? programmeTemplate = null; + if (programmeResourceStream is not null) { - if (!group.Any()) continue; + using var programmeHtmlTemplateReader = new StreamReader(programmeResourceStream); + programmeTemplate = await programmeHtmlTemplateReader.ReadToEndAsync(cancellationToken); + } - var project = group.FirstOrDefault()?.Project; + foreach (var group in lateMilestonesByProject) + { + var milestones = group.ToList(); + + if (milestones.Count == 0) continue; + + var project = milestones[0].Project; if (project is null) continue; - var mostLateMilestone = group.OrderBy(m => m.DueAt).FirstOrDefault(); - var missedMilestone = group.Where(m => DateTime.UtcNow > m.DueAt).FirstOrDefault(); + var mostLateMilestone = milestones.OrderBy(m => m.DueAt).FirstOrDefault(); - var messageBody = template; - messageBody = messageBody.Replace("[ProjectName]", project.Name); - messageBody = messageBody.Replace("[ProjectStartDate]", $"{project.StartedAt:yyyy-MM-dd}"); - messageBody = messageBody.Replace("[ProjectEndDate]", $"{project.EndedAt:yyyy-MM-dd}"); - messageBody = messageBody.Replace("[TotalLateMiletones]", $"{group.Count()}"); - - // TODO: output information of most late milestone - // TODO: output information of milestones already missed + var daysOverdue = mostLateMilestone != null + ? (int)Math.Max(0, (now - mostLateMilestone.DueAt).TotalDays) + : 0; var organisation = mostLateMilestone?.Project?.Programme?.Portfolio?.Organisation; if (organisation is null) continue; - var notificationRequest = new CreateNotificationRequest(organisation.Id, organisation.User!.Id, NotificationPlatform.Email, - "Project Status Update", organisation.User!.Email, messageBody, NotificationStatus.Queued); + var recipientId = organisation.UserId ?? organisation.CreatedBy; + var recipientEmail = organisation.User?.Email ?? organisation.Email; + + if (string.IsNullOrWhiteSpace(recipientEmail) || recipientId == Guid.Empty) continue; + + var tableRowsBuilder = new StringBuilder(); + foreach (var milestone in milestones.OrderBy(m => m.DueAt)) + { + var delay = (int)Math.Max(0, (now - milestone.DueAt).TotalDays); + var statusText = milestone.Status.ToString(); + var milestoneName = WebUtility.HtmlEncode(milestone.Name ?? "Milestone Deliverable"); + + tableRowsBuilder.AppendLine($@" + + {milestoneName} + {milestone.DueAt:yyyy-MM-dd} + +{delay} d + {statusText} + "); + } + + // 1. Send notice to Partner / Recipient Organisation + var messageBody = partnerTemplate; + messageBody = messageBody.Replace("[OrganisationName]", WebUtility.HtmlEncode(organisation.Name ?? "Partner")); + messageBody = messageBody.Replace("[RecipientName]", WebUtility.HtmlEncode(organisation.Name ?? "Partner")); + messageBody = messageBody.Replace("[ProjectName]", WebUtility.HtmlEncode(project.Name ?? "Project")); + 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("[TotalLateMilestones]", milestones.Count.ToString()); + messageBody = messageBody.Replace("[TotalLateMiletones]", milestones.Count.ToString()); + messageBody = messageBody.Replace("[MostLateMilestoneName]", WebUtility.HtmlEncode(mostLateMilestone?.Name ?? "N/A")); + messageBody = messageBody.Replace("[MostLateMilestoneDueDate]", mostLateMilestone != null ? $"{mostLateMilestone.DueAt:yyyy-MM-dd}" : "N/A"); + messageBody = messageBody.Replace("[MostLateMilestoneDaysOverdue]", daysOverdue.ToString()); + messageBody = messageBody.Replace("[LateMilestonesTable]", tableRowsBuilder.ToString()); + messageBody = messageBody.Replace("[PortalUrl]", "https://partner.ifri.khongisa.co.za"); + messageBody = messageBody.Replace("[Year]", now.Year.ToString()); + + var notificationRequest = new CreateNotificationRequest(organisation.Id, recipientId, NotificationPlatform.Email, + $"Overdue Milestone Alert: {project.Name}", recipientEmail, messageBody, NotificationStatus.Queued); _ = await service.CreateNotificationAsync(notificationRequest, SystemUser, cancellationToken); + + // 2. Construct and send escalation notification for the Programme Manager (FR-M07-009) + 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]", milestones.Count.ToString()); + progMessageBody = progMessageBody.Replace("[TotalLateMiletones]", milestones.Count.ToString()); + progMessageBody = progMessageBody.Replace("[MostLateMilestoneName]", WebUtility.HtmlEncode(mostLateMilestone?.Name ?? "N/A")); + progMessageBody = progMessageBody.Replace("[MostLateMilestoneDueDate]", mostLateMilestone != null ? $"{mostLateMilestone.DueAt:yyyy-MM-dd}" : "N/A"); + progMessageBody = progMessageBody.Replace("[MostLateMilestoneDaysOverdue]", daysOverdue.ToString()); + 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, + $"[Escalation] Overdue Milestones: {project.Name} ({programme?.Name})", + programmeManagerEmail, + progMessageBody, + NotificationStatus.Queued); + + _ = await service.CreateNotificationAsync(progNotificationRequest, SystemUser, cancellationToken); + } + } } } -} \ No newline at end of file +} diff --git a/PostFundManagement.Application/Events/NonCompliantProjectEvent.cs b/PostFundManagement.Application/Events/NonCompliantProjectEvent.cs new file mode 100644 index 0000000..01da4a8 --- /dev/null +++ b/PostFundManagement.Application/Events/NonCompliantProjectEvent.cs @@ -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 contextFactory, + ICommunicationsService service) : INotificationHandler +{ + 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($@" + + Statutory / Legal + {title} + {requirements} + Not Met + "); + } + + 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($@" + + Milestone SLA + {mName} + Due: {m.DueAt:yyyy-MM-dd} (+{delay} d) + Overdue + "); + } + + // 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); + } + } + } + } +} + diff --git a/PostFundManagement.Domain/Extensions/Constants.cs b/PostFundManagement.Domain/Extensions/Constants.cs index e3745a6..500be28 100644 --- a/PostFundManagement.Domain/Extensions/Constants.cs +++ b/PostFundManagement.Domain/Extensions/Constants.cs @@ -20,7 +20,7 @@ public static class Constants public const string DatabaseConfigName = "PfmDatabase"; public const string DefaultSchedulerName = "pfm-scheduler"; public const string SchedulerDatabaseConfigName = "PfmSchedulerDatabase"; - + public const int LabelLength = 256; public const int ShortLabelLength = 100; public const int TextLength = 1024; @@ -29,6 +29,7 @@ public static class Constants public const string FeatureEmailProcessor = "EmailProcessorJobEnabled"; public const string FeatureLateProjectReminder = "LateProjectReminderEnabled"; + public const string FeatureNonCompliantProjectCheck = "NonCompliantProjectCheckEnabled"; public const string FeatureServiceCaching = "ServiceCaching"; public static Guid SystemUser { get; } = new(0x6059f1e4, 0x6a41, 0x4447, 0xa5, 0x46, 0x86, 0xec, 0xe5, 0x10, 0x20, 0x4) /* 6059f1e4-6a41-4447-a546-86ece5102004 */; diff --git a/PostFundManagement.Domain/PostFundManagement.Domain.csproj b/PostFundManagement.Domain/PostFundManagement.Domain.csproj index 0e13618..b457269 100644 --- a/PostFundManagement.Domain/PostFundManagement.Domain.csproj +++ b/PostFundManagement.Domain/PostFundManagement.Domain.csproj @@ -3,8 +3,10 @@ + + diff --git a/PostFundManagement.Domain/Templates/lateprojectsmilestones.html b/PostFundManagement.Domain/Templates/lateprojectsmilestones.html index e69de29..4ee5d4e 100644 --- a/PostFundManagement.Domain/Templates/lateprojectsmilestones.html +++ b/PostFundManagement.Domain/Templates/lateprojectsmilestones.html @@ -0,0 +1,188 @@ + + + + + + + Project Milestone Overdue Notification + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+
+ Post-Fund Management Platform (PFM) +
+
+ Portfolio Performance & Monitoring +
+
+ + Action Required + +
+
+ + + + + +
+
+ ! +
+
+
+ Overdue Milestone Notice: [TotalLateMilestones] Milestone(s) Exceeded Due Date +
+
+ Automated daily sweep flagged scheduled deliverables requiring evidence submission or status updates. +
+
+
+ + +

+ Dear [OrganisationName] Team, +

+ + +

+ During our automated lifecycle monitoring sweep, our platform identified one or more contractual milestones for + [ProjectName] that have passed their agreed target completion deadlines without formal evidence approval. +

+ + + + + + + + + + + + + + + + + + + + + + +
+ Project Summary +
Project Title[ProjectName]
Implementation Window[ProjectStartDate] — [ProjectEndDate]
Overdue Deliverables + [TotalLateMilestones] Milestone(s) +
Most Overdue Milestone + [MostLateMilestoneName] (Due: [MostLateMilestoneDueDate]) +
+ + +
+ Milestone Breakdown +
+ + + + + + + + + + + + + [LateMilestonesTable] + +
MilestoneDue DateDelayStatus
+ + + + + + +
+
+ Disbursement & Governance Policy (BR-003 / BR-004) +
+
+ Under PFM governance regulations, tranches and disbursements are strictly gated by verified milestone deliverables. Overdue milestones may trigger project risk escalations and freeze scheduled payment releases. +
+
+ + + + + + +
+ + Open PFM Portal & Submit Evidence → + +
+ +

+ If you have already submitted the evidence for these deliverables or have an active change request under review with your Programme Manager, you may disregard this notice. +

+ +
+

+ Post-Fund Management (PFM) Platform • Automated Lifecycle & SLA Daemon +

+

+ This is an automated contractual notification sent in accordance with your funding agreement SLA.
+ Please do not reply directly to this email. For support, please contact your designated Programme Officer. +

+

+ © [Year] PostFundManagement Platform. All rights reserved. +

+
+
+ + + + diff --git a/PostFundManagement.Domain/Templates/noncompliantprojects-prog.html b/PostFundManagement.Domain/Templates/noncompliantprojects-prog.html new file mode 100644 index 0000000..5835bc3 --- /dev/null +++ b/PostFundManagement.Domain/Templates/noncompliantprojects-prog.html @@ -0,0 +1,195 @@ + + + + + + + Programme Oversight: Project Milestone Escalation Notice + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+
+ PFM Internal Governance • FR-M07-009 Escalation +
+
+ Programme Milestone Escalation +
+
+ + Internal Notice + +
+
+ + + + + +
+
+ ! +
+
+
+ Milestone SLA Breach: [TotalLateMilestones] Deliverable(s) Past Due Date +
+
+ Project under your programme governance has breached milestone delivery deadlines. Automated partner reminder was dispatched. +
+
+
+ + +

+ Dear [ProgrammeManagerName], +

+ + +

+ In accordance with portfolio monitoring requirement FR-M07-009, this automated escalation notice alerts you that + [ProjectName] has overdue milestones requiring your oversight and possible intervention. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Governance & Project Hierarchy +
Programme[ProgrammeName]
Portfolio[PortfolioName]
Recipient Organisation[OrganisationName]
Project Title[ProjectName]
Project Duration[ProjectStartDate] — [ProjectEndDate]
Overdue Status + + [TotalLateMilestones] Milestone(s) Overdue + + (Primary: [MostLateMilestoneName], [MostLateMilestoneDaysOverdue] days late) +
+ + +
+ Delinquent Deliverables Breakdown +
+ + + + + + + + + + + + + [LateMilestonesTable] + +
MilestoneDue DateDelayStatus
+ + + + + + +
+
+ Recommended Manager Actions +
+
    +
  • Verify whether unreviewed evidence submissions are awaiting your sign-off in the Admin Console.
  • +
  • Engage recipient organisation leadership regarding timeline remediation or change requests (M15).
  • +
  • Ensure disbursements linked to gated milestones remain withheld per rule BR-003.
  • +
+
+ + + + + + +
+ + Open Programme in Console → + +
+ +
+

+ Post-Fund Management (PFM) Platform • Automated Governance & Monitoring Daemon +

+

+ You received this notification because you are designated as the Programme Owner/Manager for [ProgrammeName].
+ Automated escalation according to SRS v2.0 requirement FR-M07-009. +

+

+ © [Year] PostFundManagement Platform. All rights reserved. +

+
+
+ + + + diff --git a/PostFundManagement.Domain/Templates/noncompliantprojects.html b/PostFundManagement.Domain/Templates/noncompliantprojects.html index e69de29..2b06c9e 100644 --- a/PostFundManagement.Domain/Templates/noncompliantprojects.html +++ b/PostFundManagement.Domain/Templates/noncompliantprojects.html @@ -0,0 +1,199 @@ + + + + + + + Weekly Governance Audit: Project Non-Compliance Notice + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+
+ Post-Fund Management Platform (PFM) • M10 Compliance +
+
+ Weekly Project Compliance Audit +
+
+ + Non-Compliant + +
+
+ + + + + +
+
+ ! +
+
+
+ Action Required: Project Flagged as Non-Compliant ([ComplianceScore] Met) +
+
+ Weekly automated governance audit identified unmet statutory obligations and/or overdue milestone deliverables. +
+
+
+ + +

+ Dear [OrganisationName] Team, +

+ + +

+ During our weekly automated compliance audit, [ProjectName] (under programme [ProgrammeName]) did not satisfy the mandatory statutory and contractual compliance threshold. In accordance with system governance policies, immediate remediation is required. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Audit Findings Overview +
Project Title[ProjectName]
Programme / Portfolio[ProgrammeName] • [PortfolioName]
Implementation Window[ProjectStartDate] — [ProjectEndDate]
Compliance Verdict + + Non-Compliant — Score: [ComplianceScore] + +
Unmet Compliance Items + [UnmetComplianceCount] Item(s) Pending Resolution +
Overdue Milestones + [UnmetMilestonesCount] Milestone(s) Overdue +
+ + +
+ Outstanding Compliance & Deliverable Items +
+ + + + + + + + + + + + + [NonComplianceTable] + +
CategoryItem / ObligationDetails / Due DateStatus
+ + + + + + +
+
+ Disbursement Hold Notice (BR-003 / AT-08) +
+
+ Under cardinal rule BR-003, all pending tranche disbursements and drawdown requests are frozen while a project carries non-compliant obligations. Please upload the required compliance verification evidence through the Partner Portal to restore good standing. +
+
+ + + + + + +
+ + Open Partner Portal & Submit Compliance Evidence → + +
+ +

+ Once evidence is submitted, your designated Programme Officer will review and verify the documentation to restore your project's 100% compliant status. +

+ +
+

+ Post-Fund Management (PFM) Platform • Weekly Compliance Audit Daemon +

+

+ Automated statutory oversight notification generated in accordance with SRS v2.0 Module M10.
+ Please do not reply directly to this email. For assistance, contact your Programme Officer. +

+

+ © [Year] PostFundManagement Platform. All rights reserved. +

+
+
+ + + + diff --git a/PostFundManagement.Jobs/JobsWorker.cs b/PostFundManagement.Jobs/JobsWorker.cs index f8e57fd..d502c3f 100644 --- a/PostFundManagement.Jobs/JobsWorker.cs +++ b/PostFundManagement.Jobs/JobsWorker.cs @@ -22,9 +22,10 @@ public class JobsWorker(IJobOrchestrator jobOrchestrator, IFeatureManager featur await jobOrchestrator.ScheduleAsync(ProcessEmailEvent.Create(), "0 */5 * * * ?", stoppingToken); if (await featureManager.IsEnabledAsync(FeatureLateProjectReminder)) - await jobOrchestrator.ScheduleAsync(LateProjectEvent.Create(), "", stoppingToken); - - // TODO: Add job to check for non-compliant projects once a week + await jobOrchestrator.ScheduleAsync(LateProjectEvent.Create(), "0 30 6 * * ?", stoppingToken); + + if (await featureManager.IsEnabledAsync(FeatureNonCompliantProjectCheck)) + await jobOrchestrator.ScheduleAsync(NonCompliantProjectEvent.Create(), "0 0 7 ? * MON", stoppingToken); logger.LogInformation("Recurring jobs have been registered or updated."); }