Added scheduler jobs

This commit is contained in:
2026-10-28 18:04:52 +02:00
parent 7f686fa8c6
commit 04b5656fd1
8 changed files with 937 additions and 31 deletions
@@ -19,7 +19,7 @@ public sealed class LateProjectEvent : IEvent
public static LateProjectEvent Create() => new(); public static LateProjectEvent Create() => new();
} }
public sealed class LateProjectEventHandler(IDbContextFactory<ApplicationDbContext> contextFactory, public sealed class LateProjectEventHandler(IDbContextFactory<ApplicationDbContext> contextFactory,
ICommunicationsService service) : INotificationHandler<LateProjectEvent> ICommunicationsService service) : INotificationHandler<LateProjectEvent>
{ {
readonly int lateDays = 3; readonly int lateDays = 3;
@@ -28,50 +28,149 @@ public sealed class LateProjectEventHandler(IDbContextFactory<ApplicationDbConte
{ {
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var lateMilestones = await context.Milestones var now = DateTime.UtcNow;
var overdueCutoff = lateDays > 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() .AsNoTracking()
.Include(i => i.Project!.Programme!.Portfolio!.Organisation!.User) .Include(i => i.Project)
.Where(m => (DateTime.UtcNow - m.Project!.StartedAt!).Value.TotalDays < lateDays) .ThenInclude(p => p!.Programme)
.OrderBy(o => o.CreatedAt) .ThenInclude(pr => pr!.Owner)
.GroupBy(g => g.ProjectId) .Include(i => i.Project)
.AsSplitQuery() .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); .ToListAsync(cancellationToken);
if (lateMilestones is null) return; if (overdueMilestones.Count == 0) return;
using var htmlTemplateReader = new StreamReader(Assembly.GetAssembly(typeof(IDomain))? var lateMilestonesByProject = overdueMilestones.GroupBy(g => g.ProjectId);
.GetManifestResourceStream("PostFundManagement.Domain.Templates.lateprojectsmilestones.html")!);
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; if (project is null) continue;
var mostLateMilestone = group.OrderBy(m => m.DueAt).FirstOrDefault(); var mostLateMilestone = milestones.OrderBy(m => m.DueAt).FirstOrDefault();
var missedMilestone = group.Where(m => DateTime.UtcNow > m.DueAt).FirstOrDefault();
var messageBody = template; var daysOverdue = mostLateMilestone != null
messageBody = messageBody.Replace("[ProjectName]", project.Name); ? (int)Math.Max(0, (now - mostLateMilestone.DueAt).TotalDays)
messageBody = messageBody.Replace("[ProjectStartDate]", $"{project.StartedAt:yyyy-MM-dd}"); : 0;
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 organisation = mostLateMilestone?.Project?.Programme?.Portfolio?.Organisation; var organisation = mostLateMilestone?.Project?.Programme?.Portfolio?.Organisation;
if (organisation is null) continue; if (organisation is null) continue;
var notificationRequest = new CreateNotificationRequest(organisation.Id, organisation.User!.Id, NotificationPlatform.Email, var recipientId = organisation.UserId ?? organisation.CreatedBy;
"Project Status Update", organisation.User!.Email, messageBody, NotificationStatus.Queued); 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($@"
<tr style=""border-bottom: 1px solid #e2e8f0;"">
<td style=""padding: 10px 14px; font-weight: 500; color: #1e293b;"">{milestoneName}</td>
<td style=""padding: 10px 14px; color: #64748b; font-size: 13px;"">{milestone.DueAt:yyyy-MM-dd}</td>
<td style=""padding: 10px 14px; color: #dc2626; font-weight: 600; font-size: 13px;"">+{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;"">{statusText}</span></td>
</tr>");
}
// 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); _ = 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);
}
}
} }
} }
} }
@@ -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);
}
}
}
}
}
@@ -20,7 +20,7 @@ public static class Constants
public const string DatabaseConfigName = "PfmDatabase"; public const string DatabaseConfigName = "PfmDatabase";
public const string DefaultSchedulerName = "pfm-scheduler"; public const string DefaultSchedulerName = "pfm-scheduler";
public const string SchedulerDatabaseConfigName = "PfmSchedulerDatabase"; public const string SchedulerDatabaseConfigName = "PfmSchedulerDatabase";
public const int LabelLength = 256; public const int LabelLength = 256;
public const int ShortLabelLength = 100; public const int ShortLabelLength = 100;
public const int TextLength = 1024; public const int TextLength = 1024;
@@ -29,6 +29,7 @@ public static class Constants
public const string FeatureEmailProcessor = "EmailProcessorJobEnabled"; public const string FeatureEmailProcessor = "EmailProcessorJobEnabled";
public const string FeatureLateProjectReminder = "LateProjectReminderEnabled"; public const string FeatureLateProjectReminder = "LateProjectReminderEnabled";
public const string FeatureNonCompliantProjectCheck = "NonCompliantProjectCheckEnabled";
public const string FeatureServiceCaching = "ServiceCaching"; 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 */; public static Guid SystemUser { get; } = new(0x6059f1e4, 0x6a41, 0x4447, 0xa5, 0x46, 0x86, 0xec, 0xe5, 0x10, 0x20, 0x4) /* 6059f1e4-6a41-4447-a546-86ece5102004 */;
@@ -3,8 +3,10 @@
<ItemGroup> <ItemGroup>
<None Remove="Templates\lateprojectsmilestones.html" /> <None Remove="Templates\lateprojectsmilestones.html" />
<None Remove="Templates\noncompliantprojects.html" /> <None Remove="Templates\noncompliantprojects.html" />
<None Remove="Templates\noncompliantprojects-prog.html" />
<EmbeddedResource Include="Templates\lateprojectsmilestones.html" /> <EmbeddedResource Include="Templates\lateprojectsmilestones.html" />
<EmbeddedResource Include="Templates\noncompliantprojects.html" /> <EmbeddedResource Include="Templates\noncompliantprojects.html" />
<EmbeddedResource Include="Templates\noncompliantprojects-prog.html" />
</ItemGroup> </ItemGroup>
<!-- Transformation --> <!-- Transformation -->
@@ -0,0 +1,188 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Project Milestone Overdue Notification</title>
<!--[if mso]>
<style type="text/css">
body, table, td { font-family: Arial, Helvetica, sans-serif !important; }
</style>
<![endif]-->
</head>
<body style="margin: 0; padding: 0; background-color: #f1f5f9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; color: #1e293b; line-height: 1.6;">
<!-- Wrapper Table -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f1f5f9; padding: 32px 16px;">
<tr>
<td align="center">
<!-- Main Container Card (600px) -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px; background-color: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0;">
<!-- Header Bar -->
<tr>
<td style="background-color: #0f172a; padding: 24px 32px; border-bottom: 3px solid #e11d48;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td>
<div style="font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 1.2px; margin-bottom: 4px;">
Post-Fund Management Platform (PFM)
</div>
<div style="font-size: 18px; font-weight: 700; color: #ffffff; letter-spacing: -0.3px;">
Portfolio Performance &amp; Monitoring
</div>
</td>
<td align="right" style="vertical-align: middle;">
<span style="display: inline-block; background-color: rgba(225, 29, 72, 0.2); border: 1px solid rgba(225, 29, 72, 0.5); color: #fda4af; padding: 4px 10px; border-radius: 9999px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">
Action Required
</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- Alert Banner -->
<tr>
<td style="background-color: #fff1f2; border-bottom: 1px solid #ffe4e6; padding: 16px 32px;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td style="width: 28px; vertical-align: top; padding-right: 12px;">
<div style="width: 24px; height: 24px; background-color: #e11d48; border-radius: 50%; color: #ffffff; text-align: center; line-height: 24px; font-size: 13px; font-weight: bold;">
!
</div>
</td>
<td>
<div style="font-size: 14px; font-weight: 700; color: #9f1239; margin-bottom: 2px;">
Overdue Milestone Notice: [TotalLateMilestones] Milestone(s) Exceeded Due Date
</div>
<div style="font-size: 12px; color: #be123c;">
Automated daily sweep flagged scheduled deliverables requiring evidence submission or status updates.
</div>
</td>
</tr>
</table>
</td>
</tr>
<!-- Content Body -->
<tr>
<td style="padding: 32px;">
<!-- Salutation -->
<p style="margin: 0 0 16px 0; font-size: 15px; color: #334155;">
Dear <strong>[OrganisationName]</strong> Team,
</p>
<!-- Message -->
<p style="margin: 0 0 24px 0; font-size: 14px; color: #475569; line-height: 1.6;">
During our automated lifecycle monitoring sweep, our platform identified one or more contractual milestones for
<strong style="color: #0f172a;">[ProjectName]</strong> that have passed their agreed target completion deadlines without formal evidence approval.
</p>
<!-- Project Overview Box -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0; margin-bottom: 24px; overflow: hidden;">
<tr>
<td colspan="2" style="background-color: #f1f5f9; padding: 10px 16px; border-bottom: 1px solid #e2e8f0; font-size: 12px; font-weight: 700; color: #475569; text-transform: uppercase; letter-spacing: 0.5px;">
Project Summary
</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; width: 40%; border-bottom: 1px solid #f1f5f9;">Project Title</td>
<td style="padding: 12px 16px; font-size: 13px; font-weight: 600; color: #0f172a; border-bottom: 1px solid #f1f5f9;">[ProjectName]</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Implementation Window</td>
<td style="padding: 12px 16px; font-size: 13px; color: #334155; border-bottom: 1px solid #f1f5f9;">[ProjectStartDate] &mdash; [ProjectEndDate]</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Overdue Deliverables</td>
<td style="padding: 12px 16px; font-size: 13px; font-weight: 700; color: #e11d48; border-bottom: 1px solid #f1f5f9;">
<span style="background-color: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 4px;">[TotalLateMilestones] Milestone(s)</span>
</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-size: 13px; color: #64748b;">Most Overdue Milestone</td>
<td style="padding: 12px 16px; font-size: 13px; color: #334155;">
<strong>[MostLateMilestoneName]</strong> <span style="color: #64748b; font-size: 12px;">(Due: [MostLateMilestoneDueDate])</span>
</td>
</tr>
</table>
<!-- Detailed Milestones Table Header -->
<div style="font-size: 13px; font-weight: 700; color: #1e293b; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.5px;">
Milestone Breakdown
</div>
<!-- Milestones Table -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: collapse; margin-bottom: 24px; font-size: 13px; border: 1px solid #e2e8f0; border-radius: 6px; overflow: hidden;">
<thead>
<tr style="background-color: #f8fafc; text-align: left; border-bottom: 2px solid #e2e8f0;">
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Milestone</th>
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Due Date</th>
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Delay</th>
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Status</th>
</tr>
</thead>
<tbody>
[LateMilestonesTable]
</tbody>
</table>
<!-- Governance & SLA Callout -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f8fafc; border-left: 4px solid #0284c7; padding: 14px 18px; border-radius: 4px; margin-bottom: 28px;">
<tr>
<td>
<div style="font-size: 12px; font-weight: 700; color: #0369a1; text-transform: uppercase; margin-bottom: 4px;">
Disbursement &amp; Governance Policy (BR-003 / BR-004)
</div>
<div style="font-size: 12px; color: #475569; line-height: 1.5;">
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.
</div>
</td>
</tr>
</table>
<!-- CTA Button -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="margin-bottom: 28px;">
<tr>
<td align="center">
<a href="[PortalUrl]" target="_blank" style="display: inline-block; background-color: #0f172a; color: #ffffff; text-decoration: none; font-size: 14px; font-weight: 600; padding: 12px 32px; border-radius: 6px; box-shadow: 0 2px 4px rgba(15, 23, 42, 0.15);">
Open PFM Portal &amp; Submit Evidence &rarr;
</a>
</td>
</tr>
</table>
<p style="margin: 0; font-size: 13px; color: #64748b; line-height: 1.5;">
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.
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f8fafc; padding: 24px 32px; border-top: 1px solid #e2e8f0; font-size: 12px; color: #94a3b8; text-align: center;">
<p style="margin: 0 0 8px 0; color: #64748b; font-weight: 600;">
Post-Fund Management (PFM) Platform &bull; Automated Lifecycle &amp; SLA Daemon
</p>
<p style="margin: 0 0 12px 0; line-height: 1.4;">
This is an automated contractual notification sent in accordance with your funding agreement SLA.<br>
Please do not reply directly to this email. For support, please contact your designated Programme Officer.
</p>
<p style="margin: 0; font-size: 11px; color: #cbd5e1;">
&copy; [Year] PostFundManagement Platform. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Programme Oversight: Project Milestone Escalation Notice</title>
<!--[if mso]>
<style type="text/css">
body, table, td { font-family: Arial, Helvetica, sans-serif !important; }
</style>
<![endif]-->
</head>
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; color: #1e293b; line-height: 1.6;">
<!-- Wrapper Table -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f8fafc; padding: 32px 16px;">
<tr>
<td align="center">
<!-- Main Container Card (600px) -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px; background-color: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0;">
<!-- Header Bar -->
<tr>
<td style="background-color: #0f172a; padding: 24px 32px; border-bottom: 3px solid #d97706;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td>
<div style="font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 1.2px; margin-bottom: 4px;">
PFM Internal Governance &bull; FR-M07-009 Escalation
</div>
<div style="font-size: 18px; font-weight: 700; color: #ffffff; letter-spacing: -0.3px;">
Programme Milestone Escalation
</div>
</td>
<td align="right" style="vertical-align: middle;">
<span style="display: inline-block; background-color: rgba(217, 119, 6, 0.2); border: 1px solid rgba(217, 119, 6, 0.6); color: #fde68a; padding: 4px 10px; border-radius: 9999px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">
Internal Notice
</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- Alert Banner -->
<tr>
<td style="background-color: #fffbeb; border-bottom: 1px solid #fef3c7; padding: 16px 32px;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td style="width: 28px; vertical-align: top; padding-right: 12px;">
<div style="width: 24px; height: 24px; background-color: #d97706; border-radius: 50%; color: #ffffff; text-align: center; line-height: 24px; font-size: 13px; font-weight: bold;">
!
</div>
</td>
<td>
<div style="font-size: 14px; font-weight: 700; color: #92400e; margin-bottom: 2px;">
Milestone SLA Breach: [TotalLateMilestones] Deliverable(s) Past Due Date
</div>
<div style="font-size: 12px; color: #b45309;">
Project under your programme governance has breached milestone delivery deadlines. Automated partner reminder was dispatched.
</div>
</td>
</tr>
</table>
</td>
</tr>
<!-- Content Body -->
<tr>
<td style="padding: 32px;">
<!-- Salutation -->
<p style="margin: 0 0 16px 0; font-size: 15px; color: #334155;">
Dear <strong>[ProgrammeManagerName]</strong>,
</p>
<!-- Context Narrative -->
<p style="margin: 0 0 24px 0; font-size: 14px; color: #475569; line-height: 1.6;">
In accordance with portfolio monitoring requirement <strong>FR-M07-009</strong>, this automated escalation notice alerts you that
<strong style="color: #0f172a;">[ProjectName]</strong> has overdue milestones requiring your oversight and possible intervention.
</p>
<!-- Governance Context Box -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0; margin-bottom: 24px; overflow: hidden;">
<tr>
<td colspan="2" style="background-color: #f1f5f9; padding: 10px 16px; border-bottom: 1px solid #e2e8f0; font-size: 12px; font-weight: 700; color: #475569; text-transform: uppercase; letter-spacing: 0.5px;">
Governance &amp; Project Hierarchy
</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; width: 40%; border-bottom: 1px solid #f1f5f9;">Programme</td>
<td style="padding: 12px 16px; font-size: 13px; font-weight: 600; color: #0f172a; border-bottom: 1px solid #f1f5f9;">[ProgrammeName]</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Portfolio</td>
<td style="padding: 12px 16px; font-size: 13px; color: #334155; border-bottom: 1px solid #f1f5f9;">[PortfolioName]</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Recipient Organisation</td>
<td style="padding: 12px 16px; font-size: 13px; font-weight: 600; color: #0f172a; border-bottom: 1px solid #f1f5f9;">[OrganisationName]</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Project Title</td>
<td style="padding: 12px 16px; font-size: 13px; font-weight: 600; color: #0f172a; border-bottom: 1px solid #f1f5f9;">[ProjectName]</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Project Duration</td>
<td style="padding: 12px 16px; font-size: 13px; color: #334155; border-bottom: 1px solid #f1f5f9;">[ProjectStartDate] &mdash; [ProjectEndDate]</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-size: 13px; color: #64748b;">Overdue Status</td>
<td style="padding: 12px 16px; font-size: 13px;">
<span style="background-color: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 4px; font-weight: 700;">
[TotalLateMilestones] Milestone(s) Overdue
</span>
<span style="color: #64748b; font-size: 12px; margin-left: 6px;">(Primary: [MostLateMilestoneName], [MostLateMilestoneDaysOverdue] days late)</span>
</td>
</tr>
</table>
<!-- Detailed Milestones Table Header -->
<div style="font-size: 13px; font-weight: 700; color: #1e293b; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.5px;">
Delinquent Deliverables Breakdown
</div>
<!-- Milestones Table -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: collapse; margin-bottom: 24px; font-size: 13px; border: 1px solid #e2e8f0; border-radius: 6px; overflow: hidden;">
<thead>
<tr style="background-color: #f8fafc; text-align: left; border-bottom: 2px solid #e2e8f0;">
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Milestone</th>
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Due Date</th>
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Delay</th>
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Status</th>
</tr>
</thead>
<tbody>
[LateMilestonesTable]
</tbody>
</table>
<!-- Recommended Action Guidance -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f8fafc; border-left: 4px solid #d97706; padding: 14px 18px; border-radius: 4px; margin-bottom: 28px;">
<tr>
<td>
<div style="font-size: 12px; font-weight: 700; color: #92400e; text-transform: uppercase; margin-bottom: 4px;">
Recommended Manager Actions
</div>
<ul style="margin: 0; padding-left: 18px; font-size: 12px; color: #475569; line-height: 1.6;">
<li>Verify whether unreviewed evidence submissions are awaiting your sign-off in the Admin Console.</li>
<li>Engage recipient organisation leadership regarding timeline remediation or change requests (M15).</li>
<li>Ensure disbursements linked to gated milestones remain withheld per rule BR-003.</li>
</ul>
</td>
</tr>
</table>
<!-- CTA Button -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="margin-bottom: 24px;">
<tr>
<td align="center">
<a href="[ConsoleUrl]" target="_blank" style="display: inline-block; background-color: #0f172a; color: #ffffff; text-decoration: none; font-size: 14px; font-weight: 600; padding: 12px 32px; border-radius: 6px; box-shadow: 0 2px 4px rgba(15, 23, 42, 0.15);">
Open Programme in Console &rarr;
</a>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f8fafc; padding: 24px 32px; border-top: 1px solid #e2e8f0; font-size: 12px; color: #94a3b8; text-align: center;">
<p style="margin: 0 0 8px 0; color: #64748b; font-weight: 600;">
Post-Fund Management (PFM) Platform &bull; Automated Governance &amp; Monitoring Daemon
</p>
<p style="margin: 0 0 12px 0; line-height: 1.4;">
You received this notification because you are designated as the Programme Owner/Manager for [ProgrammeName].<br>
Automated escalation according to SRS v2.0 requirement FR-M07-009.
</p>
<p style="margin: 0; font-size: 11px; color: #cbd5e1;">
&copy; [Year] PostFundManagement Platform. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,199 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Weekly Governance Audit: Project Non-Compliance Notice</title>
<!--[if mso]>
<style type="text/css">
body, table, td { font-family: Arial, Helvetica, sans-serif !important; }
</style>
<![endif]-->
</head>
<body style="margin: 0; padding: 0; background-color: #f1f5f9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; color: #1e293b; line-height: 1.6;">
<!-- Wrapper Table -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f1f5f9; padding: 32px 16px;">
<tr>
<td align="center">
<!-- Main Container Card (600px) -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px; background-color: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0;">
<!-- Header Bar -->
<tr>
<td style="background-color: #0f172a; padding: 24px 32px; border-bottom: 3px solid #be123c;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td>
<div style="font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 1.2px; margin-bottom: 4px;">
Post-Fund Management Platform (PFM) &bull; M10 Compliance
</div>
<div style="font-size: 18px; font-weight: 700; color: #ffffff; letter-spacing: -0.3px;">
Weekly Project Compliance Audit
</div>
</td>
<td align="right" style="vertical-align: middle;">
<span style="display: inline-block; background-color: rgba(190, 18, 60, 0.2); border: 1px solid rgba(190, 18, 60, 0.6); color: #fda4af; padding: 4px 10px; border-radius: 9999px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">
Non-Compliant
</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- Alert Banner -->
<tr>
<td style="background-color: #fff1f2; border-bottom: 1px solid #ffe4e6; padding: 16px 32px;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td style="width: 28px; vertical-align: top; padding-right: 12px;">
<div style="width: 24px; height: 24px; background-color: #be123c; border-radius: 50%; color: #ffffff; text-align: center; line-height: 24px; font-size: 13px; font-weight: bold;">
!
</div>
</td>
<td>
<div style="font-size: 14px; font-weight: 700; color: #9f1239; margin-bottom: 2px;">
Action Required: Project Flagged as Non-Compliant ([ComplianceScore] Met)
</div>
<div style="font-size: 12px; color: #be123c;">
Weekly automated governance audit identified unmet statutory obligations and/or overdue milestone deliverables.
</div>
</td>
</tr>
</table>
</td>
</tr>
<!-- Content Body -->
<tr>
<td style="padding: 32px;">
<!-- Salutation -->
<p style="margin: 0 0 16px 0; font-size: 15px; color: #334155;">
Dear <strong>[OrganisationName]</strong> Team,
</p>
<!-- Narrative -->
<p style="margin: 0 0 24px 0; font-size: 14px; color: #475569; line-height: 1.6;">
During our weekly automated compliance audit, <strong style="color: #0f172a;">[ProjectName]</strong> (under programme <strong>[ProgrammeName]</strong>) did not satisfy the mandatory statutory and contractual compliance threshold. In accordance with system governance policies, immediate remediation is required.
</p>
<!-- Project Summary Box -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0; margin-bottom: 24px; overflow: hidden;">
<tr>
<td colspan="2" style="background-color: #f1f5f9; padding: 10px 16px; border-bottom: 1px solid #e2e8f0; font-size: 12px; font-weight: 700; color: #475569; text-transform: uppercase; letter-spacing: 0.5px;">
Audit Findings Overview
</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; width: 40%; border-bottom: 1px solid #f1f5f9;">Project Title</td>
<td style="padding: 12px 16px; font-size: 13px; font-weight: 600; color: #0f172a; border-bottom: 1px solid #f1f5f9;">[ProjectName]</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Programme / Portfolio</td>
<td style="padding: 12px 16px; font-size: 13px; color: #334155; border-bottom: 1px solid #f1f5f9;">[ProgrammeName] &bull; [PortfolioName]</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Implementation Window</td>
<td style="padding: 12px 16px; font-size: 13px; color: #334155; border-bottom: 1px solid #f1f5f9;">[ProjectStartDate] &mdash; [ProjectEndDate]</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Compliance Verdict</td>
<td style="padding: 12px 16px; font-size: 13px; border-bottom: 1px solid #f1f5f9;">
<span style="background-color: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 4px; font-weight: 700;">
Non-Compliant &mdash; Score: [ComplianceScore]
</span>
</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Unmet Compliance Items</td>
<td style="padding: 12px 16px; font-size: 13px; font-weight: 600; color: #be123c; border-bottom: 1px solid #f1f5f9;">
[UnmetComplianceCount] Item(s) Pending Resolution
</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-size: 13px; color: #64748b;">Overdue Milestones</td>
<td style="padding: 12px 16px; font-size: 13px; font-weight: 600; color: #be123c;">
[UnmetMilestonesCount] Milestone(s) Overdue
</td>
</tr>
</table>
<!-- Items Table Header -->
<div style="font-size: 13px; font-weight: 700; color: #1e293b; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.5px;">
Outstanding Compliance &amp; Deliverable Items
</div>
<!-- Non-Compliance Items Table -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: collapse; margin-bottom: 24px; font-size: 13px; border: 1px solid #e2e8f0; border-radius: 6px; overflow: hidden;">
<thead>
<tr style="background-color: #f8fafc; text-align: left; border-bottom: 2px solid #e2e8f0;">
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Category</th>
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Item / Obligation</th>
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Details / Due Date</th>
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Status</th>
</tr>
</thead>
<tbody>
[NonComplianceTable]
</tbody>
</table>
<!-- Governance & Disbursement Hold Callout -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #fff1f2; border-left: 4px solid #be123c; padding: 14px 18px; border-radius: 4px; margin-bottom: 28px;">
<tr>
<td>
<div style="font-size: 12px; font-weight: 700; color: #9f1239; text-transform: uppercase; margin-bottom: 4px;">
Disbursement Hold Notice (BR-003 / AT-08)
</div>
<div style="font-size: 12px; color: #475569; line-height: 1.5;">
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.
</div>
</td>
</tr>
</table>
<!-- CTA Button -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="margin-bottom: 28px;">
<tr>
<td align="center">
<a href="[PortalUrl]" target="_blank" style="display: inline-block; background-color: #0f172a; color: #ffffff; text-decoration: none; font-size: 14px; font-weight: 600; padding: 12px 32px; border-radius: 6px; box-shadow: 0 2px 4px rgba(15, 23, 42, 0.15);">
Open Partner Portal &amp; Submit Compliance Evidence &rarr;
</a>
</td>
</tr>
</table>
<p style="margin: 0; font-size: 13px; color: #64748b; line-height: 1.5;">
Once evidence is submitted, your designated Programme Officer will review and verify the documentation to restore your project's 100% compliant status.
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f8fafc; padding: 24px 32px; border-top: 1px solid #e2e8f0; font-size: 12px; color: #94a3b8; text-align: center;">
<p style="margin: 0 0 8px 0; color: #64748b; font-weight: 600;">
Post-Fund Management (PFM) Platform &bull; Weekly Compliance Audit Daemon
</p>
<p style="margin: 0 0 12px 0; line-height: 1.4;">
Automated statutory oversight notification generated in accordance with SRS v2.0 Module M10.<br>
Please do not reply directly to this email. For assistance, contact your Programme Officer.
</p>
<p style="margin: 0; font-size: 11px; color: #cbd5e1;">
&copy; [Year] PostFundManagement Platform. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+4 -3
View File
@@ -22,9 +22,10 @@ public class JobsWorker(IJobOrchestrator jobOrchestrator, IFeatureManager featur
await jobOrchestrator.ScheduleAsync(ProcessEmailEvent.Create(), "0 */5 * * * ?", stoppingToken); await jobOrchestrator.ScheduleAsync(ProcessEmailEvent.Create(), "0 */5 * * * ?", stoppingToken);
if (await featureManager.IsEnabledAsync(FeatureLateProjectReminder)) if (await featureManager.IsEnabledAsync(FeatureLateProjectReminder))
await jobOrchestrator.ScheduleAsync(LateProjectEvent.Create(), "", stoppingToken); await jobOrchestrator.ScheduleAsync(LateProjectEvent.Create(), "0 30 6 * * ?", stoppingToken);
// TODO: Add job to check for non-compliant projects once a week if (await featureManager.IsEnabledAsync(FeatureNonCompliantProjectCheck))
await jobOrchestrator.ScheduleAsync(NonCompliantProjectEvent.Create(), "0 0 7 ? * MON", stoppingToken);
logger.LogInformation("Recurring jobs have been registered or updated."); logger.LogInformation("Recurring jobs have been registered or updated.");
} }