diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/InvoiceManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/InvoiceManager.cs index 599a96d37a..197b75a943 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/InvoiceManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/InvoiceManager.cs @@ -63,50 +63,48 @@ public async Task UpdatePaymentRequestWithInvoiceAsync(Guid paymentRequestId, In try { // Each attempt must have a fresh UoW - using (var uow = unitOfWorkManager.Begin()) - { - // Load with tracking - var paymentRequest = await paymentRequestRepository.GetAsync(paymentRequestId); - - if (paymentRequest == null) - { - Logger.LogWarning("PaymentRequest {Id} not found. Skipping update.", paymentRequestId); - return; - } - - // Idempotency: do not re-process - if (paymentRequest.InvoiceStatus == CasPaymentRequestStatus.SentToCas) - { - Logger.LogInformation( - "PaymentRequest {Id} already invoiced. Skipping update.", - paymentRequestId - ); - return; - } - - // Apply CAS response info - paymentRequest.SetCasHttpStatusCode((int)invoiceResponse.CASHttpStatusCode); - paymentRequest.SetCasResponse(invoiceResponse.CASReturnedMessages); - - // Set status - paymentRequest.SetInvoiceStatus( - invoiceResponse.IsSuccess() - ? CasPaymentRequestStatus.SentToCas - : CasPaymentRequestStatus.ErrorFromCas - ); + using var uow = unitOfWorkManager.Begin(); + // Load with tracking + var paymentRequest = await paymentRequestRepository.GetAsync(paymentRequestId); - await paymentRequestRepository.UpdateAsync(paymentRequest, autoSave: false); - - // Commit this attempt - await uow.CompleteAsync(); + if (paymentRequest == null) + { + Logger.LogWarning("PaymentRequest {Id} not found. Skipping update.", paymentRequestId); + return; + } + // Idempotency: do not re-process + if (paymentRequest.InvoiceStatus == CasPaymentRequestStatus.SentToCas) + { Logger.LogInformation( - "PaymentRequest {Id} updated successfully on attempt {Attempt}.", - paymentRequestId, - attempt + "PaymentRequest {Id} already invoiced. Skipping update.", + paymentRequestId ); - return; // success + return; } + + // Apply CAS response info + paymentRequest.SetCasHttpStatusCode((int)invoiceResponse.CASHttpStatusCode); + paymentRequest.SetCasResponse(invoiceResponse.CASReturnedMessages); + + // Set status + paymentRequest.SetInvoiceStatus( + invoiceResponse.IsSuccess() + ? CasPaymentRequestStatus.SentToCas + : CasPaymentRequestStatus.ErrorFromCas + ); + + await paymentRequestRepository.UpdateAsync(paymentRequest, autoSave: false); + + // Commit this attempt + await uow.CompleteAsync(); + + Logger.LogInformation( + "PaymentRequest {Id} updated successfully on attempt {Attempt}.", + paymentRequestId, + attempt + ); + return; // success } catch (Exception ex) when ( ex is AbpDbConcurrencyException || diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs index 2b2e2c831e..c46f71e5a3 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs @@ -15,8 +15,8 @@ namespace Unity.Payments.Repositories { public class PaymentRequestRepository : EfCoreRepository, IPaymentRequestRepository { - private List ReCheckStatusList { get; set; } = new List(); - private List FailedStatusList { get; set; } = new List(); + private List ReCheckStatusList { get; set; } = []; + private List FailedStatusList { get; set; } = []; public PaymentRequestRepository(IDbContextProvider dbContextProvider) : base(dbContextProvider) { diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/InvoiceService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/InvoiceService.cs index 01a02afc5a..3cc3b643e4 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/InvoiceService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/InvoiceService.cs @@ -20,6 +20,7 @@ using System.Linq; using Volo.Abp.Domain.Repositories; using Unity.SharedKernel.Utilities; +using Volo.Abp.Identity; namespace Unity.Payments.Integrations.Cas { @@ -31,10 +32,14 @@ public class InvoiceService( IEndpointManagementAppService endpointManagementAppService, ICasTokenService iTokenService, IResilientHttpRequest resilientHttpRequest, - IInvoiceManager invoiceManager) : ApplicationService, IInvoiceService + IInvoiceManager invoiceManager, + IRepository expenseApprovalRepository, + IRepository identityUserRepository) : ApplicationService, IInvoiceService { private const string CFS_APINVOICE = "cfs/apinvoice"; - protected new ICurrentTenant CurrentTenant => LazyServiceProvider.LazyGetRequiredService(); + + protected new ICurrentTenant CurrentTenant => + LazyServiceProvider.LazyGetRequiredService(); private readonly Dictionary CASPaymentGroup = new() { @@ -42,127 +47,192 @@ public class InvoiceService( [(int)PaymentGroup.Cheque] = "GEN CHQ" }; - protected virtual async Task InitializeCASInvoice(PaymentRequest paymentRequest, - string? accountDistributionCode) + protected virtual async Task InitializeCASInvoice( + PaymentRequest paymentRequest, + string? accountDistributionCode) { - Invoice? casInvoice = new(); Site? site = await invoiceManager.GetSiteByPaymentRequestAsync(paymentRequest); - if (site != null && site.Supplier != null && site.Supplier.Number != null && accountDistributionCode != null) + if (site == null || + site.Supplier == null || + string.IsNullOrWhiteSpace(site.Supplier.Number) || + string.IsNullOrWhiteSpace(accountDistributionCode)) { - // This can not be UTC Now it is sent to cas and can not be in the future - this is not being stored in Unity as a date - var vancouverTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time"); - var localDateTime = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, vancouverTimeZone); - var currentMonth = localDateTime.ToString("MMM").Trim('.'); - var currentDay = localDateTime.ToString("dd"); - var currentYear = localDateTime.ToString("yyyy"); - var dateStringDayMonYear = $"{currentDay}-{currentMonth}-{currentYear}"; - - casInvoice.SupplierNumber = site.Supplier.Number; // This is from each Applicant - casInvoice.SupplierName = site.Supplier.Name; - casInvoice.SupplierSiteNumber = site.Number; - casInvoice.PayGroup = CASPaymentGroup[(int)site.PaymentGroup]; // GEN CHQ - other options - casInvoice.InvoiceNumber = paymentRequest.InvoiceNumber; - casInvoice.InvoiceDate = dateStringDayMonYear; //DD-MMM-YYYY - casInvoice.DateInvoiceReceived = dateStringDayMonYear; - casInvoice.GlDate = dateStringDayMonYear; - casInvoice.InvoiceAmount = paymentRequest.Amount; - casInvoice.InvoiceBatchName = paymentRequest.BatchName; - // Payment description: build or use existing - casInvoice.PaymentAdviceComments = await BuildPaymentDescriptionAsync(paymentRequest.Description); + return null; + } - // Set QualifiedReceiver to the Level1 approver's user name (lookup by DecisionUserId when possible) - casInvoice.QualifiedReceiver = await GetLevel1DecisionUserNameAsync(paymentRequest); - InvoiceLineDetail invoiceLineDetail = new() - { - InvoiceLineNumber = 1, - InvoiceLineAmount = paymentRequest.Amount, - DefaultDistributionAccount = accountDistributionCode // This will be at the tenant level - }; - casInvoice.InvoiceLineDetails = new List { invoiceLineDetail }; + // This can not be UTC Now it is sent to cas and can not be in the future - this is not being stored in Unity as a date + var vancouverTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time"); + var localDateTime = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, vancouverTimeZone); + var currentMonth = localDateTime.ToString("MMM").Trim('.'); + var currentDay = localDateTime.ToString("dd"); + var currentYear = localDateTime.ToString("yyyy"); + var dateStringDayMonYear = $"{currentDay}-{currentMonth}-{currentYear}"; + + if (!CASPaymentGroup.TryGetValue((int)site.PaymentGroup, out var payGroup)) + { + throw new UserFriendlyException( + $"Unsupported payment group: {site.PaymentGroup}"); } + var casInvoice = new Invoice + { + SupplierNumber = site.Supplier.Number, + SupplierName = site.Supplier.Name, + SupplierSiteNumber = site.Number, + PayGroup = payGroup, + InvoiceNumber = paymentRequest.InvoiceNumber, + InvoiceDate = dateStringDayMonYear, + DateInvoiceReceived = dateStringDayMonYear, + GlDate = dateStringDayMonYear, + InvoiceAmount = paymentRequest.Amount, + InvoiceBatchName = paymentRequest.BatchName, + + // Payment description: build or use existing + PaymentAdviceComments = + await BuildPaymentDescriptionAsync(paymentRequest.Description), + + // Level1 approver username + QualifiedReceiver = + await GetLevel1DecisionUserNameAsync(paymentRequest), + + InvoiceLineDetails = + [ + new() + { + InvoiceLineNumber = 1, + InvoiceLineAmount = paymentRequest.Amount, + DefaultDistributionAccount = accountDistributionCode + } + ] + }; + return casInvoice; } - private async Task GetLevel1DecisionUserNameAsync(PaymentRequest paymentRequest) + private async Task GetLevel1DecisionUserNameAsync( + PaymentRequest? paymentRequest) { - if (paymentRequest?.ExpenseApprovals == null) + if (paymentRequest == null) + { return string.Empty; + } - var decisionUserId = paymentRequest.ExpenseApprovals.FirstOrDefault(x => x.Type == ExpenseApprovalType.Level1)?.DecisionUserId; - if (decisionUserId == null || decisionUserId == Guid.Empty) - return string.Empty; + Guid? decisionUserId = null; try { - // Try to resolve a repository for IdentityUser: IRepository - var repoType = typeof(IRepository<,>).MakeGenericType(typeof(Volo.Abp.Identity.IdentityUser), typeof(Guid)); - var repoObj = LazyServiceProvider.LazyGetService(repoType); - if (repoObj != null) + if (paymentRequest.ExpenseApprovals == null || + paymentRequest.ExpenseApprovals.Count == 0) { - // Use dynamic to call FindAsync - dynamic repo = repoObj; - var user = await repo.FindAsync((Guid)decisionUserId); - if (user != null) - { - // Prefer UserName, then full name (Name + Surname), then fallback to id - string? userName = user.UserName as string; - if (!string.IsNullOrWhiteSpace(userName)) - return userName; - - var givenName = user.Name as string; - var surname = user.Surname as string; - if (!string.IsNullOrWhiteSpace(givenName) || !string.IsNullOrWhiteSpace(surname)) - return $"{givenName} {surname}".Trim(); - } + var approvals = await expenseApprovalRepository.GetListAsync( + a => a.PaymentRequestId == paymentRequest.Id && + a.Type == ExpenseApprovalType.Level1); + + decisionUserId = approvals + .FirstOrDefault()? + .DecisionUserId; } + else + { + decisionUserId = paymentRequest.ExpenseApprovals + .FirstOrDefault(x => x.Type == ExpenseApprovalType.Level1)? + .DecisionUserId; + } + + if (decisionUserId == null || decisionUserId == Guid.Empty) + { + return string.Empty; + } + + var user = await identityUserRepository.FindAsync( + (Guid)decisionUserId); + + if (user == null) + { + return string.Empty; + } + + if (!string.IsNullOrWhiteSpace(user.UserName)) + { + return user.UserName; + } + + var fullName = $"{user.Name} {user.Surname}".Trim(); + + return string.IsNullOrWhiteSpace(fullName) + ? string.Empty + : fullName; } - catch + catch (Exception ex) { - // ignore and fallback - } + Logger.LogWarning( + ex, + "Failed resolving Level1 approver for payment request {PaymentRequestId}", + paymentRequest.Id); - return string.Empty; + return string.Empty; + } } - // Tenant/CurrentUser lookups: resolve from IServiceProvider (no dependency on Web project utilities) - private async Task BuildPaymentDescriptionAsync(string? existingDescription) + private async Task BuildPaymentDescriptionAsync( + string? existingDescription) { if (!string.IsNullOrWhiteSpace(existingDescription)) { var trimmed = existingDescription.Trim(); - return trimmed.Length > 50 ? trimmed.Substring(0, 50) : trimmed; + + return trimmed.Length > 50 + ? trimmed[..50] + : trimmed; } - // Resolve tenant name via shared helper - var serviceProvider = LazyServiceProvider.LazyGetRequiredService(); - var tenantDesc = await AbpUserTenantAccessor.GetCurrentTenantNameAsync(serviceProvider) ?? string.Empty; + + var serviceProvider = + LazyServiceProvider.LazyGetRequiredService(); + + var tenantDesc = + await AbpUserTenantAccessor.GetCurrentTenantNameAsync(serviceProvider) + ?? string.Empty; + var generated = string.IsNullOrWhiteSpace(tenantDesc) ? "Grant Payment" : $"{tenantDesc} – Grant Payment"; + if (generated.Length > 50) - generated = generated.Substring(0, 50); + { + generated = generated[..50]; + } + return generated; } - public async Task CreateInvoiceByPaymentRequestAsync(string invoiceNumber) + public async Task CreateInvoiceByPaymentRequestAsync( + string invoiceNumber) { InvoiceResponse invoiceResponse = new(); + try { - var paymentRequestData = await invoiceManager.GetPaymentRequestDataAsync(invoiceNumber); + var paymentRequestData = + await invoiceManager.GetPaymentRequestDataAsync(invoiceNumber); - if (!string.IsNullOrEmpty(paymentRequestData.AccountDistributionCode)) + if (!string.IsNullOrWhiteSpace( + paymentRequestData.AccountDistributionCode)) { - Invoice? invoice = await InitializeCASInvoice(paymentRequestData.PaymentRequest, paymentRequestData.AccountDistributionCode); + var invoice = await InitializeCASInvoice( + paymentRequestData.PaymentRequest, + paymentRequestData.AccountDistributionCode); - if (invoice is not null) + if (invoice != null) { invoiceResponse = await CreateInvoiceAsync(invoice); - if (invoiceResponse is not null) + + if (invoiceResponse != null) { - await invoiceManager.UpdatePaymentRequestWithInvoiceAsync(paymentRequestData.PaymentRequest.Id, invoiceResponse); + await invoiceManager.UpdatePaymentRequestWithInvoiceAsync( + paymentRequestData.PaymentRequest.Id, + invoiceResponse); } } } @@ -178,59 +248,84 @@ private async Task BuildPaymentDescriptionAsync(string? existingDescript public async Task CreateInvoiceAsync(Invoice casAPInvoice) { - string jsonString = JsonSerializer.Serialize(casAPInvoice); + string jsonString = JsonSerializer.Serialize(casAPInvoice); var authToken = await iTokenService.GetAuthTokenAsync(CurrentTenant.Id ?? Guid.Empty); string casBaseUrl = await endpointManagementAppService.GetUgmUrlByKeyNameAsync(DynamicUrlKeyNames.PAYMENT_API_BASE); var resource = $"{casBaseUrl}/{CFS_APINVOICE}/"; - var response = await resilientHttpRequest.HttpAsync(HttpMethod.Post, resource, jsonString, authToken); - - if (response != null) + var response = await resilientHttpRequest.HttpAsync(HttpMethod.Post, resource, jsonString, authToken) + ?? throw new UserFriendlyException("CAS InvoiceService CreateInvoiceAsync: Null response"); + if (response.Content != null && + response.StatusCode != HttpStatusCode.NotFound) { - if (response.Content != null && response.StatusCode != HttpStatusCode.NotFound) - { - var contentString = await ResilientHttpRequest.ContentToStringAsync(response.Content); - var result = JsonSerializer.Deserialize(contentString) - ?? throw new UserFriendlyException("CAS InvoiceService CreateInvoiceAsync Exception: " + response); - result.CASHttpStatusCode = response.StatusCode; - return result; - } - else if (response.RequestMessage != null) - { - throw new UserFriendlyException("CAS InvoiceService CreateInvoiceAsync Exception: " + response.RequestMessage); - } - else - { - throw new UserFriendlyException("CAS InvoiceService CreateInvoiceAsync Exception: " + response); - } + var contentString = + await ResilientHttpRequest.ContentToStringAsync( + response.Content); + + var result = + JsonSerializer.Deserialize(contentString) + ?? throw new UserFriendlyException( + $"CAS InvoiceService CreateInvoiceAsync Exception: {response}"); + + result.CASHttpStatusCode = response.StatusCode; + + return result; } - else + + if (response.RequestMessage != null) { - throw new UserFriendlyException("CAS InvoiceService CreateInvoiceAsync: Null response"); + throw new UserFriendlyException( + $"CAS InvoiceService CreateInvoiceAsync Exception: {response.RequestMessage}"); } + + throw new UserFriendlyException( + $"CAS InvoiceService CreateInvoiceAsync Exception: {response}"); } - public async Task GetCasInvoiceAsync(string invoiceNumber, string supplierNumber, string supplierSiteCode) + public async Task GetCasInvoiceAsync( + string invoiceNumber, + string supplierNumber, + string supplierSiteCode) { - var authToken = await iTokenService.GetAuthTokenAsync(CurrentTenant.Id ?? Guid.Empty); - var casBaseUrl = await endpointManagementAppService.GetUgmUrlByKeyNameAsync(DynamicUrlKeyNames.PAYMENT_API_BASE); - var resource = $"{casBaseUrl}/{CFS_APINVOICE}/{invoiceNumber}/{supplierNumber}/{supplierSiteCode}"; - var response = await resilientHttpRequest.HttpAsync(HttpMethod.Get, resource, body: null, authToken); + var authToken = + await iTokenService.GetAuthTokenAsync( + CurrentTenant.Id ?? Guid.Empty); + + var casBaseUrl = + await endpointManagementAppService.GetUgmUrlByKeyNameAsync( + DynamicUrlKeyNames.PAYMENT_API_BASE); + + var resource = + $"{casBaseUrl}/{CFS_APINVOICE}/{invoiceNumber}/{supplierNumber}/{supplierSiteCode}"; - if (response != null - && response.Content != null - && response.IsSuccessStatusCode) + var response = await resilientHttpRequest.HttpAsync( + HttpMethod.Get, + resource, + body: null, + authToken); + + if (response != null && + response.Content != null && + response.IsSuccessStatusCode) { - string contentString = await ResilientHttpRequest.ContentToStringAsync(response.Content); - var result = JsonSerializer.Deserialize(contentString); + string contentString = + await ResilientHttpRequest.ContentToStringAsync( + response.Content); + + var result = + JsonSerializer.Deserialize( + contentString); + return result ?? new CasPaymentSearchResult(); } - else - { - return new CasPaymentSearchResult() { }; - } + + return new CasPaymentSearchResult(); } - public async Task GetCasPaymentAsync(Guid tenantId, string invoiceNumber, string supplierNumber, string siteNumber) + public async Task GetCasPaymentAsync( + Guid tenantId, + string invoiceNumber, + string supplierNumber, + string siteNumber) { Logger.LogInformation("GetCasPaymentAsync for Invoice: {InvoiceNumber}, SupplierNumber: {SupplierNumber}, SiteNumber: {SiteNumber}, TenantId: {TenantId}", invoiceNumber, supplierNumber, siteNumber, tenantId); var authToken = await iTokenService.GetAuthTokenAsync(tenantId); @@ -239,15 +334,20 @@ public async Task GetCasPaymentAsync(Guid tenantId, stri var response = await resilientHttpRequest.HttpAsync(HttpMethod.Get, resource, body: null, authToken); CasPaymentSearchResult casPaymentSearchResult = new(); - if (response != null - && response.Content != null - && response.IsSuccessStatusCode) + if (response != null && + response.Content != null && + response.IsSuccessStatusCode) { - var content = response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(content.Result); + var content = + await response.Content.ReadAsStringAsync(); + + var result = + JsonSerializer.Deserialize(content); + return result ?? casPaymentSearchResult; } - else if (response != null) + + if (response != null) { casPaymentSearchResult.InvoiceStatus = response.StatusCode.ToString(); } @@ -256,22 +356,27 @@ public async Task GetCasPaymentAsync(Guid tenantId, stri } } -#pragma warning disable S125 // Sections of code should not be commented out +#pragma warning disable S125 /* // - Example Response for GET: - { - "invoice_number": "TESTINVOICE2", - "invoice_status": "Validated", - "payment_status": " Paid", - "payment_number": "009877676", - "payment_date": "25-Aug-2017" - } + + Example Response for GET: + + { + "invoice_number": "TESTINVOICE2", + "invoice_status": "Validated", + "payment_status": " Paid", + "payment_number": "009877676", + "payment_date": "25-Aug-2017" + } Void Payment Webservices Request Format, Type POST + https://:/ords/cas/cfs/apinvoice/ - Sample JSON File – Regular Standard Invoice - Web Service + + Sample JSON File – Regular Standard Invoice - Web Service + { "invoiceType": "Standard", "supplierNumber": "3125635", @@ -296,8 +401,8 @@ Sample JSON File – Regular Standard Invoice - Web Service "glDate": "06-MAR-2023", "invoiceBatchName": "CASAPWEB1", "currencyCode": "CAD", - "invoiceLineDetails": - [{ + "invoiceLineDetails": + [{ "invoiceLineNumber": 1, "invoiceLineType": "Item", "lineCode": "DR", @@ -309,8 +414,9 @@ Sample JSON File – Regular Standard Invoice - Web Service "info1": "", "info2": "", "info3": "" - }] + }] } */ -#pragma warning restore S125 // Sections of code should not be commented out + +#pragma warning restore S125 } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialNotificationSummaryWorker.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialNotificationSummaryWorker.cs index f8ada042ec..84e5e516d0 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialNotificationSummaryWorker.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialNotificationSummaryWorker.cs @@ -18,6 +18,8 @@ public class FinancialNotificationSummaryWorker : QuartzBackgroundWorkerBase private readonly FinancialSummaryNotifier _financialSummaryNotifier; private readonly IEnumerable _strategies; + private const string FallbackCron = "0 0 9 1/1 * ? *"; + public FinancialNotificationSummaryWorker( ISettingManager settingManager, FinancialSummaryNotifier financialSummaryNotifier, @@ -27,40 +29,52 @@ public FinancialNotificationSummaryWorker( _financialSummaryNotifier = financialSummaryNotifier; _strategies = strategies; - logger.LogInformation("FinancialNotificationSummary Constructor: Email strategies registered."); + var cronExpression = ResolveCronExpression(settingManager, logger); - string casFinancialNotificationExpression = ""; + JobDetail = JobBuilder + .Create() + .WithIdentity(nameof(FinancialNotificationSummaryWorker)) + .Build(); - try - { - casFinancialNotificationExpression = SettingDefinitions - .GetSettingsValue(settingManager, - PaymentSettingsConstants.BackgroundJobs.CasFinancialNotificationSummary_ProducerExpression); - } - catch - { - casFinancialNotificationExpression = "0 0 9 1/1 * ? *"; - } - - if (!casFinancialNotificationExpression.IsNullOrEmpty()) - { - - JobDetail = JobBuilder - .Create() - .WithIdentity(nameof(FinancialNotificationSummaryWorker)) - .Build(); - - Trigger = TriggerBuilder - .Create() - .WithIdentity(nameof(FinancialNotificationSummaryWorker)) - .WithSchedule(CronScheduleBuilder.CronSchedule(casFinancialNotificationExpression) + Trigger = TriggerBuilder + .Create() + .WithIdentity(nameof(FinancialNotificationSummaryWorker)) + .WithSchedule(CronScheduleBuilder + .CronSchedule(cronExpression) .WithMisfireHandlingInstructionIgnoreMisfires()) - .Build(); - } + .Build(); } public override async Task Execute(IJobExecutionContext context) { + Logger.LogInformation("FinancialNotificationSummary Execute"); await _financialSummaryNotifier.NotifyFailedPayments(_strategies); } + + private static string ResolveCronExpression(ISettingManager settingManager, ILogger logger) + { + try + { + var expression = SettingDefinitions.GetSettingsValue( + settingManager, + PaymentSettingsConstants.BackgroundJobs.CasFinancialNotificationSummary_ProducerExpression); + + if (!expression.IsNullOrEmpty()) + { + return expression; + } + + logger.LogWarning( + "FinancialNotificationSummary: Cron expression setting was empty. Using fallback: {Fallback}", + FallbackCron); + } + catch (Exception ex) + { + logger.LogWarning(ex, + "FinancialNotificationSummary: Failed to read cron expression setting. Using fallback: {Fallback}", + FallbackCron); + } + + return FallbackCron; + } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/FieldPathTypeDto.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/FieldPathTypeDto.cs index 6d74905038..e50335bf1c 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/FieldPathTypeDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/FieldPathTypeDto.cs @@ -39,5 +39,13 @@ public class FieldPathTypeDto /// The path to reach the data, this is a datacentric version of the Path, and could be the same /// public string DataPath { get; set; } = string.Empty; + + /// + /// Optional version label used only by the consolidated providers. + /// Null means the field is merged across all versions ("All"). + /// A single value (e.g., "v1") means the field is exclusive to that version. + /// A comma-separated value (e.g., "v1, v2") means the field appears in those versions but not all. + /// + public string? VersionLabel { get; set; } = null; } } diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/MapMetadataDto.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/MapMetadataDto.cs index c6b57d93a7..2b948f6597 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/MapMetadataDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/MapMetadataDto.cs @@ -16,5 +16,10 @@ public class MapMetadataDto /// used for display purposes, change detection analysis, and mapping management operations. /// public Dictionary Info { get; set; } = new Dictionary(); + + /// + /// Gets or sets the optional free-text description for this mapping configuration (max 500 characters). + /// + public string? Description { get; set; } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/ReportColumnsMapDto.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/ReportColumnsMapDto.cs index f7116f3223..6583e3cf69 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/ReportColumnsMapDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/ReportColumnsMapDto.cs @@ -72,6 +72,11 @@ public class MappingDto /// Each row defines how a source field maps to a database column with type, path, and label information. /// public MapRowDto[] Rows { get; set; } = []; + + /// + /// Gets or sets optional metadata for this mapping configuration including description and info context. + /// + public MapMetadataDto? Metadata { get; set; } } /// @@ -128,5 +133,12 @@ public class MapRowDto /// Represents the component type path (e.g., "form->panel->textfield") in the source schema structure. /// public string TypePath { get; set; } = string.Empty; + + /// + /// Gets or sets an optional version label indicating which form version this column belongs to. + /// Used exclusively for consolidated worksheet views: null means the column is merged across all versions; + /// a non-null value (e.g., "v1", "v2") means the column is specific to that form version. + /// + public string? VersionLabel { get; set; } = null; } } diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/UpsertColumnMappingDto.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/UpsertColumnMappingDto.cs index 01042b9ebc..9c3a227ebb 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/UpsertColumnMappingDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/UpsertColumnMappingDto.cs @@ -15,6 +15,11 @@ public class UpsertColumnMappingDto /// of the auto-generated mapping configuration while preserving automatic naming for unmapped fields. /// public UpsertMapRowDto[] Rows { get; set; } = []; + + /// + /// Gets or sets the optional free-text description for this mapping configuration (max 500 characters). + /// + public string? Description { get; set; } } /// diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ConsolidatedFormVersionFieldsProvider.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ConsolidatedFormVersionFieldsProvider.cs new file mode 100644 index 0000000000..a81086a22f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ConsolidatedFormVersionFieldsProvider.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Unity.GrantManager.ApplicationForms; +using Unity.GrantManager.Reporting.Configuration; +using Unity.Reporting.Domain.Configuration; +using Volo.Abp.DependencyInjection; + +namespace Unity.Reporting.Configuration.FieldsProviders +{ + /// + /// Fields provider for consolidated form version submission views that span all form versions. + /// Reads live field metadata directly from the form metadata service across all form versions, + /// merges fields by (Label, Path, Type), and detects version changes for break notification. + /// The CorrelationId for this provider is the FormId (not a specific form version ID). + /// + public class ConsolidatedFormVersionFieldsProvider( + IApplicationFormAppService applicationFormAppService, + IFormMetadataService formMetadataService) + : IFieldsProvider, ITransientDependency + { + public string CorrelationProvider => Providers.FormVersionConsolidated; + + /// + /// Retrieves and merges submission field metadata across all form versions for consolidated view configuration. + /// Fields matching on (Label, Path, Type) are merged into a single column entry. + /// Fields with the same (Label, Path) but different Type produce per-version conflict entries. + /// Fields unique to one version are included with a VersionLabel marker. + /// + public async Task GetFieldsMetadataAsync(Guid formId) + { + var versions = await applicationFormAppService.GetVersionsAsync(formId); + var versionsWithFields = new List<(Guid VersionId, string VersionLabel, FieldPathTypeDto[] Fields)>(); + var metadataInfo = new Dictionary(); + + foreach (var version in versions.OrderBy(v => v.Version)) + { + var versionLabel = $"v{version.Version}"; + var fullMetadata = await formMetadataService.GetFormComponentMetaDataAsync(version.Id); + + var fields = fullMetadata.Components + .Select(ConvertToFieldPathType) + .Where(x => x != null) + .Select(x => x!) + .ToArray(); + + if (fields.Length == 0) + continue; + + versionsWithFields.Add((version.Id, versionLabel, fields)); + metadataInfo[$"formversion_{version.Id}"] = versionLabel; + } + + var mergedFields = MergeFields(versionsWithFields); + var mapMetadata = new MapMetadataDto { Info = metadataInfo }; + + return new FieldPathMetaMapDto { Fields = [.. mergedFields], Metadata = mapMetadata }; + } + + /// + /// Detects changes in form versions since the consolidated mapping was last saved. + /// Returns a semicolon-joined change description or null if nothing has changed. + /// Since form version fields are immutable, only added/removed versions are tracked. + /// + public async Task DetectChangesAsync(Guid formId, ReportColumnsMap reportColumnsMap) + { + var versions = await applicationFormAppService.GetVersionsAsync(formId); + var currentInfo = new Dictionary(); + + foreach (var version in versions) + { + var versionLabel = $"v{version.Version}"; + var fullMetadata = await formMetadataService.GetFormComponentMetaDataAsync(version.Id); + + if (fullMetadata.Components.Count == 0) + continue; + + currentInfo[$"formversion_{version.Id}"] = versionLabel; + } + + var storedInfo = GetStoredInfo(reportColumnsMap); + var changes = DetectInfoChanges(storedInfo, currentInfo); + + return changes.Count > 0 ? string.Join("; ", changes) : null; + } + + private static FieldPathTypeDto? ConvertToFieldPathType(FormComponentMetaDataItemDto? item) + { + if (item == null) + return null; + + return new FieldPathTypeDto + { + Id = item.Id, + Path = item.Path, + Type = item.Type, + Key = item.Key, + Label = item.Label, + TypePath = item.TypePath, + DataPath = item.DataPath + }; + } + + private static List MergeFields( + List<(Guid VersionId, string VersionLabel, FieldPathTypeDto[] Fields)> versionsWithFields) + { + var exactMatchGroups = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var pathGroups = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var (_, versionLabel, fields) in versionsWithFields) + { + foreach (var field in fields) + { + var exactKey = $"{field.Label?.ToLowerInvariant()}|{field.Path?.ToLowerInvariant()}|{field.Type?.ToLowerInvariant()}"; + var pathKey = $"{field.Label?.ToLowerInvariant()}|{field.Path?.ToLowerInvariant()}"; + + if (!exactMatchGroups.TryGetValue(exactKey, out var exactList)) + { + exactList = []; + exactMatchGroups[exactKey] = exactList; + } + if (!exactList.Any(e => e.VersionLabel == versionLabel)) + { + exactList.Add((versionLabel, field)); + } + + if (!pathGroups.TryGetValue(pathKey, out var typeSet)) + { + typeSet = new HashSet(StringComparer.OrdinalIgnoreCase); + pathGroups[pathKey] = typeSet; + } + typeSet.Add(field.Type?.ToLowerInvariant() ?? string.Empty); + } + } + + var result = new List(); + var processedExactKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var (_, versionLabel, fields) in versionsWithFields) + { + foreach (var field in fields) + { + var exactKey = $"{field.Label?.ToLowerInvariant()}|{field.Path?.ToLowerInvariant()}|{field.Type?.ToLowerInvariant()}"; + var pathKey = $"{field.Label?.ToLowerInvariant()}|{field.Path?.ToLowerInvariant()}"; + + if (processedExactKeys.Contains(exactKey)) + continue; + + processedExactKeys.Add(exactKey); + + var typesForPath = pathGroups[pathKey]; + var exactGroup = exactMatchGroups[exactKey]; + var versionsHavingThisExact = exactGroup.Select(e => e.VersionLabel).ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (typesForPath.Count > 1) + { + // Conflict: same (label, path) but different types — emit one row per + // distinct type, labelled with every version that carries that type. + // exactGroup already holds every (versionLabel, field) pair for this + // exact (label, path, type) triple, so join them all rather than using + // the outer-loop versionLabel (which would only reflect the first version + // encountered due to processedExactKeys suppressing subsequent entries). + result.Add(new FieldPathTypeDto + { + Id = field.Id, + Key = field.Key, + Label = field.Label, + Path = field.Path, + Type = field.Type, + TypePath = field.TypePath, + DataPath = field.DataPath, + VersionLabel = string.Join(", ", exactGroup.Select(e => e.VersionLabel)) + }); + } + else if (versionsWithFields.Count > 1 && versionsHavingThisExact.Count == versionsWithFields.Count) + { + // Merged: exact match across all versions — no version label + result.Add(new FieldPathTypeDto + { + Id = field.Id, + Key = field.Key, + Label = field.Label, + Path = field.Path, + Type = field.Type, + TypePath = field.TypePath, + DataPath = field.DataPath, + VersionLabel = null + }); + } + else + { + // Version-exclusive field: present in some but not all versions + result.Add(new FieldPathTypeDto + { + Id = field.Id, + Key = field.Key, + Label = field.Label, + Path = field.Path, + Type = field.Type, + TypePath = field.TypePath, + DataPath = field.DataPath, + VersionLabel = string.Join(", ", exactGroup.Select(e => e.VersionLabel)) + }); + } + } + } + + return result; + } + + private static Dictionary GetStoredInfo(ReportColumnsMap reportColumnsMap) + { + if (string.IsNullOrEmpty(reportColumnsMap.Mapping)) + return []; + + try + { + var mapping = JsonSerializer.Deserialize(reportColumnsMap.Mapping); + return mapping?.Metadata?.Info ?? []; + } + catch + { + return []; + } + } + + private static List DetectInfoChanges( + Dictionary storedInfo, + Dictionary currentInfo) + { + var changes = new List(); + + var addedVersionKeys = currentInfo.Keys + .Where(k => k.StartsWith("formversion_", StringComparison.OrdinalIgnoreCase)) + .Except(storedInfo.Keys, StringComparer.OrdinalIgnoreCase); + + foreach (var key in addedVersionKeys) + { + var label = currentInfo[key]; + changes.Add($"Version added: {label}"); + } + + var removedVersionKeys = storedInfo.Keys + .Where(k => k.StartsWith("formversion_", StringComparison.OrdinalIgnoreCase)) + .Except(currentInfo.Keys, StringComparer.OrdinalIgnoreCase); + + foreach (var key in removedVersionKeys) + { + var label = storedInfo[key]; + changes.Add($"Version removed: {label}"); + } + + return changes; + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ConsolidatedWorksheetFieldsProvider.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ConsolidatedWorksheetFieldsProvider.cs new file mode 100644 index 0000000000..1ad032d941 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ConsolidatedWorksheetFieldsProvider.cs @@ -0,0 +1,325 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Unity.Flex.Reporting.Configuration; +using Unity.Flex.WorksheetLinks; +using Unity.GrantManager.ApplicationForms; +using Unity.Reporting.Domain.Configuration; +using Volo.Abp.DependencyInjection; + +namespace Unity.Reporting.Configuration.FieldsProviders +{ + /// + /// Fields provider for consolidated worksheet views that span all form versions. + /// Reads live worksheet field metadata directly from the Flex module across all form versions, + /// merges fields by (Label, Path, Type), and detects version/worksheet changes for break notification. + /// The CorrelationId for this provider is the FormId (not a specific form version ID). + /// + public class ConsolidatedWorksheetFieldsProvider( + IApplicationFormAppService applicationFormAppService, + IWorksheetsMetadataService worksheetsMetadataService, + IWorksheetLinkAppService worksheetLinkAppService) + : IFieldsProvider, ITransientDependency + { + public string CorrelationProvider => Providers.WorksheetConsolidated; + + /// + /// Retrieves and merges worksheet field metadata across all form versions for consolidated view configuration. + /// Fields matching on (Label, Path, Type) are merged into a single column entry. + /// Fields with the same (Label, Path) but different Type produce per-version conflict entries. + /// Fields unique to one version are included with a VersionLabel marker. + /// + public async Task GetFieldsMetadataAsync(Guid formId) + { + var versions = await applicationFormAppService.GetVersionsAsync(formId); + var versionsWithFields = new List<(Guid VersionId, string VersionLabel, FieldPathTypeDto[] Fields)>(); + var metadataInfo = new Dictionary(); + + foreach (var version in versions.OrderBy(v => v.Version)) + { + var versionLabel = $"v{version.Version}"; + var links = await worksheetLinkAppService.GetListByCorrelationAsync(version.Id, "FormVersion"); + + if (links.Count == 0) + continue; + + var allComponents = new List(); + + foreach (var link in links) + { + var metadata = await worksheetsMetadataService.GetWorksheetSchemaMetaDataAsync(link.WorksheetId, version.Id); + var components = metadata.Components + .Select(ConvertToFieldPathType) + .Where(x => x != null) + .Select(x => x!); + allComponents.AddRange(components); + + var worksheetTitle = link.Worksheet?.Title ?? "Unknown Worksheet"; + var worksheetName = link.Worksheet?.Name ?? "Unknown"; + metadataInfo[$"ws_{version.Id}_{link.WorksheetId}"] = $"{worksheetTitle} ({worksheetName})"; + } + + // Stamp within-version duplicate DataPaths with (DK1), (DK2), … before merging, + // so MergeFields() treats them as distinct paths and preserves both rather than + // silently dropping the second occurrence. + var versionComponents = allComponents.ToArray(); + WorksheetFieldsUtils.UniqueifyDataPaths(versionComponents); + versionsWithFields.Add((version.Id, versionLabel, versionComponents)); + metadataInfo[$"formversion_{version.Id}"] = versionLabel; + } + + var mergedFields = MergeFields(versionsWithFields); + var mapMetadata = new MapMetadataDto { Info = metadataInfo }; + + return new FieldPathMetaMapDto { Fields = [.. mergedFields], Metadata = mapMetadata }; + } + + /// + /// Detects changes in form versions and worksheet links since the consolidated mapping was last saved. + /// Returns a semicolon-joined change description or null if nothing has changed. + /// + public async Task DetectChangesAsync(Guid formId, ReportColumnsMap reportColumnsMap) + { + var versions = await applicationFormAppService.GetVersionsAsync(formId); + var currentInfo = new Dictionary(); + + foreach (var version in versions) + { + var versionLabel = $"v{version.Version}"; + var links = await worksheetLinkAppService.GetListByCorrelationAsync(version.Id, "FormVersion"); + + if (links.Count == 0) + continue; + + currentInfo[$"formversion_{version.Id}"] = versionLabel; + + foreach (var link in links) + { + var worksheetTitle = link.Worksheet?.Title ?? "Unknown Worksheet"; + var worksheetName = link.Worksheet?.Name ?? "Unknown"; + currentInfo[$"ws_{version.Id}_{link.WorksheetId}"] = $"{worksheetTitle} ({worksheetName})"; + } + } + + var storedInfo = GetStoredInfo(reportColumnsMap); + var changes = DetectInfoChanges(storedInfo, currentInfo); + + return changes.Count > 0 ? string.Join("; ", changes) : null; + } + + private static FieldPathTypeDto? ConvertToFieldPathType(WorksheetComponentMetaDataItemDto? item) + { + if (item == null) + return null; + + return new FieldPathTypeDto + { + Id = item.Id, + Path = item.Path, + Type = item.Type, + Key = item.Key, + Label = item.Label, + TypePath = item.TypePath, + DataPath = item.DataPath + }; + } + + private static List MergeFields( + List<(Guid VersionId, string VersionLabel, FieldPathTypeDto[] Fields)> versionsWithFields) + { + // Track: (label.lower, path.lower, type.lower) → list of (versionLabel, field) + var exactMatchGroups = new Dictionary>(StringComparer.OrdinalIgnoreCase); + // Track: (label.lower, path.lower) → set of types seen + var pathGroups = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var (_, versionLabel, fields) in versionsWithFields) + { + foreach (var field in fields) + { + var exactKey = $"{field.Label?.ToLowerInvariant()}|{field.Path?.ToLowerInvariant()}|{field.Type?.ToLowerInvariant()}"; + var pathKey = $"{field.Label?.ToLowerInvariant()}|{field.Path?.ToLowerInvariant()}"; + + if (!exactMatchGroups.TryGetValue(exactKey, out var exactList)) + { + exactList = []; + exactMatchGroups[exactKey] = exactList; + } + // Only add first occurrence per version (avoid duplicates within same version) + if (!exactList.Any(e => e.VersionLabel == versionLabel)) + { + exactList.Add((versionLabel, field)); + } + + if (!pathGroups.TryGetValue(pathKey, out var typeSet)) + { + typeSet = new HashSet(StringComparer.OrdinalIgnoreCase); + pathGroups[pathKey] = typeSet; + } + typeSet.Add(field.Type?.ToLowerInvariant() ?? string.Empty); + } + } + + var result = new List(); + var processedExactKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var (_, versionLabel, fields) in versionsWithFields) + { + foreach (var field in fields) + { + var exactKey = $"{field.Label?.ToLowerInvariant()}|{field.Path?.ToLowerInvariant()}|{field.Type?.ToLowerInvariant()}"; + var pathKey = $"{field.Label?.ToLowerInvariant()}|{field.Path?.ToLowerInvariant()}"; + + if (processedExactKeys.Contains(exactKey)) + continue; + + processedExactKeys.Add(exactKey); + + var typesForPath = pathGroups[pathKey]; + var exactGroup = exactMatchGroups[exactKey]; + var versionsHavingThisExact = exactGroup.Select(e => e.VersionLabel).ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (typesForPath.Count > 1) + { + // Conflict: same (label, path) but different types — emit one row per + // distinct type, labelled with every version that carries that type. + // exactGroup already holds every (versionLabel, field) pair for this + // exact (label, path, type) triple, so join them all rather than using + // the outer-loop versionLabel (which would only reflect the first version + // encountered due to processedExactKeys suppressing subsequent entries). + result.Add(new FieldPathTypeDto + { + Id = field.Id, + Key = field.Key, + Label = field.Label, + Path = field.Path, + Type = field.Type, + TypePath = field.TypePath, + DataPath = field.DataPath, + VersionLabel = string.Join(", ", exactGroup.Select(e => e.VersionLabel)) + }); + } + else if (versionsWithFields.Count > 1 && versionsHavingThisExact.Count == versionsWithFields.Count) + { + // Merged: exact match across all versions — no version label + result.Add(new FieldPathTypeDto + { + Id = field.Id, + Key = field.Key, + Label = field.Label, + Path = field.Path, + Type = field.Type, + TypePath = field.TypePath, + DataPath = field.DataPath, + VersionLabel = null + }); + } + else + { + // Version-exclusive field: present in some but not all versions + result.Add(new FieldPathTypeDto + { + Id = field.Id, + Key = field.Key, + Label = field.Label, + Path = field.Path, + Type = field.Type, + TypePath = field.TypePath, + DataPath = field.DataPath, + VersionLabel = string.Join(", ", exactGroup.Select(e => e.VersionLabel)) + }); + } + } + } + + return result; + } + + private static Dictionary GetStoredInfo(ReportColumnsMap reportColumnsMap) + { + if (string.IsNullOrEmpty(reportColumnsMap.Mapping)) + return []; + + try + { + var mapping = JsonSerializer.Deserialize(reportColumnsMap.Mapping); + return mapping?.Metadata?.Info ?? []; + } + catch + { + return []; + } + } + + private static List DetectInfoChanges( + Dictionary storedInfo, + Dictionary currentInfo) + { + var changes = new List(); + + // Detect added/removed form versions + var addedVersionKeys = currentInfo.Keys + .Where(k => k.StartsWith("formversion_", StringComparison.OrdinalIgnoreCase)) + .Except(storedInfo.Keys, StringComparer.OrdinalIgnoreCase); + + foreach (var key in addedVersionKeys) + { + var label = currentInfo[key]; + changes.Add($"Version added: {label} (has worksheets)"); + } + + var removedVersionKeys = storedInfo.Keys + .Where(k => k.StartsWith("formversion_", StringComparison.OrdinalIgnoreCase)) + .Except(currentInfo.Keys, StringComparer.OrdinalIgnoreCase); + + foreach (var key in removedVersionKeys) + { + var label = storedInfo[key]; + changes.Add($"Version removed: {label}"); + } + + // Detect added/removed worksheets within versions + var addedWsKeys = currentInfo.Keys + .Where(k => k.StartsWith("ws_", StringComparison.OrdinalIgnoreCase)) + .Except(storedInfo.Keys, StringComparer.OrdinalIgnoreCase); + + foreach (var key in addedWsKeys) + { + var worksheetInfo = currentInfo[key]; + var versionLabel = GetVersionLabelFromWsKey(key, currentInfo); + changes.Add($"Worksheet added to {versionLabel}: {worksheetInfo}"); + } + + var removedWsKeys = storedInfo.Keys + .Where(k => k.StartsWith("ws_", StringComparison.OrdinalIgnoreCase)) + .Except(currentInfo.Keys, StringComparer.OrdinalIgnoreCase); + + foreach (var key in removedWsKeys) + { + var worksheetInfo = storedInfo[key]; + var versionLabel = GetVersionLabelFromWsKey(key, storedInfo); + changes.Add($"Worksheet removed from {versionLabel}: {worksheetInfo}"); + } + + return changes; + } + + // ws_{versionId}_{worksheetId} → look up formversion_{versionId} in info + // versionId is a GUID (36 chars) starting at position 3 (after "ws_") + private static string GetVersionLabelFromWsKey(string wsKey, Dictionary info) + { + const int guidLength = 36; + const int prefixLength = 3; // "ws_" + + if (wsKey.Length >= prefixLength + guidLength) + { + var versionIdStr = wsKey.Substring(prefixLength, guidLength); + var versionKey = $"formversion_{versionIdStr}"; + if (info.TryGetValue(versionKey, out var label)) + return label; + } + return "unknown version"; + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/WorksheetFieldsProvider.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/WorksheetFieldsProvider.cs index b609850c8e..4e984e35a7 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/WorksheetFieldsProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/WorksheetFieldsProvider.cs @@ -51,6 +51,10 @@ public async Task GetFieldsMetadataAsync(Guid correlationId .Where(x => x != null) .Select(x => x!)]; + // Mirror submission behaviour: stamp within-version duplicate DataPaths with (DK1), (DK2), … + // so that each row is distinguishable and the Duplicate Keys warning is triggered. + WorksheetFieldsUtils.UniqueifyDataPaths(convertedMetadata); + return new FieldPathMetaMapDto() { Fields = convertedMetadata, Metadata = mapMetadata }; } diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/WorksheetFieldsUtils.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/WorksheetFieldsUtils.cs new file mode 100644 index 0000000000..e15431de24 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/WorksheetFieldsUtils.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Linq; +using Unity.Reporting.Domain.Configuration; + +namespace Unity.Reporting.Configuration.FieldsProviders +{ + /// + /// Shared utilities for worksheet fields providers. + /// + internal static class WorksheetFieldsUtils + { + /// + /// Prefixes duplicate DataPaths with (DK1), (DK2), … on both + /// and , mirroring the behaviour of + /// FormMetadataService.UniqueifyPaths() for worksheet fields. + /// + /// Unlike the submissions path (where DataPath is derived from Path after uniqueification), + /// worksheet DataPaths are constructed independently in the schema parser, so both properties + /// must be prefixed here. + /// + /// Mutates the array in place. + /// + /// true if any duplicates were found and prefixed, otherwise false. + internal static bool UniqueifyDataPaths(FieldPathTypeDto[] fields) + { + // Identify which DataPath values appear more than once + var duplicatePaths = fields + .Where(f => !string.IsNullOrEmpty(f.DataPath)) + .GroupBy(f => f.DataPath, System.StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToHashSet(System.StringComparer.OrdinalIgnoreCase); + + if (duplicatePaths.Count == 0) return false; + + var counters = new Dictionary(System.StringComparer.OrdinalIgnoreCase); + + foreach (var field in fields) + { + if (string.IsNullOrEmpty(field.DataPath) || !duplicatePaths.Contains(field.DataPath)) + continue; + + counters[field.DataPath] = counters.GetValueOrDefault(field.DataPath, 0) + 1; + int n = counters[field.DataPath]; + + field.Path = $"(DK{n}){field.Path}"; + field.DataPath = $"(DK{n}){field.DataPath}"; + } + + return true; + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/ReportMappingUtils.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/ReportMappingUtils.cs index 6c36736706..956a7aff06 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/ReportMappingUtils.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/ReportMappingUtils.cs @@ -343,7 +343,8 @@ internal static ReportColumnsMap CreateNewMap(UpsertReportColumnsMapDto upsertRe Path = field.Path, DataPath = field.DataPath, TypePath = field.TypePath, - Id = field.Id + Id = field.Id, + VersionLabel = field.VersionLabel }; }).ToList(); @@ -351,7 +352,11 @@ internal static ReportColumnsMap CreateNewMap(UpsertReportColumnsMapDto upsertRe var mapping = new Mapping { Rows = [.. mapRows], - Metadata = new MapMetadata() { Info = fieldsMap.Metadata?.Info } + Metadata = new MapMetadata() + { + Info = fieldsMap.Metadata?.Info, + Description = upsertReportColmnsMapDto.Mapping?.Description + } }; // Create and return the map entity @@ -447,15 +452,20 @@ internal static ReportColumnsMap UpdateExistingMap(UpsertReportColumnsMapDto upd Path = field.Path, DataPath = field.DataPath, TypePath = field.TypePath, - Id = field.Id + Id = field.Id, + VersionLabel = field.VersionLabel }; }).ToList(); // Create new mapping object and serialize it - var updatedMapping = new Mapping - { + var updatedMapping = new Mapping + { Rows = [.. mapRows], - Metadata = new MapMetadata() { Info = fieldsMap.Metadata?.Info } + Metadata = new MapMetadata() + { + Info = fieldsMap.Metadata?.Info, + Description = updateReportColumnsMapDto.Mapping?.Description + } }; existing.Mapping = JsonSerializer.Serialize(updatedMapping); diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Domain/Configuration/ReportColumnsMap.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Domain/Configuration/ReportColumnsMap.cs index df20a20437..7bd059bb13 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Domain/Configuration/ReportColumnsMap.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Domain/Configuration/ReportColumnsMap.cs @@ -133,6 +133,14 @@ public class MapRow /// Represents the component type path (e.g., "form->panel->textfield") in the source schema. /// public string TypePath { get; set; } = string.Empty; + + /// + /// Gets or sets an optional version label indicating which form version(s) this column belongs to. + /// Used exclusively for consolidated views: null means the column is merged across all versions ("All"); + /// a single value (e.g., "v1") means the column is exclusive to that version; + /// a comma-separated value (e.g., "v1, v2") means the column appears in those versions but not all. + /// + public string? VersionLabel { get; set; } = null; } /// @@ -148,5 +156,11 @@ public class MapMetadata /// used for display purposes and change detection analysis. /// public Dictionary? Info { get; set; } = null; + + /// + /// Gets or sets an optional free-text description for this mapping configuration. + /// Maximum 500 characters. Used to document the purpose or context of this reporting configuration. + /// + public string? Description { get; set; } = null; } } diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/EntityFrameworkCore/Repositories/ReportColumnsMapRepository.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/EntityFrameworkCore/Repositories/ReportColumnsMapRepository.cs index 2e559ef4cf..08593ddb8a 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/EntityFrameworkCore/Repositories/ReportColumnsMapRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/EntityFrameworkCore/Repositories/ReportColumnsMapRepository.cs @@ -91,6 +91,8 @@ public async Task GenerateViewAsync(Guid correlationId, string correlationProvid "formversion" => $@"CALL ""Reporting"".generate_formversion_view({correlationId});", "worksheet" => $@"CALL ""Reporting"".generate_worksheet_view({correlationId});", "scoresheet" => $@"CALL ""Reporting"".generate_scoresheet_view({correlationId});", + "worksheet_consolidated" => $@"CALL ""Reporting"".generate_consolidated_worksheet_view({correlationId});", + "formversion_consolidated" => $@"CALL ""Reporting"".generate_consolidated_formversion_view({correlationId});", _ => throw new ArgumentException($"Unsupported correlation provider: {correlationProvider}"), }; await dbContext.Database.ExecuteSqlAsync(sql); diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/ReportingApplicationMapperlyProfile.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/ReportingApplicationMapperlyProfile.cs index 134acf454e..75ddf45272 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/ReportingApplicationMapperlyProfile.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/ReportingApplicationMapperlyProfile.cs @@ -49,3 +49,11 @@ public partial class MapRowToMapRowDtoMapper : MapperBase public override partial void Map(MapRow source, MapRowDto destination); } + +[Mapper] +public partial class MapMetadataToMapMetadataDtoMapper : MapperBase +{ + public override partial MapMetadataDto Map(MapMetadata source); + + public override partial void Map(MapMetadata source, MapMetadataDto destination); +} diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Domain.Shared/Configuration/Providers.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Domain.Shared/Configuration/Providers.cs index 8fe4e5cf08..301a8c4c72 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Domain.Shared/Configuration/Providers.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Domain.Shared/Configuration/Providers.cs @@ -28,5 +28,19 @@ public static class Providers /// Scoresheets contain structured evaluation criteria and scoring mechanisms for application review. /// public static string Scoresheet => "scoresheet"; + + /// + /// Gets the correlation provider identifier for consolidated worksheet views spanning all form versions. + /// Used when creating a single unified report view that merges worksheet data across all versions of a form. + /// The CorrelationId for this provider is the FormId (not a specific version ID). + /// + public static string WorksheetConsolidated => "worksheet_consolidated"; + + /// + /// Gets the correlation provider identifier for consolidated form version submission views spanning all form versions. + /// Used when creating a single unified report view that merges submission data across all versions of a form. + /// The CorrelationId for this provider is the FormId (not a specific version ID). + /// + public static string FormVersionConsolidated => "formversion_consolidated"; } } diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.cshtml index c8ded88444..f419f5b7a5 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.cshtml @@ -15,63 +15,47 @@ - -
-
-
- - + +
+
+
+
+ + - @if (await FeatureChecker.IsEnabledAsync("Unity.Flex")) - { - - + @if (await FeatureChecker.IsEnabledAsync("Unity.Flex")) + { + + - - - } -
+ + + } +
- - -
-
+
+
+ + + + +
+
-
- @if (Model.IsVersionSelectorVisible) - { -
- -
- } - else - { - -
- -
- -
-
- -
- Form ID: @Model.FormId + @if (await FeatureChecker.IsEnabledAsync("Unity.Flex")) + { +
+
+ + + + +
-
+ }
- } -
+ +
@if (Model.CorrelationId.HasValue) { @@ -83,36 +67,15 @@ @if (await PermissionChecker.IsGrantedAsync(ReportingPermissions.Configuration.Update)) { - } - -
- - - Warning - -
-
- - - Unmapped DataGrid - -
+ } @if (await PermissionChecker.IsGrantedAsync(ReportingPermissions.Configuration.Delete)) { @@ -148,12 +111,55 @@ class="btn unt-btn-outline-primary btn-outline-primary mx-1" style="display: none;"> + @if (await PermissionChecker.IsGrantedAsync(ReportingPermissions.Configuration.Update)) + { +
+ + +
+ } + +
+
+ +
+
+ + +
@@ -164,45 +170,43 @@
- @if (await PermissionChecker.IsGrantedAsync(ReportingPermissions.Configuration.Update)) - { -
- +
+
+ + + Duplicate Keys + +
-
- - -
+
+ + + Unmapped DataGrid +
- } + + @if (await PermissionChecker.IsGrantedAsync(ReportingPermissions.Configuration.Update)) + { +
+ +
+ } +
@@ -224,9 +228,9 @@
- +
-
+
View name must start with a letter or underscore, contain only letters, numbers, and underscores, and be between 1-63 characters long.
@@ -247,6 +251,39 @@
+ + +