diff --git a/.github/workflows/manual-trigger.yml b/.github/workflows/manual-trigger.yml index c34b9e697b..3761bd180a 100644 --- a/.github/workflows/manual-trigger.yml +++ b/.github/workflows/manual-trigger.yml @@ -18,7 +18,7 @@ on: type: string env: TARGET_ENV: ${{ inputs.name }} - GH_TOKEN: ${{secrets.GH_API_TOKEN}} + GH_TOKEN: ${{secrets.GITHUB_TOKEN}} OC_CLUSTER: ${{ vars.OPENSHIFT_CLUSTER }} OC_REGISTRY: ${{ vars.OPENSHIFT_REGISTRY }} OC_AUTH_TOKEN: ${{ secrets.OPENSHIFT_TOKEN }} @@ -89,6 +89,8 @@ jobs: permissions: actions: write contents: read + env: + GH_TOKEN: ${{ secrets.GH_API_TOKEN }} steps: - name: Checkout repository uses: actions/checkout@v6 diff --git a/.github/workflows/sonarsource-scan.yml b/.github/workflows/sonarsource-scan.yml index fd30e826d6..375ac6f42b 100644 --- a/.github/workflows/sonarsource-scan.yml +++ b/.github/workflows/sonarsource-scan.yml @@ -27,7 +27,7 @@ jobs: uses: actions/setup-java@v5 with: java-version: 17 - distribution: 'zulu' + distribution: 'temurin' - uses: actions/checkout@v6 with: diff --git a/applications/Unity.AutoUI/cypress/e2e/library/chefs.cy.ts b/applications/Unity.AutoUI/cypress/e2e/library/chefs.cy.ts index 67900ba159..c2867af2cb 100644 --- a/applications/Unity.AutoUI/cypress/e2e/library/chefs.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/library/chefs.cy.ts @@ -1,8 +1,17 @@ -describe('Chefs Login and Logout', () => { +describe("Chefs Login and Logout", () => { + it("Verify that Chefs is online.", () => { + cy.getChefsDetail("chefsBaseURL").then((baseURL) => { + cy.visit(baseURL); - it('Verify that Chefs is online.', () => { - cy.chefsLogin(); - cy.contains("My Forms").should('exist').click(); - cy.chefsLogout(); - }) -}) \ No newline at end of file + cy.contains("button, a, [role='button']", /log\s*in|login/i) + .should("exist") + .click({ force: true }); + + cy.location("pathname").should("include", "/app/login"); + cy.contains("button, a, [role='button']", /IDIR/i).should("exist"); + cy.contains("button, a, [role='button']", /BC Services Card/i).should( + "exist", + ); + }); + }); +}); diff --git a/applications/Unity.AutoUI/cypress/support/commands.ts b/applications/Unity.AutoUI/cypress/support/commands.ts index 7216c27660..07edc350ba 100644 --- a/applications/Unity.AutoUI/cypress/support/commands.ts +++ b/applications/Unity.AutoUI/cypress/support/commands.ts @@ -143,15 +143,39 @@ Cypress.Commands.add("getChefsDetail", (key: string) => { Cypress.Commands.add("chefsLogin", () => { cy.getChefsDetail("chefsBaseURL").then((baseURL) => { cy.visit(baseURL); // Visit the URL fetched from chefs.json - cy.get("#app > div > main > header > header > div > div.d-print-none") - .should("exist") - .click(); // click the login button + cy.get("body").then(($body) => { + // Prefer resilient text-based selectors over brittle full DOM paths. + if ($body.find("button:contains('LOGIN'), a:contains('LOGIN')").length) { + cy.contains("button, a", /^LOGIN$/i) + .first() + .click({ force: true }); + } else if ( + $body.find( + "#app > div > main > header > header > div > div.d-print-none", + ).length + ) { + cy.get("#app > div > main > header > header > div > div.d-print-none") + .should("exist") + .click({ force: true }); + } + }); cy.wait(1000); - cy.get( - "#app > div > main > div.v-container.v-locale--is-ltr.text-center.main > div > div:nth-child(2) > div > button", - ) - .should("exist") - .click(); // click the idir buttton + cy.get("body").then(($body) => { + // Some pages show an IDIR choice button before the credential form. + if ($body.find("button:contains('IDIR'), a:contains('IDIR')").length) { + cy.contains("button, a", /IDIR/i).first().click({ force: true }); + } else if ( + $body.find( + "#app > div > main > div.v-container.v-locale--is-ltr.text-center.main > div > div:nth-child(2) > div > button", + ).length + ) { + cy.get( + "#app > div > main > div.v-container.v-locale--is-ltr.text-center.main > div > div:nth-child(2) > div > button", + ) + .should("exist") + .click({ force: true }); + } + }); cy.wait(1000); cy.get("body").then(($body) => { // Check if you're already logged in. @@ -246,11 +270,14 @@ function fetchGrantApplications(): Cypress.Chainable { .then((response) => { if (response.status !== 200) { throw new Error( - `API request failed with status ${response.status}: ${JSON.stringify(response.body)}` + `API request failed with status ${response.status}: ${JSON.stringify(response.body)}`, ); } const data = response.body as GrantApplicationResponse; - Cypress.log({ name: "fetch", message: `📋 Fetched ${data.items?.length || 0} applications` }); + Cypress.log({ + name: "fetch", + message: `📋 Fetched ${data.items?.length || 0} applications`, + }); return data.items || []; }); }); @@ -262,89 +289,92 @@ Cypress.Commands.add( return fetchGrantApplications().then((allApplications) => { let applications = allApplications; - Cypress.log({ name: "fetch", message: `📋 Fetched ${applications.length} applications from API` }); - - // Filter by category if specified (e.g., 'Data Seeder') - if (options.categoryFilter) { - applications = applications.filter((app) => - app.category === options.categoryFilter - ); - Cypress.log({ - name: "filter", - message: `📋 Filtered to ${applications.length} applications with category: ${options.categoryFilter}`, - }); - } - - // Filter by status if specified (e.g., 'Submitted', 'Under Assessment', 'Approved') - if (options.statusFilter && options.statusFilter.length > 0) { - applications = applications.filter((app) => - options.statusFilter!.includes(app.status) - ); - Cypress.log({ - name: "filter", - message: `📋 Filtered to ${applications.length} applications with status: ${options.statusFilter.join(", ")}`, - }); - } - - // Filter by max age if specified - if (options.maxAge) { - const cutoffDate = new Date(); - cutoffDate.setDate(cutoffDate.getDate() - options.maxAge); - applications = applications.filter((app) => { - const submissionDate = new Date(app.submissionDate); - return submissionDate >= cutoffDate; - }); - Cypress.log({ - name: "filter", - message: `📋 Filtered to ${applications.length} applications within ${options.maxAge} days`, - }); - } - - if (applications.length === 0) { - throw new Error( - "No applications found matching the specified criteria" - ); - } - - // Sort applications (default: by submissionDate descending for latest first) - const sortBy = options.sortBy || 'submissionDate'; - const sortOrder = options.sortOrder || 'desc'; - applications.sort((a, b) => { - let aVal: number | string; - let bVal: number | string; - - if (sortBy === 'submissionDate') { - aVal = new Date(a.submissionDate).getTime(); - bVal = new Date(b.submissionDate).getTime(); - } else { - aVal = a[sortBy] as number; - bVal = b[sortBy] as number; - } - - if (sortOrder === 'desc') { - return bVal > aVal ? 1 : bVal < aVal ? -1 : 0; - } else { - return aVal > bVal ? 1 : aVal < bVal ? -1 : 0; - } - }); - - // Get the submission at the specified index (default: 0 = first/latest) - const index = options.index || 0; - if (index >= applications.length) { - throw new Error( - `Index ${index} out of range. Only ${applications.length} applications available.` - ); - } - - const selectedApp = applications[index]; - Cypress.log({ - name: "selected", - message: `✅ Selected submission: ${selectedApp.referenceNo} (Status: ${selectedApp.status}, Category: ${selectedApp.category})`, - }); - - return selectedApp.referenceNo; + Cypress.log({ + name: "fetch", + message: `📋 Fetched ${applications.length} applications from API`, + }); + + // Filter by category if specified (e.g., 'Data Seeder') + if (options.categoryFilter) { + applications = applications.filter( + (app) => app.category === options.categoryFilter, + ); + Cypress.log({ + name: "filter", + message: `📋 Filtered to ${applications.length} applications with category: ${options.categoryFilter}`, + }); + } + + // Filter by status if specified (e.g., 'Submitted', 'Under Assessment', 'Approved') + if (options.statusFilter && options.statusFilter.length > 0) { + applications = applications.filter((app) => + options.statusFilter!.includes(app.status), + ); + Cypress.log({ + name: "filter", + message: `📋 Filtered to ${applications.length} applications with status: ${options.statusFilter.join(", ")}`, + }); + } + + // Filter by max age if specified + if (options.maxAge) { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - options.maxAge); + applications = applications.filter((app) => { + const submissionDate = new Date(app.submissionDate); + return submissionDate >= cutoffDate; }); - } + Cypress.log({ + name: "filter", + message: `📋 Filtered to ${applications.length} applications within ${options.maxAge} days`, + }); + } + + if (applications.length === 0) { + throw new Error( + "No applications found matching the specified criteria", + ); + } + + // Sort applications (default: by submissionDate descending for latest first) + const sortBy = options.sortBy || "submissionDate"; + const sortOrder = options.sortOrder || "desc"; + applications.sort((a, b) => { + let aVal: number | string; + let bVal: number | string; + + if (sortBy === "submissionDate") { + aVal = new Date(a.submissionDate).getTime(); + bVal = new Date(b.submissionDate).getTime(); + } else { + aVal = a[sortBy] as number; + bVal = b[sortBy] as number; + } + + if (sortOrder === "desc") { + return bVal > aVal ? 1 : bVal < aVal ? -1 : 0; + } else { + return aVal > bVal ? 1 : aVal < bVal ? -1 : 0; + } + }); + + // Get the submission at the specified index (default: 0 = first/latest) + const index = options.index || 0; + if (index >= applications.length) { + throw new Error( + `Index ${index} out of range. Only ${applications.length} applications available.`, + ); + } + + const selectedApp = applications[index]; + Cypress.log({ + name: "selected", + message: `✅ Selected submission: ${selectedApp.referenceNo} (Status: ${selectedApp.status}, Category: ${selectedApp.category})`, + }); + + return selectedApp.referenceNo; + }); + }, ); /** diff --git a/applications/Unity.GrantManager/common.props b/applications/Unity.GrantManager/common.props index 99989f95b1..bdf9a7442f 100644 --- a/applications/Unity.GrantManager/common.props +++ b/applications/Unity.GrantManager/common.props @@ -16,4 +16,10 @@ + + + + + \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Attachments/IAttachmentSummaryAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Attachments/IAttachmentSummaryAppService.cs index 7b572a0703..e82a6f7669 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Attachments/IAttachmentSummaryAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Attachments/IAttachmentSummaryAppService.cs @@ -7,7 +7,5 @@ namespace Unity.GrantManager.Attachments; public interface IAttachmentSummaryAppService : IApplicationService { - Task GenerateAttachmentSummaryAsync(Guid attachmentId, string? promptVersion = null); - Task> GenerateAttachmentSummariesAsync(List attachmentIds, string? promptVersion = null); Task> GenerateAttachmentSummariesForPipelineAsync(List attachmentIds, string? promptVersion = null); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Automation/IApplicationAIGenerationQueue.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Automation/IApplicationAIGenerationQueue.cs index f7837b6c5a..e7c0dbbb6c 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Automation/IApplicationAIGenerationQueue.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Automation/IApplicationAIGenerationQueue.cs @@ -1,11 +1,12 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; namespace Unity.AI.Automation; public interface IApplicationAIGenerationQueue { - Task QueueAttachmentSummaryAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); + Task QueueAttachmentSummaryAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null, List? attachmentIds = null); Task QueueApplicationAnalysisAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); Task QueueApplicationScoringAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); Task QueueAllAIStagesAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/AIRateLimitStateDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/AIRateLimitStateDto.cs index 9f53b07bb2..e316f9721f 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/AIRateLimitStateDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/AIRateLimitStateDto.cs @@ -3,4 +3,5 @@ namespace Unity.AI.RateLimit; public class AIRateLimitStateDto { public int RetryAfterSeconds { get; set; } + public bool IsGenerating { get; set; } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/IAIGenerationActivityProvider.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/IAIGenerationActivityProvider.cs new file mode 100644 index 0000000000..e1de0b5ccb --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/IAIGenerationActivityProvider.cs @@ -0,0 +1,8 @@ +using System.Threading.Tasks; + +namespace Unity.AI.RateLimit; + +public interface IAIGenerationActivityProvider +{ + Task HasActiveGenerationAsync(); +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/IAIRateLimiter.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/IAIRateLimiter.cs index cf2317e2ed..74525aad89 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/IAIRateLimiter.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/RateLimit/IAIRateLimiter.cs @@ -22,8 +22,8 @@ public interface IAIRateLimiter Task StampAsync(Guid? userId); /// - /// Returns the remaining cooldown for the current user. RetryAfterSeconds is 0 when - /// the user can generate immediately. + /// Returns the current user's shared AI generation state. RetryAfterSeconds is 0 when + /// the user can generate immediately, unless a generation is still active. /// Task GetStateAsync(); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs index 156ccfda96..78762cea48 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs @@ -97,13 +97,30 @@ private async Task GenerateOrFallbackAsync( } } - public async Task> GenerateForApplicationAsync(Guid applicationId, string? promptVersion = null, CancellationToken cancellationToken = default) + public async Task> GenerateForApplicationAsync( + Guid applicationId, + string? promptVersion = null, + IReadOnlyCollection? attachmentIds = null, + CancellationToken cancellationToken = default) { - var attachmentIds = (await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId)) + var applicationAttachmentIds = (await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId)) .Select(a => a.Id) .ToList(); - return await GenerateAndSaveAsync(attachmentIds, promptVersion, cancellationToken); + if (attachmentIds is not { Count: > 0 }) + { + return await GenerateAndSaveAsync(applicationAttachmentIds, promptVersion, cancellationToken); + } + + var applicationAttachmentIdSet = applicationAttachmentIds.ToHashSet(); + var selectedIds = attachmentIds.Distinct().ToList(); + + if (selectedIds.Any(id => !applicationAttachmentIdSet.Contains(id))) + { + throw new InvalidOperationException("One or more selected attachments do not belong to the application."); + } + + return await GenerateAndSaveAsync(selectedIds, promptVersion, cancellationToken); } private async Task OpenAttachmentStreamAsync( diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IAttachmentSummaryService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IAttachmentSummaryService.cs index feed628055..af2e301444 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IAttachmentSummaryService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IAttachmentSummaryService.cs @@ -9,5 +9,5 @@ public interface IAttachmentSummaryService { Task GenerateAndSaveAsync(Guid attachmentId, string? promptVersion = null, CancellationToken cancellationToken = default); Task> GenerateAndSaveAsync(IEnumerable attachmentIds, string? promptVersion = null, CancellationToken cancellationToken = default); - Task> GenerateForApplicationAsync(Guid applicationId, string? promptVersion = null, CancellationToken cancellationToken = default); + Task> GenerateForApplicationAsync(Guid applicationId, string? promptVersion = null, IReadOnlyCollection? attachmentIds = null, CancellationToken cancellationToken = default); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Attachments/AttachmentSummaryAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Attachments/AttachmentSummaryAppService.cs index 6e1cedc435..1a5cdbfe4d 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Attachments/AttachmentSummaryAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Attachments/AttachmentSummaryAppService.cs @@ -2,8 +2,6 @@ using System.Collections.Generic; using System.Threading.Tasks; using Unity.AI; -using Unity.AI.Features; -using Unity.AI.Localization; using Unity.AI.Operations; using Unity.AI.Permissions; using Unity.AI.Settings; @@ -15,40 +13,8 @@ namespace Unity.GrantManager.Attachments; [Authorize(AIPermissions.Analysis.GenerateAttachmentSummaries)] [ExposeServices(typeof(AttachmentSummaryAppService), typeof(IAttachmentSummaryAppService))] public class AttachmentSummaryAppService( - IAttachmentSummaryService attachmentSummaryService, - AIFeatureGuard featureGuard) : AIAppService, IAttachmentSummaryAppService + IAttachmentSummaryService attachmentSummaryService) : AIAppService, IAttachmentSummaryAppService { - public virtual async Task GenerateAttachmentSummaryAsync(System.Guid attachmentId, string? promptVersion = null) - { - await featureGuard.EnsureEnabledAsync( - AIFeatures.AttachmentSummaries, - AILocalizationKeys.AttachmentSummariesDisabled); - - await attachmentSummaryService.GenerateAndSaveAsync(attachmentId, promptVersion); - return new AttachmentSummaryResultDto { Completed = true }; - } - - public virtual async Task> GenerateAttachmentSummariesAsync(List attachmentIds, string? promptVersion = null) - { - await featureGuard.EnsureEnabledAsync( - AIFeatures.AttachmentSummaries, - AILocalizationKeys.AttachmentSummariesDisabled); - - if (attachmentIds.Count == 0) - { - return []; - } - - var results = new List(); - foreach (var attachmentId in attachmentIds) - { - await attachmentSummaryService.GenerateAndSaveAsync(attachmentId, promptVersion); - results.Add(new AttachmentSummaryResultDto { Completed = true }); - } - - return results; - } - // Internal-only: no HTTP endpoint, no auth check — safe for background job callers [AllowAnonymous] [RemoteService(IsEnabled = false)] diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs index 37d22f5bdb..fe5a68b569 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.Threading.Tasks; using Medallion.Threading; @@ -20,7 +21,8 @@ public class AIRateLimiter( IDistributedCache cache, ICurrentUser currentUser, IConfiguration configuration, - IDistributedLockProvider distributedLockProvider) : IAIRateLimiter, ITransientDependency + IDistributedLockProvider distributedLockProvider, + IEnumerable generationActivityProviders) : IAIRateLimiter, ITransientDependency { private const string CooldownKeyPrefix = "ai-generation:cooldown:"; private const string CooldownLockPrefix = "ai-generation:cooldown-lock:"; @@ -81,7 +83,7 @@ public virtual async Task GetStateAsync() { if (currentUser.Id is not Guid userId) { - return new AIRateLimitStateDto { RetryAfterSeconds = 0 }; + return new AIRateLimitStateDto { RetryAfterSeconds = 0, IsGenerating = false }; } var userLock = distributedLockProvider.CreateLock(CooldownLockPrefix + userId); @@ -89,11 +91,25 @@ public virtual async Task GetStateAsync() { return new AIRateLimitStateDto { - RetryAfterSeconds = await GetRemainingSecondsAsync(userId) + RetryAfterSeconds = await GetRemainingSecondsAsync(userId), + IsGenerating = await HasActiveGenerationAsync() }; } } + private async Task HasActiveGenerationAsync() + { + foreach (var provider in generationActivityProviders) + { + if (await provider.HasActiveGenerationAsync()) + { + return true; + } + } + + return false; + } + private async Task GetRemainingSecondsAsync(Guid userId) { var raw = await cache.GetStringAsync(KeyFor(userId)); diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/FlexApplicationMapperlyProfile.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/FlexApplicationMapperlyProfile.cs index 9027e28a1b..019367b090 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/FlexApplicationMapperlyProfile.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/FlexApplicationMapperlyProfile.cs @@ -32,6 +32,7 @@ public override void Map(Worksheet source, WorksheetDto destination) destination.Version = source.Version; destination.Published = source.Published; destination.ReportViewName = source.ReportViewName; + destination.IsArchived = source.IsArchived; destination.Sections = source.Sections? .Select(s => new WorksheetSectionMapper().Map(s)) .ToList() ?? []; diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/CustomFieldValueAppService.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/CustomFieldValueAppService.cs index c4bb4261d1..5f0accc309 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/CustomFieldValueAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/CustomFieldValueAppService.cs @@ -27,7 +27,12 @@ public async Task ExplicitSetAsync(Guid valueId, string value) [RemoteService(false)] public async Task ExplicitAddAsync(CustomFieldValueDto value) { - await customFieldValueRepository.InsertAsync(ObjectMapper.Map(value)); + var entity = new CustomFieldValue( + value.Id, + value.WorksheetInstanceId, + value.CustomFieldId, + value.CurrentValue ?? "{}"); + await customFieldValueRepository.InsertAsync(entity); } [RemoteService(false)] diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/EditDataRowModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/EditDataRowModal.cshtml.cs index 50fc621fa5..ebe5fe0968 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/EditDataRowModal.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/EditDataRowModal.cshtml.cs @@ -68,7 +68,8 @@ public async Task OnGetAsync(Guid valueId, Guid worksheetInstanceId, Guid formVersionId, Guid applicationId, - string uiAnchor) + string uiAnchor, + string columnOrder = "") { Row = row; ValueId = valueId; @@ -114,7 +115,7 @@ public async Task OnGetAsync(Guid valueId, DynamicKeyMap = JsonSerializer.Serialize(keyMap); - AllFields = MergeAndSortFields(DynamicFields ?? [], Properties ?? []); + AllFields = MergeAndSortFields(DynamicFields ?? [], Properties ?? [], columnOrder); } private static DynamicFieldMap[] PrefixDynamicFields(DynamicFieldMap[] dynamicFieldMaps) @@ -246,15 +247,26 @@ private static void ApplyDynamicFieldPresentationFormat( private sealed record DynamicKeyMapEntry(string Name, string Type, bool IsDynamic = true); - private static List MergeAndSortFields(DynamicFieldMap[] dynamicFields, List customFields) + private static List MergeAndSortFields(DynamicFieldMap[] dynamicFields, List customFields, string columnOrder) { + var columnKeys = columnOrder.Split(',', StringSplitOptions.RemoveEmptyEntries); + var orderMap = columnKeys + .Select((key, idx) => (key, idx)) + .ToDictionary(t => t.key, t => t.idx, StringComparer.OrdinalIgnoreCase); + + int GetOrder(string key) => orderMap.TryGetValue(key, out var idx) ? idx : int.MaxValue; + var fields = new List(); foreach (var df in dynamicFields) { + var rawKey = df.Key.StartsWith(DynamicFieldPrefix, StringComparison.Ordinal) + ? df.Key[DynamicFieldPrefix.Length..] + : df.Key; fields.Add(new EditRowField { SortKey = df.Name, + SortOrder = GetOrder(rawKey), DynamicField = df }); } @@ -264,16 +276,18 @@ private static List MergeAndSortFields(DynamicFieldMap[] dynamicFi fields.Add(new EditRowField { SortKey = cf.Label, + SortOrder = GetOrder(cf.Name), CustomField = cf }); } - return [.. fields.OrderBy(f => f.SortKey, StringComparer.OrdinalIgnoreCase)]; + return [.. fields.OrderBy(f => f.SortOrder).ThenBy(f => f.SortKey, StringComparer.OrdinalIgnoreCase)]; } public class EditRowField { public string SortKey { get; set; } = string.Empty; + public int SortOrder { get; set; } = int.MaxValue; public WorksheetFieldViewModel? CustomField { get; set; } public DynamicFieldMap? DynamicField { get; set; } public bool IsDynamic => DynamicField != null; diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.js b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.js index 6e1c2b2eda..7fd001ce6c 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.js @@ -162,31 +162,32 @@ $(function () { } return -1; // Return -1 if the column is not found } - function openEditDatagridRowModal(valueId, - fieldId, - worksheetId, - worksheetInstanceId, - row, - isNew, - uiAnchor) { - + function openEditDatagridRowModal(options) { let formVersionId = $('#ApplicationFormVersionId').val(); let applicationId = $('#DetailsViewApplicationId').val(); editDatagridRowModal.open({ - valueId: valueId, - fieldId: fieldId, - row: row, - isNew: isNew, - worksheetId: worksheetId, - worksheetInstanceId: worksheetInstanceId, + valueId: options.valueId, + fieldId: options.fieldId, + row: options.row, + isNew: options.isNew, + worksheetId: options.worksheetId, + worksheetInstanceId: options.worksheetInstanceId, // There is dependency here on the core module and details page ! formVersionId: formVersionId, applicationId: applicationId, - uiAnchor: uiAnchor + uiAnchor: options.uiAnchor, + columnOrder: options.columnOrder || '' }); } + function getColumnOrder(dt) { + return dt.columns().header().toArray() + .map(th => $(th).data('key')) + .filter(key => key !== undefined && key !== null) + .join(','); + } + let actionButtons = [ { id: 'AddRecord', @@ -201,13 +202,16 @@ $(function () { let tableElement = $('#' + tableId); let tableDataSet = tableElement[0].dataset; - openEditDatagridRowModal(tableDataSet.valueId, - tableDataSet.fieldId, - tableDataSet.wsId, - tableDataSet.wsiId, - 0, - true, - tableDataSet.wsAnchor); + openEditDatagridRowModal({ + valueId: tableDataSet.valueId, + fieldId: tableDataSet.fieldId, + worksheetId: tableDataSet.wsId, + worksheetInstanceId: tableDataSet.wsiId, + row: 0, + isNew: true, + uiAnchor: tableDataSet.wsAnchor, + columnOrder: getColumnOrder(dt) + }); } }, { @@ -320,13 +324,16 @@ $(function () { let table = $(button).closest('table'); let tableDataSet = table[0].dataset; - openEditDatagridRowModal(tableDataSet.valueId, - tableDataSet.fieldId, - tableDataSet.wsId, - tableDataSet.wsiId, - rowDataSet.rowNo, - false, - tableDataSet.uiAnchor); + openEditDatagridRowModal({ + valueId: tableDataSet.valueId, + fieldId: tableDataSet.fieldId, + worksheetId: tableDataSet.wsId, + worksheetInstanceId: tableDataSet.wsiId, + row: rowDataSet.rowNo, + isNew: false, + uiAnchor: tableDataSet.uiAnchor, + columnOrder: getColumnOrder(table.DataTable()) + }); } PubSub.subscribe( diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/NotificationsDataSeedContributor.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/NotificationsDataSeedContributor.cs index c47d8a0b06..caa34d854a 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/NotificationsDataSeedContributor.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/NotificationsDataSeedContributor.cs @@ -44,7 +44,8 @@ public async Task SeedAsync(DataSeedContext context) new() { Name = "Applicant ID", Token = "applicant_id", MapTo = "applicant.unityApplicantId" }, new() { Name = "Requested Amount", Token = "requested_amount", MapTo = "requestedAmount" }, new() { Name = "Recommended Amount", Token = "recommended_amount", MapTo = "recommendedAmount" }, - new() { Name = "Unity Application ID", Token = "unity_application_id", MapTo = "unityApplicationId" } + new() { Name = "Unity Application ID", Token = "unity_application_id", MapTo = "unityApplicationId" }, + new() { Name = "Today's Date", Token = "today_date", MapTo = "" } }; try @@ -74,9 +75,9 @@ await templateVariablesRepository.InsertAsync( var emailGroups = new List { - new() {Name = "FSB-AP", Description = "This group manages the recipients for PO-related payments, which will be sent to FSB-AP to update contracts and initiate payment creation.",Type = "static"}, - new() {Name = "Payments", Description = "This group manages the recipients for payment notifications, such as failures or errors",Type = "static"} - }; + new() {Name = "FSB-AP", Description = "This group manages the recipients for PO-related payments, which will be sent to FSB-AP to update contracts and initiate payment creation.",Type = "static"}, + new() {Name = "Payments", Description = "This group manages the recipients for payment notifications, such as failures or errors",Type = "static"} + }; try { var allGroups = await emailGroupsRepository.GetListAsync(); @@ -111,4 +112,4 @@ internal class EmailTempateVariableDto public string Token { get; set; } = string.Empty; public string MapTo { get; set; } = string.Empty; } -} +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Enums/PaymentRequestStatus.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Enums/PaymentRequestStatus.cs index 16d5575835..9d0bd15462 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Enums/PaymentRequestStatus.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Enums/PaymentRequestStatus.cs @@ -18,5 +18,6 @@ public enum PaymentRequestStatus Paid = 10, Failed = 11, FSB = 12, // Financial Services Branch - Prevent CAS Payment + HistoricalPayment = 13, } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Integrations/Cas/Invoice.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Integrations/Cas/Invoice.cs index 91dbedf70b..3eb2971e7b 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Integrations/Cas/Invoice.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Integrations/Cas/Invoice.cs @@ -1,7 +1,6 @@ -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; - +using System.Collections.Generic; namespace Unity.Payments.Integrations.Cas { diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/CreateHistoricalPaymentRequestDto.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/CreateHistoricalPaymentRequestDto.cs new file mode 100644 index 0000000000..1e7a83b668 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/CreateHistoricalPaymentRequestDto.cs @@ -0,0 +1,28 @@ +using System; + +namespace Unity.Payments.PaymentRequests +{ +#pragma warning disable CS8618 + [Serializable] + public class CreateHistoricalPaymentRequestDto + { + public string InvoiceNumber { get; set; } + public decimal Amount { get; set; } + public string? Description { get; set; } + public Guid CorrelationId { get; set; } + public Guid? SiteId { get; set; } + public string PayeeName { get; set; } + public string ContractNumber { get; set; } + public string? SupplierNumber { get; set; } + public string? SupplierName { get; set; } + public string CorrelationProvider { get; set; } = string.Empty; + public string BatchName { get; set; } + public decimal BatchNumber { get; set; } = 0; + public string ReferenceNumber { get; set; } = string.Empty; + public string SubmissionConfirmationCode { get; set; } = string.Empty; + public Guid? AccountCodingId { get; set; } + public string? Note { get; set; } + public string PaidDate { get; set; } + } +#pragma warning restore CS8618 +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs index 611002c023..bd604dbc3c 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs @@ -9,6 +9,7 @@ namespace Unity.Payments.PaymentRequests public interface IPaymentRequestAppService : IApplicationService { Task> CreateAsync(List paymentRequests); + Task> CreateHistoricalAsync(List paymentRequests); Task> GetListAsync(PagedAndSortedResultRequestDto input); Task GetTotalPaymentRequestAmountByCorrelationIdAsync(Guid correlationId); Task> GetListByApplicationIdAsync(Guid applicationId); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Exceptions/ErrorConsts.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Exceptions/ErrorConsts.cs index be14623c40..99f86d9452 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Exceptions/ErrorConsts.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Exceptions/ErrorConsts.cs @@ -9,5 +9,7 @@ public static class ErrorConsts public const string InvalidAccountCodingField = "Unity.Payments:Errors:InvalidAccountCodingFiled"; public const string L2ApproverRestriction = "Unity.Payments:Errors:L2ApproverRestriction"; public const string MissingSupplierNumber = "Unity.Payments:Errors:MissingSupplierNumber"; + public const string MissingSite = "Unity.Payments:Errors:MissingSite"; + public const string MissingAccountCoding = "Unity.Payments:Errors:MissingAccountCoding"; } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs index 629eab4a60..8a2f4d4314 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs @@ -9,6 +9,7 @@ using Volo.Abp; using Unity.Payments.Domain.Exceptions; using Unity.Payments.PaymentRequests; +using Unity.Payments.Codes; using Unity.Payments.Domain.PaymentTags; using Unity.Payments.Domain.AccountCodings; @@ -17,14 +18,8 @@ namespace Unity.Payments.Domain.PaymentRequests public class PaymentRequest : FullAuditedAggregateRoot, IMultiTenant, ICorrelationEntity { public Guid? TenantId { get; set; } - public virtual Guid SiteId { get; set; } - public virtual Site Site - { - set => _site = value; - get => _site - ?? throw new InvalidOperationException("Uninitialized property: " + nameof(Site)); - } - private Site? _site; + public virtual Guid? SiteId { get; set; } + public virtual Site? Site { get; set; } public virtual string InvoiceNumber { get; private set; } = string.Empty; public virtual decimal Amount { get; private set; } @@ -110,6 +105,35 @@ public PaymentRequest(Guid id, CreatePaymentRequestDto createPaymentRequestDto) ValidatePaymentRequest(); } + public PaymentRequest(Guid id, CreateHistoricalPaymentRequestDto dto) : base(id) + { + InvoiceNumber = dto.InvoiceNumber; + Amount = dto.Amount; + PayeeName = dto.PayeeName; + ContractNumber = dto.ContractNumber; + SupplierNumber = dto.SupplierNumber ?? string.Empty; + SupplierName = dto.SupplierName; + SiteId = dto.SiteId; + Description = dto.Description; + CorrelationId = dto.CorrelationId; + CorrelationProvider = dto.CorrelationProvider; + ReferenceNumber = dto.ReferenceNumber; + SubmissionConfirmationCode = dto.SubmissionConfirmationCode; + BatchName = dto.BatchName; + BatchNumber = dto.BatchNumber; + AccountCodingId = dto.AccountCodingId; + Note = dto.Note; + Status = PaymentRequestStatus.HistoricalPayment; + PaymentStatus = CasPaymentRequestStatus.Paid; + InvoiceStatus = CasPaymentRequestStatus.Paid; + PaymentDate = dto.PaidDate; + ExpenseApprovals = []; + PaymentTags = null; + + if (Amount <= 0) + throw new BusinessException(ErrorConsts.ZeroPayment); + } + public PaymentRequest SetNote(string note) { Note = note; @@ -210,6 +234,16 @@ public PaymentRequest ValidatePaymentRequest() throw new BusinessException(ErrorConsts.MissingSupplierNumber); } + if (!SiteId.HasValue || SiteId.Value == Guid.Empty) + { + throw new BusinessException(ErrorConsts.MissingSite); + } + + if (!AccountCodingId.HasValue || AccountCodingId.Value == Guid.Empty) + { + throw new BusinessException(ErrorConsts.MissingAccountCoding); + } + return this; } } 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 565b400a57..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 @@ -25,7 +25,8 @@ public class InvoiceManager( { public async Task GetSiteByPaymentRequestAsync(PaymentRequest paymentRequest) { - Site? site = await siteRepository.GetAsync(paymentRequest.SiteId, true); + if (!paymentRequest.SiteId.HasValue) return null; + Site? site = await siteRepository.GetAsync(paymentRequest.SiteId.Value, true); if (site?.SupplierId != null) { Supplier supplier = await supplierRepository.GetAsync(site.SupplierId); @@ -62,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/Domain/Services/PaymentRequestQueryManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs index 9edd0cb42c..c9b7f6e813 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs @@ -209,8 +209,11 @@ public async Task ManuallyAddPaymentRequestsToReconciliationQueueAsync(List(paymentRequest); - Site site = await siteRepository.GetAsync(paymentRequest.SiteId); - paymentRequestDto.Site = objectMapper.Map(site); + if (paymentRequest.SiteId.HasValue) + { + Site site = await siteRepository.GetAsync(paymentRequest.SiteId.Value); + paymentRequestDto.Site = objectMapper.Map(site); + } paymentRequestDtos.Add(paymentRequestDto); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsDbContextModelCreatingExtensions.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsDbContextModelCreatingExtensions.cs index 6f1a352510..f476876bd9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsDbContextModelCreatingExtensions.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsDbContextModelCreatingExtensions.cs @@ -33,6 +33,7 @@ public static void ConfigurePayments( b.HasOne(e => e.Site) .WithMany() .HasForeignKey(x => x.SiteId) + .IsRequired(false) .OnDelete(DeleteBehavior.NoAction); b.HasOne(e => e.AccountCoding) 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 f26ca656cf..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) { @@ -140,8 +140,9 @@ public async Task> GetBatchPaymentRollupsByCor { ApplicationId = g.Key, TotalPaid = g - .Where(p => p.PaymentStatus != null - && p.PaymentStatus.Trim().ToUpper() == CasPaymentRequestStatus.FullyPaid.ToUpper()) + .Where(p => (p.PaymentStatus != null + && p.PaymentStatus.Trim().ToUpper() == CasPaymentRequestStatus.FullyPaid.ToUpper()) + || p.Status == PaymentRequestStatus.HistoricalPayment) .Sum(p => p.Amount), TotalPending = g .Where(p => p.Status == PaymentRequestStatus.L1Pending 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 57b08ccd98..9cd659d376 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 @@ -3,7 +3,10 @@ using System.Threading.Tasks; using System.Text.Json; using System; +using System.Globalization; +using System.Text.RegularExpressions; using Unity.Payments.Integrations.Http; +using System.Text.Encodings.Web; using Volo.Abp.Application.Services; using System.Collections.Generic; using Unity.Payments.Enums; @@ -17,6 +20,10 @@ using Unity.GrantManager.Integrations; using Unity.Payments.Domain.Services; using Volo.Abp.MultiTenancy; +using System.Linq; +using Volo.Abp.Domain.Repositories; +using Unity.SharedKernel.Utilities; +using Volo.Abp.Identity; namespace Unity.Payments.Integrations.Cas { @@ -28,10 +35,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() { @@ -39,63 +50,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) - { - // 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; - casInvoice.PaymentAdviceComments = paymentRequest.Description; - - InvoiceLineDetail invoiceLineDetail = new() - { - InvoiceLineNumber = 1, - InvoiceLineAmount = paymentRequest.Amount, - DefaultDistributionAccount = accountDistributionCode // This will be at the tenant level - }; - casInvoice.InvoiceLineDetails = [invoiceLineDetail]; + if (site == null || + site.Supplier == null || + string.IsNullOrWhiteSpace(site.Supplier.Number) || + string.IsNullOrWhiteSpace(accountDistributionCode)) + { + return null; } + + // 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; } - public async Task CreateInvoiceByPaymentRequestAsync(string invoiceNumber) + private async Task GetLevel1DecisionUserNameAsync( + PaymentRequest? paymentRequest) + { + if (paymentRequest == null) + { + return string.Empty; + } + + Guid? decisionUserId = null; + + try + { + if (paymentRequest.ExpenseApprovals == null || + paymentRequest.ExpenseApprovals.Count == 0) + { + 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 (Exception ex) + { + Logger.LogWarning( + ex, + "Failed resolving Level1 approver for payment request {PaymentRequestId}", + paymentRequest.Id); + + return string.Empty; + } + } + + private async Task BuildPaymentDescriptionAsync( + string? existingDescription) + { + if (!string.IsNullOrWhiteSpace(existingDescription)) + { + var trimmed = existingDescription.Trim(); + + return trimmed.Length > 50 + ? trimmed[..50] + : trimmed; + } + + 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[..50]; + } + + return generated; + } + + 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); } } } @@ -111,59 +251,91 @@ public class InvoiceService( public async Task CreateInvoiceAsync(Invoice casAPInvoice) { - string jsonString = JsonSerializer.Serialize(casAPInvoice); + var jsonOptions = new JsonSerializerOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + string jsonString = JsonSerializer.Serialize(casAPInvoice, jsonOptions); + + 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); @@ -172,15 +344,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(); } @@ -189,22 +366,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", @@ -229,8 +411,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", @@ -242,8 +424,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/Integrations/Cas/SupplierService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/SupplierService.cs index 23147665c9..d9e7a06529 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/SupplierService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/SupplierService.cs @@ -146,7 +146,9 @@ string GetProp(string name) => string supplierprotected = GetProp("supplierprotected"); string standardindustryclassification = GetProp("standardindustryclassification"); - _ = DateTime.TryParse(lastUpdated, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out DateTime lastUpdatedDate); + DateTime? lastUpdatedDate = DateTime.TryParse(lastUpdated, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out DateTime parsedLastUpdated) + ? parsedLastUpdated + : null; var siteEtos = new List(); if (casSupplierResponse.TryGetProperty("supplieraddress", out var sitesJson) && @@ -181,7 +183,9 @@ public static SiteEto GetSiteEto(JsonElement site) ? new string('*', accountNumber.Length - 4) + accountNumber[^4..] : accountNumber; string siteLastUpdated = GetJsonProperty("lastupdated", site); - _ = DateTime.TryParse(siteLastUpdated, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out DateTime siteLastUpdatedDate); + DateTime? siteLastUpdatedDate = DateTime.TryParse(siteLastUpdated, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out DateTime parsedSiteLastUpdated) + ? parsedSiteLastUpdated + : null; return new SiteEto { 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.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs index a4698477af..e97d71ab6a 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs @@ -55,7 +55,7 @@ public async Task AddPaymentRequestsToInvoiceQueue(PaymentRequest paymentRequest PaymentRequestId = paymentRequest.Id, InvoiceNumber = paymentRequest.InvoiceNumber, SupplierNumber = paymentRequest.SupplierNumber, - SiteNumber = paymentRequest.Site.Number, + SiteNumber = paymentRequest.Site?.Number ?? string.Empty, TenantId = (Guid)currentTenant.Id }; @@ -103,7 +103,7 @@ public async Task AddPaymentRequestsToReconciliationQueue() PaymentRequestId = paymentRequest.Id, InvoiceNumber = paymentRequest.InvoiceNumber, SupplierNumber = paymentRequest.SupplierNumber, - SiteNumber = paymentRequest.Site.Number, + SiteNumber = paymentRequest.Site?.Number ?? string.Empty, TenantId = tenantId }; diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs index a387f4099a..fd46067031 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs @@ -43,29 +43,46 @@ public class PaymentRequestAppService( public virtual async Task> CreateAsync(List paymentRequests) { List createdPayments = []; - var paymentConfig = await paymentRequestConfigurationManager.GetPaymentConfigurationAsync(); - var paymentIdPrefix = string.Empty; + var (batchName, batchNumber, nextSequenceNumber, paymentIdPrefix) = await GetBatchSetupAsync(); - if (paymentConfig != null && !paymentConfig.PaymentIdPrefix.IsNullOrEmpty()) + foreach (var paymentRequestItem in paymentRequests.Select((value, i) => new { i, value })) { - paymentIdPrefix = paymentConfig.PaymentIdPrefix; + try + { + // referenceNumber + Chefs Confirmation ID + 6 digit sequence based on sequence number and index + CreatePaymentRequestDto paymentRequestDto = paymentRequestItem.value; + var (invoiceNumber, referenceNumber) = GeneratePaymentNumbers(paymentIdPrefix, paymentRequestDto.InvoiceNumber, nextSequenceNumber, paymentRequestItem.i); + + paymentRequestDto.InvoiceNumber = invoiceNumber; + paymentRequestDto.ReferenceNumber = referenceNumber; + paymentRequestDto.BatchName = batchName; + paymentRequestDto.BatchNumber = batchNumber; + + var payment = new PaymentRequest(Guid.NewGuid(), paymentRequestDto); + var result = await paymentRequestQueryManager.InsertPaymentRequestAsync(payment); + createdPayments.Add(MapToPaymentRequestDto(result)); + } + catch (Exception ex) + { + Logger.LogException(ex); + throw; + } } + return createdPayments; + } - var batchNumber = await paymentRequestConfigurationManager.GetMaxBatchNumberAsync(); - var batchName = $"{paymentIdPrefix}_UNITY_BATCH_{batchNumber}"; - var currentYear = DateTime.UtcNow.Year; - var nextSequenceNumber = await paymentRequestConfigurationManager.GetNextSequenceNumberAsync(currentYear); + [Authorize(PaymentsPermissions.Payments.AddHistoricalPayment)] + public virtual async Task> CreateHistoricalAsync(List paymentRequests) + { + List createdPayments = []; + var (batchName, batchNumber, nextSequenceNumber, paymentIdPrefix) = await GetBatchSetupAsync(); foreach (var paymentRequestItem in paymentRequests.Select((value, i) => new { i, value })) { try { - // referenceNumber + Chefs Confirmation ID + 6 digit sequence based on sequence number and index - CreatePaymentRequestDto paymentRequestDto = paymentRequestItem.value; - string referenceNumberPrefix = paymentRequestConfigurationManager.GenerateReferenceNumberPrefix(paymentIdPrefix); - string sequenceNumber = paymentRequestConfigurationManager.GenerateSequenceNumber(nextSequenceNumber, paymentRequestItem.i); - string referenceNumber = paymentRequestConfigurationManager.GenerateReferenceNumber(referenceNumberPrefix, sequenceNumber); - string invoiceNumber = paymentRequestConfigurationManager.GenerateInvoiceNumber(referenceNumberPrefix, paymentRequestDto.InvoiceNumber, sequenceNumber); + CreateHistoricalPaymentRequestDto paymentRequestDto = paymentRequestItem.value; + var (invoiceNumber, referenceNumber) = GeneratePaymentNumbers(paymentIdPrefix, paymentRequestDto.InvoiceNumber, nextSequenceNumber, paymentRequestItem.i); paymentRequestDto.InvoiceNumber = invoiceNumber; paymentRequestDto.ReferenceNumber = referenceNumber; @@ -74,32 +91,62 @@ public virtual async Task> CreateAsync(List GetBatchSetupAsync() + { + var paymentConfig = await paymentRequestConfigurationManager.GetPaymentConfigurationAsync(); + var paymentIdPrefix = string.Empty; + if (paymentConfig != null && !paymentConfig.PaymentIdPrefix.IsNullOrEmpty()) + { + paymentIdPrefix = paymentConfig.PaymentIdPrefix; + } + var batchNumber = await paymentRequestConfigurationManager.GetMaxBatchNumberAsync(); + var batchName = $"{paymentIdPrefix}_UNITY_BATCH_{batchNumber}"; + var nextSequenceNumber = await paymentRequestConfigurationManager.GetNextSequenceNumberAsync(DateTime.UtcNow.Year); + return (batchName, batchNumber, nextSequenceNumber, paymentIdPrefix); + } + + private (string invoiceNumber, string referenceNumber) GeneratePaymentNumbers( + string paymentIdPrefix, string originalInvoiceNumber, int nextSequenceNumber, int index) + { + string referenceNumberPrefix = paymentRequestConfigurationManager.GenerateReferenceNumberPrefix(paymentIdPrefix); + string sequenceNumber = paymentRequestConfigurationManager.GenerateSequenceNumber(nextSequenceNumber, index); + string referenceNumber = paymentRequestConfigurationManager.GenerateReferenceNumber(referenceNumberPrefix, sequenceNumber); + string invoiceNumber = paymentRequestConfigurationManager.GenerateInvoiceNumber(referenceNumberPrefix, originalInvoiceNumber, sequenceNumber); + return (invoiceNumber, referenceNumber); + } + + private static PaymentRequestDto MapToPaymentRequestDto(PaymentRequest result) + { + return new PaymentRequestDto() + { + Id = result.Id, + InvoiceNumber = result.InvoiceNumber, + InvoiceStatus = result.InvoiceStatus, + Amount = result.Amount, + PayeeName = result.PayeeName, + SupplierNumber = result.SupplierNumber, + ContractNumber = result.ContractNumber, + CorrelationId = result.CorrelationId, + CorrelationProvider = result.CorrelationProvider, + Description = result.Description, + CreationTime = result.CreationTime, + Status = result.Status, + ReferenceNumber = result.ReferenceNumber, + SubmissionConfirmationCode = result.SubmissionConfirmationCode + }; + } + public async Task GetNextBatchInfoAsync() { return await paymentRequestConfigurationManager.GetNextBatchInfoAsync(); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Permissions/PaymentsPermissionDefinitionProvider.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Permissions/PaymentsPermissionDefinitionProvider.cs index d29c48009b..e4186e3b76 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Permissions/PaymentsPermissionDefinitionProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Permissions/PaymentsPermissionDefinitionProvider.cs @@ -19,6 +19,7 @@ public override void Define(IPermissionDefinitionContext context) paymentsPermissions.AddChild(PaymentsPermissions.Payments.L3ApproveOrDecline, L("Permission:Payments.L3ApproveOrDecline")); paymentsPermissions.AddChild(PaymentsPermissions.Payments.RequestPayment, L("Permission:Payments.RequestPayment")); paymentsPermissions.AddChild(PaymentsPermissions.Payments.AccountCodingOverride, L("Permission:Payments.AccountCodingOverride")); + paymentsPermissions.AddChild(PaymentsPermissions.Payments.AddHistoricalPayment, L("Permission:Payments.AddHistoricalPayment")); //-- PAYMENT INFO PERMISSIONS grantApplicationPermissionsGroup.Add_PaymentInfo_Permissions(); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Unity.Payments.Application.csproj b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Unity.Payments.Application.csproj index b1cad6e2c4..86f2b532cf 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Unity.Payments.Application.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Unity.Payments.Application.csproj @@ -36,6 +36,7 @@ + diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json index 79e9083674..bd8d494ef3 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json @@ -7,6 +7,8 @@ "Unity.Payments:Errors:ThresholdExceeded": "There are payments in this batch that require a third level of approval. Please remove them from this batch and add to another for the appropriate level of approval", "Unity.Payments:Errors:ZeroPayment": "Cannot submit a payment request for $0.00", "Unity.Payments:Errors:MissingSupplierNumber": "Cannot submit a payment request without a supplier number", + "Unity.Payments:Errors:MissingSite": "Cannot submit a payment request without a site", + "Unity.Payments:Errors:MissingAccountCoding": "Cannot submit a payment request without an account coding", "Unity.Payments:Errors:ConfigurationExists": "Configuration already exitst", "Unity.Payments:Errors:ConfigurationDoesNotExist": "Configuration does not exits", "Unity.Payments:Errors:InvalidAccountCodingFiled": "Invalid account coding field {field} : {length}", @@ -124,6 +126,7 @@ "Permission:Payments.RequestPayment": "Request Payment", "Permission:Payments.AccountCodingOverride": "Override Account Coding", "Permission:Payments.EditFormPaymentConfiguration": "Edit Form Payment Configuration", + "Permission:Payments.AddHistoricalPayment": "Add Historical Payment", "Enum:PaymentRequestStatus.L1Pending": "L1 Pending", "Enum:PaymentRequestStatus.L1Approved": "L1 Approved", "Enum:PaymentRequestStatus.L1Declined": "L1 Declined", @@ -140,6 +143,11 @@ "Enum:PaymentRequestStatus.Paid": "Paid", "Enum:PaymentRequestStatus.Failed": "Payment Failed", "Enum:PaymentRequestStatus.PaymentFailed": "Payment Failed", + "Enum:PaymentRequestStatus.HistoricalPayment": "Historical Payment", + "ApplicationHistoricalPaymentRequest:Title": "Historical Payment", + "ApplicationHistoricalPaymentRequest:PaidDate": "Paid Date", + "ApplicationHistoricalPaymentRequest:SubmitButtonText": "Add Historical Payment", + "ApplicationHistoricalPaymentRequest:CancelButtonText": "Cancel", "Unity.GrantManager.ApplicationManagement.Payment": "Payment Info", "Unity.GrantManager.ApplicationManagement.Payment.Summary": "Payment Summary", diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Permissions/PaymentsPermissions.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Permissions/PaymentsPermissions.cs index 0cda791d74..a0b470a799 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Permissions/PaymentsPermissions.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Permissions/PaymentsPermissions.cs @@ -17,7 +17,8 @@ public static class Payments public const string AccountCodingOverride = Default + ".AccountCodingOverride"; public const string EditSupplierInfo = Default + ".EditSupplierInfo"; public const string EditFormPaymentConfiguration = Default + ".EditFormPaymentConfiguration"; - } + public const string AddHistoricalPayment = Default + ".AddHistoricalPayment"; + } public static string[] GetAll() { diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreateHistoricalPayments.cshtml b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreateHistoricalPayments.cshtml new file mode 100644 index 0000000000..19f6b69409 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreateHistoricalPayments.cshtml @@ -0,0 +1,221 @@ +@page +@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal +@using Unity.Payments.Localization +@using Microsoft.Extensions.Localization +@model Unity.Payments.Web.Pages.Payments.CreateHistoricalPaymentsModel + +@inject IStringLocalizer L +@{ + Layout = null; +} + + + +
+ + + + @if (ViewData["Error"] != null) + { + + } + else + { + + + @{ + string? currentGroupKey = null; + bool groupOpen = false; + } + @for (var i = 0; i < Model.ApplicationPaymentRequestForm?.Count; i++) + { + var item = Model.ApplicationPaymentRequestForm[i]; + var itemParentReference = !string.IsNullOrEmpty(item.ParentReferenceNo) + ? item.ParentReferenceNo + : item.SubmissionConfirmationCode; + string? itemGroupKey = item.IsPartOfParentChildGroup + ? itemParentReference + : null; + + string? nextGroupKey = null; + if (i + 1 < Model.ApplicationPaymentRequestForm.Count) + { + var next = Model.ApplicationPaymentRequestForm[i + 1]; + var nextParentReference = !string.IsNullOrEmpty(next.ParentReferenceNo) + ? next.ParentReferenceNo + : next.SubmissionConfirmationCode; + nextGroupKey = next.IsPartOfParentChildGroup + ? nextParentReference + : null; + } + + if (itemGroupKey != null && itemGroupKey != currentGroupKey) + { + @:
+ groupOpen = true; + } + +
+ + +
+ @item.ApplicantName/@item.InvoiceNumber + @if (!string.IsNullOrEmpty(item.ParentReferenceNo)) + { +   (Parent Id: @item.ParentReferenceNo) + } +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + +
+ $ + +
+
+ + + + + + + + + + + + + @for (var j = 0; j < item.ErrorList?.Count; j++) + { + + Error  @item.ErrorList[j] + + } + @if (!item.SiteId.HasValue || item.SiteId == Guid.Empty) + { + + Note  No site selected for this payment. You can still add the payment. + + } +
+
+ + if (groupOpen && nextGroupKey != itemGroupKey) + { + @: + @:
+ groupOpen = false; + } + + currentGroupKey = itemGroupKey; + } + +
+
+ } +
+ + + + +
+
+ + diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreateHistoricalPayments.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreateHistoricalPayments.cshtml.cs new file mode 100644 index 0000000000..d26612341a --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreateHistoricalPayments.cshtml.cs @@ -0,0 +1,171 @@ +using Microsoft.AspNetCore.Mvc; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.GrantApplications; +using Unity.Payments.PaymentRequests; +using Volo.Abp; +using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Payments; +using Unity.Payments.Domain.Suppliers; +using Unity.Payments.PaymentConfigurations; + +namespace Unity.Payments.Web.Pages.Payments +{ + [SuppressMessage("Major Code Smell", "S107:Methods should not have too many parameters", Justification = "Primary constructor for dependency injection")] + public class CreateHistoricalPaymentsModel( + IGrantApplicationAppService applicationService, + IPaymentRequestAppService paymentRequestAppService, + IApplicationFormRepository applicationFormRepository, + ISiteRepository siteRepository, + IPaymentSettingsAppService paymentSettingsAppService, + ApplicationIdsCacheService cacheService, + PaymentRequestPageHelperService helperService + ) : AbpPageModel + { + public List SelectedApplicationIds { get; set; } = []; + + [BindProperty] + public List ApplicationPaymentRequestForm { get; set; } = []; + + [BindProperty] + public bool DisableSubmit { get; set; } + + public async Task OnGetAsync(string cacheKey) + { + try + { + var applicationIds = await cacheService.GetApplicationIdsAsync(cacheKey); + + if (applicationIds == null || applicationIds.Count == 0) + { + Logger.LogWarning("Cache key expired or invalid: {CacheKey}", cacheKey); + ViewData["Error"] = "The session has expired. Please select applications and try again."; + DisableSubmit = true; + return; + } + + SelectedApplicationIds = applicationIds; + Logger.LogInformation("Successfully loaded historical payment modal for {Count} applications", applicationIds.Count); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error loading historical payment modal"); + ViewData["Error"] = "An error occurred while loading the historical payment form. Please try again."; + DisableSubmit = true; + return; + } + + var applications = await applicationService.GetApplicationDetailsListAsync(SelectedApplicationIds); + + foreach (var application in applications) + { + decimal remainingAmount = await helperService.GetRemainingAmountAsync(application); + var applicationForm = await applicationFormRepository.GetAsync(application.ApplicationForm.Id); + Guid? accountCodingId = await paymentSettingsAppService.GetAccountCodingIdByApplicationIdAsync(application.Id); + + HistoricalPaymentsModel request = new() + { + CorrelationId = application.Id, + ApplicantName = application.Applicant.ApplicantName == "" ? "Applicant Name" : application.Applicant.ApplicantName, + SubmissionConfirmationCode = application.ReferenceNo, + Amount = remainingAmount, + Description = "", + InvoiceNumber = application.ReferenceNo, + ContractNumber = application.ContractNumber, + RemainingAmount = remainingAmount, + AccountCodingId = accountCodingId + }; + + var supplier = await helperService.GetSupplierAsync(application); + string supplierNumber = supplier?.Number ?? string.Empty; + + Guid? siteId = application.DefaultSiteId; + Site? site = null; + if (siteId.HasValue && siteId != Guid.Empty) + { + site = await siteRepository.GetAsync(siteId.Value); + request.SiteName = $"{site.Number} ({supplierNumber}, {site.City})"; + request.SiteId = siteId; + } + + request.SupplierName = supplier?.Name; + request.SupplierNumber = supplierNumber; + + var (errorList, parentReferenceNo) = await helperService.GetErrorListAsync( + supplier, site, application, applicationForm, remainingAmount, + accountCodingId, isHistorical: true); + + request.ErrorList = errorList; + request.ParentReferenceNo = parentReferenceNo; + + if (request.ErrorList.Count > 0) + { + request.DisableFields = true; + } + + ApplicationPaymentRequestForm!.Add(request); + } + + ApplicationPaymentRequestForm = helperService.SortByHierarchy(ApplicationPaymentRequestForm); + await helperService.PopulateParentChildValidationDataAsync(ApplicationPaymentRequestForm); + } + + public async Task OnPostAsync() + { + if (ApplicationPaymentRequestForm == null) return NoContent(); + + foreach (var payment in ApplicationPaymentRequestForm) + { + if (string.IsNullOrWhiteSpace(payment.PaidDate)) + throw new UserFriendlyException("Paid Date is required for all historical payments."); + + if (!DateTime.TryParseExact(payment.PaidDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var paidDate)) + throw new UserFriendlyException($"Paid Date '{payment.PaidDate}' is not a valid date."); + + if (paidDate.Date > DateTime.Today) + throw new UserFriendlyException("Paid Date cannot be in the future."); + + payment.PaidDate = paidDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + } + + var standaloneErrors = await helperService.ValidateStandalonePaymentAmountsAsync(ApplicationPaymentRequestForm); + if (standaloneErrors.Count != 0) + { + throw new UserFriendlyException(string.Join(" ", standaloneErrors)); + } + + var validationErrors = await helperService.ValidateParentChildPaymentAmountsAsync(ApplicationPaymentRequestForm); + if (validationErrors.Count != 0) + { + throw new UserFriendlyException(string.Join(" ", validationErrors)); + } + + var payments = ApplicationPaymentRequestForm.Select(payment => new CreateHistoricalPaymentRequestDto() + { + Amount = payment.Amount, + CorrelationId = payment.CorrelationId, + SiteId = payment.SiteId, + AccountCodingId = payment.AccountCodingId, + Description = payment.Description, + InvoiceNumber = payment.InvoiceNumber, + ContractNumber = payment.ContractNumber ?? string.Empty, + SupplierName = payment.SupplierName, + SupplierNumber = payment.SupplierNumber, + PayeeName = payment.ApplicantName ?? string.Empty, + SubmissionConfirmationCode = payment.SubmissionConfirmationCode ?? string.Empty, + CorrelationProvider = PaymentConsts.ApplicationCorrelationProvider, + PaidDate = payment.PaidDate, + }).ToList(); + + await paymentRequestAppService.CreateHistoricalAsync(payments); + + return NoContent(); + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreateHistoricalPaymentsModal.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreateHistoricalPaymentsModal.js new file mode 100644 index 0000000000..b4f8787f70 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreateHistoricalPaymentsModal.js @@ -0,0 +1,93 @@ +function removeHistoricalPaymentRequest(applicationId) { + let $container = $('#' + applicationId); + let $parentGroup = $container.closest('.parent-child-group'); + $container.remove(); + + if ($('div.single-payment').length) { + $('#no-payment-msg').css('display', 'none'); + } else { + $('#no-payment-msg').css('display', 'block'); + $('#historical-payment-modal').find('#btnSubmitHistoricalPayment').prop('disabled', true); + } + + if ($parentGroup.length && $parentGroup.find('.single-payment').length === 0) { + $parentGroup.remove(); + } + + validateAllHistoricalPaymentAmounts(); +} + +function closeHistoricalPaymentModal() { + $('#historical-payment-modal').modal('hide'); +} + +function checkHistoricalMaxValueRequest(applicationId, input, amountRemaining) { + if (isPartOfParentChildGroup(applicationId)) { + validateParentChildAmounts(applicationId); + } else { + let enteredValue = Number.parseFloat(input.value.replaceAll(',', '')); + let remainingErrorId = '#error_column_' + applicationId; + if (amountRemaining < enteredValue) { + $(remainingErrorId).css('display', 'block'); + } else { + $(remainingErrorId).css('display', 'none'); + } + } +} + +function validateAllHistoricalPaymentAmounts() { + $('input[name*=".CorrelationId"]').each(function () { + let correlationId = $(this).val(); + let index = getIndexByCorrelationId(correlationId); + let isPartOfGroup = + $(`input[name="ApplicationPaymentRequestForm[${index}].IsPartOfParentChildGroup"]`).val() === 'True'; + + if (isPartOfGroup) { + validateParentChildAmounts(correlationId); + } else { + let amountInput = $(`input[name="ApplicationPaymentRequestForm[${index}].Amount"]`); + let remainingAmount = Number.parseFloat( + $(`input[name="ApplicationPaymentRequestForm[${index}].RemainingAmount"]`).val() + ); + let enteredValue = Number.parseFloat(amountInput.val().replaceAll(',', '')) || 0; + let remainingErrorId = `#error_column_${correlationId}`; + + if (enteredValue > remainingAmount) { + $(remainingErrorId).css('display', 'block'); + } else { + $(remainingErrorId).css('display', 'none'); + } + } + }); +} + +function submitHistoricalPayments() { + validateAllHistoricalPaymentAmounts(); + + let validationFailed = $('.payment-error-column:visible').length > 0; + + if (validationFailed) { + abp.notify.error( + '', + 'There are payment requests that are in error. Please remove or fix them before submitting.' + ); + return false; + } + + if (!$('#historicalpaymentform').valid()) { + return false; + } + + abp.message.confirm( + 'You are about to create and associate a historical payment to the current application. This action bypasses the standard approval workflow and creates a payment with Paid status. Are you sure you want to proceed?', + 'Add Historical Payment', + function (confirmed) { + if (!confirmed) return; + $('#historicalpaymentform').submit(); + } + ); +} + +$(function () { + validateAllHistoricalPaymentAmounts(); +}); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml index 7509ead8f8..2c25168f17 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml @@ -191,7 +191,7 @@ - + diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml.cs index 53e3722655..ccebb2e77c 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml.cs @@ -2,23 +2,20 @@ using System.Collections.Generic; using System; using System.Diagnostics.CodeAnalysis; -using Unity.Payments.Suppliers; using Unity.Payments.PaymentRequests; using System.Threading.Tasks; using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; using Unity.Payments.PaymentConfigurations; using Unity.GrantManager.GrantApplications; -using Unity.Payments.Domain.Suppliers; -using System.Linq; -using Unity.GrantManager.Payments; using Unity.GrantManager.Applications; -using Unity.GrantManager.ApplicationForms; using Volo.Abp; -using Unity.Payments.Enums; +using System.Linq; using Microsoft.Extensions.Logging; using Unity.Payments.Domain.AccountCodings; using Microsoft.AspNetCore.Authorization; using Unity.Payments.Permissions; +using Unity.Payments.Domain.Suppliers; +using Unity.GrantManager.Payments; namespace Unity.Payments.Web.Pages.Payments @@ -29,13 +26,11 @@ public class CreatePaymentRequestsModel( IGrantApplicationAppService applicationService, IPaymentRequestAppService paymentRequestAppService, IPaymentConfigurationAppService paymentConfigurationAppService, - ISupplierAppService iSupplierAppService, ISiteRepository siteRepository, IPaymentSettingsAppService paymentSettingsAppService, - IApplicationLinksService applicationLinksService, IApplicationFormRepository applicationFormRepository, - IApplicationFormAppService applicationFormAppService, - ApplicationIdsCacheService cacheService + ApplicationIdsCacheService cacheService, + PaymentRequestPageHelperService helperService ) : AbpPageModel { @@ -58,7 +53,7 @@ ApplicationIdsCacheService cacheService public string? DefaultAccountCodingId { get; set; } [BindProperty] - public string? AccountCodingOverride { get; set; } + public string? AccountCodingOverride { get; set; } [BindProperty] public string BatchNumberDisplay { get; set; } = string.Empty; @@ -119,7 +114,7 @@ public async Task OnGetAsync(string cacheKey) foreach (var application in applications) { - decimal remainingAmount = await GetRemainingAmountAllowedByApplicationAsync(application); + decimal remainingAmount = await helperService.GetRemainingAmountAsync(application); // Grabs the Account Coding ID from the Application Form and if there is none then the Payment Configuration // If neither exist then an error on the payment request will be shown @@ -141,12 +136,13 @@ public async Task OnGetAsync(string cacheKey) AccountCodingId = accountCodingId }; - var supplier = await GetSupplierByApplicationAync(application); - string supplierNumber = supplier?.Number?? string.Empty; + var supplier = await helperService.GetSupplierAsync(application); + string supplierNumber = supplier?.Number ?? string.Empty; Guid siteId = application.DefaultSiteId ?? Guid.Empty; Site? site = null; - if(siteId != Guid.Empty) { + if (siteId != Guid.Empty) + { site = await siteRepository.GetAsync(siteId); var siteName = $"{site.Number} ({supplierNumber}, {site.City})"; request.SiteName = siteName; @@ -156,7 +152,7 @@ public async Task OnGetAsync(string cacheKey) request.SupplierName = supplier?.Name; request.SupplierNumber = supplierNumber; - var (errorList, parentReferenceNo) = await GetErrorlist(supplier, site, application, applicationForm, remainingAmount, accountCodingId); + var (errorList, parentReferenceNo) = await helperService.GetErrorListAsync(supplier, site, application, applicationForm, remainingAmount, accountCodingId); request.ErrorList = errorList; request.ParentReferenceNo = parentReferenceNo; @@ -168,219 +164,76 @@ public async Task OnGetAsync(string cacheKey) ApplicationPaymentRequestForm!.Add(request); } - ApplicationPaymentRequestForm = SortPaymentRequestsByHierarchy(ApplicationPaymentRequestForm); + ApplicationPaymentRequestForm = helperService.SortByHierarchy(ApplicationPaymentRequestForm); // Populate parent-child validation data for frontend - await PopulateParentChildValidationData(); + await helperService.PopulateParentChildValidationDataAsync(ApplicationPaymentRequestForm); var batchName = await paymentRequestAppService.GetNextBatchInfoAsync(); BatchNumberDisplay = batchName; TotalAmount = ApplicationPaymentRequestForm?.Sum(x => x.Amount) ?? 0m; } - private async Task<(List ErrorList, string? ParentReferenceNo)> GetErrorlist(SupplierDto? supplier, Site? site, GrantApplicationDto application, ApplicationForm applicationForm, decimal remainingAmount, Guid? accountCodingId) - { - bool missingFields = false; - - List errorList = []; - if (supplier == null || site == null || string.IsNullOrWhiteSpace(supplier.Number)) - { - missingFields = true; - } - - // If the site paygroup is eft but there is no bank account - if(site != null && site.PaymentGroup == PaymentGroup.EFT && string.IsNullOrWhiteSpace(site.BankAccount)) - { - errorList.Add("Payment cannot be submitted because the default site’s pay group is set to EFT, but no bank account is configured. Please update the bank account before proceeding."); - } - - if (remainingAmount <= 0) - { - errorList.Add("There is no remaining amount for this application."); - } - - if (missingFields) - { - errorList.Add("Some payment information is missing for this applicant. Please make sure supplier information is provided and default site is selected."); - } - - if (application.StatusCode != GrantApplicationState.GRANT_APPROVED) - { - errorList.Add("The selected Application is not Approved. To continue please remove the item from the list."); - } - - var allLinks = await applicationLinksService.GetListByApplicationAsync(application.Id); - var parentLink = allLinks.Find(link => link.LinkType == ApplicationLinkType.Parent && link.ApplicationId != application.Id); - if (parentLink != null) - { - var parentApplication = await applicationService.GetAsync(parentLink.ApplicationId); - if (parentApplication.Id == Guid.Empty || parentApplication.StatusCode != GrantApplicationState.GRANT_APPROVED) - { - errorList.Add("Payment cannot be processed because the linked parent submission is not approved. Please ensure the parent submission is approved before creating a payment."); - } - } - - if (!application.ApplicationForm.Payable) - { - errorList.Add("The selected application is not Payable. To continue please remove the item from the list."); - } - - if(accountCodingId == null || accountCodingId == Guid.Empty) - { - errorList.Add("The selected application form does not have an Account Coding or no default Account Coding is set."); - } - - // Add form hierarchy and parent link validation - var (hierarchyErrors, parentReferenceNo) = await ValidateFormHierarchyAndParentLink(application, applicationForm); - errorList.AddRange(hierarchyErrors); - - return (errorList, parentReferenceNo); - } - - private async Task GetRemainingAmountAllowedByApplicationAsync(GrantApplicationDto application) + public async Task OnPostAsync() { - decimal remainingAmount = 0; - // Calculate the "Future paid amount" and if it is more than Approved Amount, the system shall: - // Highlight the record - // Show error message: This payment exceeds the Approved Amount. - // Future paid amount: Total Pending Amount + Total Paid amount + Amount that is in the current payment request - if (application.ApprovedAmount > 0) - { - decimal approvedAmmount = application.ApprovedAmount; - decimal totalFutureRequested = await paymentRequestAppService.GetTotalPaymentRequestAmountByCorrelationIdAsync(application.Id); - - // If this application has children, include their paid/pending amounts too - decimal childrenTotalPaidPending = 0; - var applicationLinks = await applicationLinksService.GetListByApplicationAsync(application.Id); - var childLinks = applicationLinks.Where(link => link.LinkType == ApplicationLinkType.Child && link.ApplicationId != application.Id).ToList(); - - if (childLinks.Count > 0) - { - // This is a parent application, sum up all children's paid/pending payments - foreach (var childLink in childLinks) - { - decimal childTotal = await paymentRequestAppService - .GetTotalPaymentRequestAmountByCorrelationIdAsync(childLink.ApplicationId); - childrenTotalPaidPending += childTotal; - } - } - - // Calculate remaining: Approved - (Parent Paid/Pending + Children Paid/Pending) - decimal totalConsumed = totalFutureRequested + childrenTotalPaidPending; - if (approvedAmmount > totalConsumed) - { - remainingAmount = approvedAmmount - totalConsumed; - } - } - - return remainingAmount; - } + if (ApplicationPaymentRequestForm == null) return NoContent(); - private async Task GetSupplierByApplicationAync(GrantApplicationDto application) - { - if (application.Applicant.SupplierId != Guid.Empty) + // Validate standalone and parent-child payment amounts against current DB state + var standaloneErrors = await helperService.ValidateStandalonePaymentAmountsAsync(ApplicationPaymentRequestForm); + if (standaloneErrors.Count != 0) { - return await iSupplierAppService.GetAsync(application.Applicant.SupplierId); + throw new UserFriendlyException(string.Join(" ", standaloneErrors)); } - return null; - } - - private async Task<(List Errors, string? ParentReferenceNo)> ValidateFormHierarchyAndParentLink( - GrantApplicationDto application, - ApplicationForm applicationForm) - { - List errors = []; - string? parentReferenceNo = null; - - // Only validate if form is payable and has Child hierarchy - if (!applicationForm.Payable || - !applicationForm.FormHierarchy.HasValue || - applicationForm.FormHierarchy.Value != FormHierarchyType.Child) + var validationErrors = await helperService.ValidateParentChildPaymentAmountsAsync(ApplicationPaymentRequestForm); + if (validationErrors.Count != 0) { - return (errors, parentReferenceNo); // No validation needed + throw new UserFriendlyException(string.Join(" ", validationErrors)); } - // Check if ParentFormId is set - if (!applicationForm.ParentFormId.HasValue) + if (ApplicationPaymentRequestForm.Exists(payment => string.IsNullOrWhiteSpace(payment.SupplierNumber))) { - // Configuration issue - should not happen if validation works - return (errors, parentReferenceNo); + throw new UserFriendlyException( + "Cannot submit payment request: Supplier number is missing for one or more applications."); } - // Get parent links for this application - var allLinks = await applicationLinksService.GetListByApplicationAsync(application.Id); - var parentLink = allLinks.Find(link => link.LinkType == ApplicationLinkType.Parent && link.ApplicationId != application.Id); - - // Rule 2: No parent link exists - if (parentLink == null) + if (ApplicationPaymentRequestForm.Exists(payment => payment.SiteId == Guid.Empty)) { - errors.Add("Payment Configuration for this form requires a valid parent application link before payments can be processed."); - return (errors, parentReferenceNo); + throw new UserFriendlyException( + "Cannot submit payment request: Site is missing for one or more applications."); } - // Rule 1: Parent link exists but doesn't match Payment Configuration - // Get the parent application's form version details - var parentFormDetails = await applicationFormAppService.GetFormDetailsByApplicationIdAsync(parentLink.ApplicationId); - - // If validation passed, get the parent application's reference number - var parentApplication = await applicationService.GetAsync(parentLink.ApplicationId); - parentReferenceNo = parentApplication.ReferenceNo; - - // Validate ParentFormId matches - bool formIdMatches = parentFormDetails.ApplicationFormId == applicationForm.ParentFormId.Value; - - if (!formIdMatches) - { - errors.Add("The selected parent form in Payment Configuration does not match the application's linked parent. Please verify and try again."); - return (errors, parentReferenceNo); - } - - return (errors, parentReferenceNo); - } - - public async Task OnPostAsync() - { - if (ApplicationPaymentRequestForm == null) return NoContent(); - - // Validate parent-child payment amounts - var validationErrors = await ValidateParentChildPaymentAmounts(); - if (validationErrors.Count != 0) + // Resolve override once — used for both validation and mapping below + bool hasOverridePermission = await AuthorizationService.IsGrantedAsync(PaymentsPermissions.Payments.AccountCodingOverride); + Guid? accountCodingOverrideId = null; + if (hasOverridePermission + && !string.IsNullOrWhiteSpace(AccountCodingOverride) + && Guid.TryParse(AccountCodingOverride, out var overrideGuid) + && overrideGuid != Guid.Empty) { - throw new UserFriendlyException(string.Join(" ", validationErrors)); + accountCodingOverrideId = overrideGuid; } - if (ApplicationPaymentRequestForm.Exists(payment => string.IsNullOrWhiteSpace(payment.SupplierNumber))) + if (accountCodingOverrideId == null + && ApplicationPaymentRequestForm.Exists(payment => payment.AccountCodingId == null || payment.AccountCodingId == Guid.Empty)) { throw new UserFriendlyException( - "Cannot submit payment request: Supplier number is missing for one or more applications."); + "Cannot submit payment request: Account Coding is missing for one or more applications."); } - bool hasOverridePermission = await AuthorizationService.IsGrantedAsync(PaymentsPermissions.Payments.AccountCodingOverride); - var payments = MapPaymentRequests(hasOverridePermission); + var payments = MapPaymentRequests(accountCodingOverrideId); await paymentRequestAppService.CreateAsync(payments); return NoContent(); } - private List MapPaymentRequests(bool hasOverridePermission) + private List MapPaymentRequests(Guid? accountCodingOverrideId) { var payments = new List(); if (ApplicationPaymentRequestForm == null) return payments; - // Only apply the override if the user has the permission, - // a value was provided, and it parses to a valid non-empty GUID - Guid? accountCodingOverrideId = null; - if (hasOverridePermission - && !string.IsNullOrWhiteSpace(AccountCodingOverride) - && Guid.TryParse(AccountCodingOverride, out var overrideGuid) - && overrideGuid != Guid.Empty) - { - accountCodingOverrideId = overrideGuid; - } - foreach (var payment in ApplicationPaymentRequestForm) { payments.Add(new CreatePaymentRequestDto() @@ -402,212 +255,5 @@ private List MapPaymentRequests(bool hasOverridePermiss return payments; } - - private static List SortPaymentRequestsByHierarchy(List paymentRequests) - { - var sortedList = new List(); - var processed = new HashSet(); - - // Step 1: Find all parent-child groups and process them - var parentGroups = paymentRequests - .Where(x => !string.IsNullOrEmpty(x.ParentReferenceNo)) - .GroupBy(x => x.ParentReferenceNo) - .ToList(); - - foreach (var group in parentGroups.OrderBy(g => g.Key)) - { - string parentRefNo = group.Key!; - - // Add children first (sorted by InvoiceNumber) - var children = group.OrderBy(x => x.InvoiceNumber).ToList(); - sortedList.AddRange(children); - foreach (var child in children) - { - processed.Add(child.InvoiceNumber); - } - - // Add parent after children (if it exists in the list) - var parent = paymentRequests - .Find(x => x.SubmissionConfirmationCode == parentRefNo && string.IsNullOrEmpty(x.ParentReferenceNo)); - - if (parent != null) - { - sortedList.Add(parent); - processed.Add(parent.InvoiceNumber); - } - } - - // Step 2: Add standalone items at the end - var standaloneItems = paymentRequests - .Where(x => !processed.Contains(x.InvoiceNumber)) - .OrderBy(x => x.InvoiceNumber) - .ToList(); - sortedList.AddRange(standaloneItems); - - return sortedList; - } - - private async Task PopulateParentChildValidationData() - { - // Find all child groups in current submission - var childGroups = ApplicationPaymentRequestForm - .Where(x => !string.IsNullOrEmpty(x.ParentReferenceNo)) - .GroupBy(x => x.ParentReferenceNo); - - foreach (var childGroup in childGroups) - { - string parentRefNo = childGroup.Key!; - var children = childGroup.ToList(); - - // Find parent in current submission - var parentInSubmission = ApplicationPaymentRequestForm - .Find(x => x.SubmissionConfirmationCode == parentRefNo && - string.IsNullOrEmpty(x.ParentReferenceNo)); - - // Get parent application details - Guid parentApplicationId; - - if (parentInSubmission != null) - { - parentApplicationId = parentInSubmission.CorrelationId; - } - else - { - // Parent not in submission, get from first child's link - var firstChild = await applicationService.GetAsync(children[0].CorrelationId); - var allLinks = await applicationLinksService.GetListByApplicationAsync(firstChild.Id); - var parentLink = allLinks.Find(link => link.LinkType == ApplicationLinkType.Parent && link.ApplicationId != firstChild.Id); - - if (parentLink == null) - { - // Skip this group if parent link not found - continue; - } - parentApplicationId = parentLink.ApplicationId; - } - - // Get parent application - var parentApplication = await applicationService.GetAsync(parentApplicationId); - decimal approvedAmount = parentApplication.ApprovedAmount; - - // Get parent's total paid + pending - decimal parentTotalPaidPending = await paymentRequestAppService - .GetTotalPaymentRequestAmountByCorrelationIdAsync(parentApplicationId); - - // Get ALL children of this parent and their total paid + pending - var parentLinks = await applicationLinksService.GetListByApplicationAsync(parentApplicationId); - var allChildLinks = parentLinks.Where(link => link.LinkType == ApplicationLinkType.Child).ToList(); - - decimal childrenTotalPaidPending = 0; - foreach (var childLink in allChildLinks) - { - decimal childTotal = await paymentRequestAppService - .GetTotalPaymentRequestAmountByCorrelationIdAsync(childLink.ApplicationId); - childrenTotalPaidPending += childTotal; - } - - // Calculate maximum allowed - decimal maximumPaymentAmount = approvedAmount - (parentTotalPaidPending + childrenTotalPaidPending); - - // Apply validation data to all children in this group - foreach (var child in children) - { - child.MaximumAllowedAmount = maximumPaymentAmount; - child.IsPartOfParentChildGroup = true; - child.ParentApprovedAmount = approvedAmount; - } - - // Apply validation data to parent if in submission - if (parentInSubmission != null) - { - parentInSubmission.MaximumAllowedAmount = maximumPaymentAmount; - parentInSubmission.IsPartOfParentChildGroup = true; - parentInSubmission.ParentApprovedAmount = approvedAmount; - } - } - } - - private async Task> ValidateParentChildPaymentAmounts() - { - List errors = []; - - // Find all child groups in current submission - var childGroups = ApplicationPaymentRequestForm - .Where(x => !string.IsNullOrEmpty(x.ParentReferenceNo)) - .GroupBy(x => x.ParentReferenceNo); - - foreach (var childGroup in childGroups) - { - string parentRefNo = childGroup.Key!; - var children = childGroup.ToList(); - - // Find parent in current submission - var parentInSubmission = ApplicationPaymentRequestForm - .Find(x => x.SubmissionConfirmationCode == parentRefNo && - string.IsNullOrEmpty(x.ParentReferenceNo)); - - // Get parent application details - Guid parentApplicationId; - decimal currentParentAmount = 0; - - if (parentInSubmission != null) - { - parentApplicationId = parentInSubmission.CorrelationId; - currentParentAmount = parentInSubmission.Amount; - } - else - { - // Parent not in submission, get from first child's link - var firstChild = await applicationService.GetAsync(children[0].CorrelationId); - var allLinks = await applicationLinksService.GetListByApplicationAsync(firstChild.Id); - var parentLink = allLinks.Find(link => link.LinkType == ApplicationLinkType.Parent && link.ApplicationId != firstChild.Id); - - if (parentLink == null) - { - errors.Add($"Parent application link not found for reference {parentRefNo}."); - continue; - } - parentApplicationId = parentLink.ApplicationId; - } - - // Get parent application - var parentApplication = await applicationService.GetAsync(parentApplicationId); - decimal approvedAmount = parentApplication.ApprovedAmount; - - // Get parent's total paid + pending - decimal parentTotalPaidPending = await paymentRequestAppService - .GetTotalPaymentRequestAmountByCorrelationIdAsync(parentApplicationId); - - // Get ALL children of this parent and their total paid + pending - var parentLinks = await applicationLinksService.GetListByApplicationAsync(parentApplicationId); - var allChildLinks = parentLinks.Where(link => link.LinkType == ApplicationLinkType.Child).ToList(); - - decimal childrenTotalPaidPending = 0; - foreach (var childLink in allChildLinks) - { - decimal childTotal = await paymentRequestAppService - .GetTotalPaymentRequestAmountByCorrelationIdAsync(childLink.ApplicationId); - childrenTotalPaidPending += childTotal; - } - - // Calculate maximum allowed - decimal maximumPaymentAmount = approvedAmount - (parentTotalPaidPending + childrenTotalPaidPending); - - // Calculate current submission total - decimal currentChildrenAmount = children.Sum(x => x.Amount); - decimal currentSubmissionTotal = currentParentAmount + currentChildrenAmount; - - // Validate - if (currentSubmissionTotal > maximumPaymentAmount) - { - errors.Add($"Payment request for parent application {parentRefNo} and its children exceeds the maximum allowed amount. " + - $"Maximum: ${maximumPaymentAmount:N2}, Requested: ${currentSubmissionTotal:N2}. " + - $"(Parent Approved Amount: ${approvedAmount:N2}, Already Paid/Pending for Parent: ${parentTotalPaidPending:N2}, " + - $"Already Paid/Pending for All Children: ${childrenTotalPaidPending:N2})"); - } - } - - return errors; - } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/HistoricalPaymentsModel.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/HistoricalPaymentsModel.cs new file mode 100644 index 0000000000..562af0411d --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/HistoricalPaymentsModel.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace Unity.Payments.Web.Pages.Payments +{ + public class HistoricalPaymentsModel : IPaymentFormItem + { + // IPaymentFormItem + public Guid CorrelationId { get; set; } + [Required(ErrorMessage = "This field is required.")] + [DisplayName("ApplicationPaymentRequest:InvoiceNumber")] + public string InvoiceNumber { get; set; } = string.Empty; + [Required] + [DisplayName("ApplicationPaymentRequest:Amount")] + public decimal Amount { get; set; } + public string? ParentReferenceNo { get; set; } + public string? SubmissionConfirmationCode { get; set; } + public decimal? MaximumAllowedAmount { get; set; } + public bool IsPartOfParentChildGroup { get; set; } + public decimal? ParentApprovedAmount { get; set; } + + [Required(ErrorMessage = "Paid Date is required.")] + [DisplayName("ApplicationHistoricalPaymentRequest:PaidDate")] + public string PaidDate { get; set; } = string.Empty; + + [DisplayName("ApplicationPaymentRequest:Description")] + [MaxLength(40)] + public string? Description { get; set; } + + public string? ApplicantName { get; set; } + public string? ContractNumber { get; set; } + public decimal RemainingAmount { get; set; } + public List ErrorList { get; set; } = []; + public bool DisableFields { get; set; } = false; + public string? SupplierName { get; set; } + public string? SupplierNumber { get; set; } + public Guid? SiteId { get; set; } + public string? SiteName { get; set; } + public Guid? AccountCodingId { get; set; } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/IPaymentFormItem.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/IPaymentFormItem.cs new file mode 100644 index 0000000000..75977b4f83 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/IPaymentFormItem.cs @@ -0,0 +1,16 @@ +using System; + +namespace Unity.Payments.Web.Pages.Payments +{ + public interface IPaymentFormItem + { + Guid CorrelationId { get; set; } + string InvoiceNumber { get; set; } + decimal Amount { get; set; } + string? ParentReferenceNo { get; set; } + string? SubmissionConfirmationCode { get; set; } + decimal? MaximumAllowedAmount { get; set; } + bool IsPartOfParentChildGroup { get; set; } + decimal? ParentApprovedAmount { get; set; } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js index ba06ae76b7..f2deb13b7c 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js @@ -141,8 +141,7 @@ $(function () { } } } - }, - + } ]; let responseCallback = function (result) { @@ -286,7 +285,8 @@ $(function () { } else { payment_check_status_buttons.disable(); } - if (dataTable.rows({ selected: true }).indexes().length > 0 && !isInSentState) { + let hasHistoricalPayment = dataTable.rows('.selected').data().toArray().some(row => row.status === 'HistoricalPayment'); + if (dataTable.rows({ selected: true }).indexes().length > 0 && !isInSentState && !hasHistoricalPayment) { if (abp.auth.isGranted('PaymentsPermissions.Payments.L1ApproveOrDecline') || abp.auth.isGranted('PaymentsPermissions.Payments.L2ApproveOrDecline') || abp.auth.isGranted('PaymentsPermissions.Payments.L3ApproveOrDecline')) { @@ -389,11 +389,11 @@ $(function () { const safeApplicantName = $.fn.dataTable.render.text().display(applicantName); if (type === 'display' && abp.auth.isGranted('GrantApplicationManagement.Applicants.ViewList')) { - const applicantId = row?.correlationId; + const applicantId = row?.id; const isGuid = applicantId && guidPattern.test(applicantId); - if (row?.correlationProvider === 'Application' && isGuid) { - return `${safeApplicantName}`; + if (isGuid) { + return `${safeApplicantName}`; } return safeApplicantName; @@ -412,11 +412,26 @@ $(function () { className: 'data-table-header text-nowrap', index: columnIndex, render: function (data, type, row) { - if (row.correlationProvider === 'Application' && data?.length > 0) { - return `${data}`; + let code = (typeof data !== 'string' || data.trim() === '') ? '' : data; + + if (type === 'sort' || type === 'filter') { + return code; } - return data || null; + const safeCode = $.fn.dataTable.render.text().display(code); + + if (type === 'display' && abp.auth.isGranted('GrantApplicationManagement.Applicants.ViewList')) { + const applicantId = row?.id; + const isGuid = applicantId && guidPattern.test(applicantId); + + if (isGuid) { + return `${safeCode}`; + } + + return safeCode; + } + + return code || null; } }; } @@ -484,6 +499,7 @@ $(function () { className: 'data-table-header', index: columnIndex, render: function (data) { + if (!data) return ''; switch (data.paymentGroup) { case 1: return 'EFT'; @@ -516,9 +532,21 @@ $(function () { data: 'status', className: 'data-table-header', index: columnIndex, - render: function (data) { - let statusColor = getStatusTextColor(data); - return `` + l(`Enum:PaymentRequestStatus.${data}`) + ''; + render: function (data, type, row) { + const statusText = data ? l(`Enum:PaymentRequestStatus.${data}`) : ''; + + if (type === 'sort' || type === 'filter') { + return statusText; + } + + const safeStatus = $.fn.dataTable.render.text().display(statusText); + + if (type === 'display') { + let statusColor = getStatusTextColor(data); + return `` + safeStatus + ''; + } + + return statusText; } }; } @@ -574,7 +602,7 @@ $(function () { title: l('ApplicationPaymentListTable:CASResponse'), name: 'CASResponse', data: 'casResponse', - className: 'data-table-header', + className: 'data-table-header notexport', index: columnIndex, render: function (data) { if (data + "" !== "undefined" && data?.length > 0) { @@ -801,6 +829,7 @@ $(function () { case "Submitted": return "#5595D9"; + case "HistoricalPayment": case "Paid": return "#42814A"; diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/PaymentRequestPageHelperService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/PaymentRequestPageHelperService.cs new file mode 100644 index 0000000000..7686fd1e4b --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/PaymentRequestPageHelperService.cs @@ -0,0 +1,389 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.ApplicationForms; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantApplications; +using Unity.Payments.Domain.Suppliers; +using Unity.Payments.Enums; +using Unity.Payments.PaymentRequests; +using Unity.Payments.Suppliers; + +namespace Unity.Payments.Web.Pages.Payments +{ + public class PaymentRequestPageHelperService( + IGrantApplicationAppService applicationService, + IPaymentRequestAppService paymentRequestAppService, + IApplicationLinksService applicationLinksService, + ISupplierAppService supplierAppService, + IApplicationFormAppService applicationFormAppService + ) + { + public async Task GetRemainingAmountAsync(GrantApplicationDto application) + { + decimal remainingAmount = 0; + if (application.ApprovedAmount > 0) + { + decimal approvedAmount = application.ApprovedAmount; + decimal totalFutureRequested = await paymentRequestAppService.GetTotalPaymentRequestAmountByCorrelationIdAsync(application.Id); + + decimal childrenTotalPaidPending = 0; + var applicationLinks = await applicationLinksService.GetListByApplicationAsync(application.Id); + var childLinks = applicationLinks + .Where(link => link.LinkType == ApplicationLinkType.Child && link.ApplicationId != application.Id) + .ToList(); + + foreach (var childLink in childLinks) + { + decimal childTotal = await paymentRequestAppService + .GetTotalPaymentRequestAmountByCorrelationIdAsync(childLink.ApplicationId); + childrenTotalPaidPending += childTotal; + } + + decimal totalConsumed = totalFutureRequested + childrenTotalPaidPending; + if (approvedAmount > totalConsumed) + { + remainingAmount = approvedAmount - totalConsumed; + } + } + + return remainingAmount; + } + + public async Task GetSupplierAsync(GrantApplicationDto application) + { + if (application.Applicant.SupplierId != Guid.Empty) + { + return await supplierAppService.GetAsync(application.Applicant.SupplierId); + } + + return null; + } + + public async Task<(List Errors, string? ParentReferenceNo)> ValidateFormHierarchyAndParentLink( + GrantApplicationDto application, + ApplicationForm applicationForm) + { + List errors = []; + string? parentReferenceNo = null; + + if (!applicationForm.Payable || + !applicationForm.FormHierarchy.HasValue || + applicationForm.FormHierarchy.Value != FormHierarchyType.Child) + { + return (errors, parentReferenceNo); + } + + if (!applicationForm.ParentFormId.HasValue) + { + return (errors, parentReferenceNo); + } + + var allLinks = await applicationLinksService.GetListByApplicationAsync(application.Id); + var parentLink = allLinks.Find(link => link.LinkType == ApplicationLinkType.Parent && link.ApplicationId != application.Id); + + if (parentLink == null) + { + errors.Add("Payment Configuration for this form requires a valid parent application link before payments can be processed."); + return (errors, parentReferenceNo); + } + + var parentFormDetails = await applicationFormAppService.GetFormDetailsByApplicationIdAsync(parentLink.ApplicationId); + var parentApplication = await applicationService.GetAsync(parentLink.ApplicationId); + parentReferenceNo = parentApplication.ReferenceNo; + + bool formIdMatches = parentFormDetails.ApplicationFormId == applicationForm.ParentFormId.Value; + if (!formIdMatches) + { + errors.Add("The selected parent form in Payment Configuration does not match the application's linked parent. Please verify and try again."); + return (errors, parentReferenceNo); + } + + return (errors, parentReferenceNo); + } + + public async Task<(List ErrorList, string? ParentReferenceNo)> GetErrorListAsync( + SupplierDto? supplier, + Site? site, + GrantApplicationDto application, + ApplicationForm applicationForm, + decimal remainingAmount, + Guid? accountCodingId, + bool isHistorical = false) + { + bool missingFields = false; + List errorList = []; + + if (!isHistorical && (supplier == null || site == null || string.IsNullOrWhiteSpace(supplier.Number))) + { + missingFields = true; + } + + if (!isHistorical && site != null && site.PaymentGroup == PaymentGroup.EFT && string.IsNullOrWhiteSpace(site.BankAccount)) + { + errorList.Add("Payment cannot be submitted because the default site's pay group is set to EFT, but no bank account is configured. Please update the bank account before proceeding."); + } + + if (remainingAmount <= 0) + { + errorList.Add("There is no remaining amount for this application."); + } + + if (missingFields) + { + errorList.Add("Some payment information is missing for this applicant. Please make sure supplier information is provided and default site is selected."); + } + + if (application.StatusCode != GrantApplicationState.GRANT_APPROVED) + { + errorList.Add("The selected Application is not Approved. To continue please remove the item from the list."); + } + + var allLinks = await applicationLinksService.GetListByApplicationAsync(application.Id); + var parentLink = allLinks.Find(link => link.LinkType == ApplicationLinkType.Parent && link.ApplicationId != application.Id); + if (parentLink != null) + { + var parentApplication = await applicationService.GetAsync(parentLink.ApplicationId); + if (parentApplication.Id == Guid.Empty || parentApplication.StatusCode != GrantApplicationState.GRANT_APPROVED) + { + errorList.Add("Payment cannot be processed because the linked parent submission is not approved. Please ensure the parent submission is approved before creating a payment."); + } + } + + if (!application.ApplicationForm.Payable) + { + errorList.Add("The selected application is not Payable. To continue please remove the item from the list."); + } + + if (!isHistorical && (accountCodingId == null || accountCodingId == Guid.Empty)) + { + errorList.Add("The selected application form does not have an Account Coding or no default Account Coding is set."); + } + + var (hierarchyErrors, parentReferenceNo) = await ValidateFormHierarchyAndParentLink(application, applicationForm); + errorList.AddRange(hierarchyErrors); + + return (errorList, parentReferenceNo); + } + + public List SortByHierarchy(List paymentRequests) where T : IPaymentFormItem + { + var sortedList = new List(); + var processed = new HashSet(); + + var parentGroups = paymentRequests + .Where(x => !string.IsNullOrEmpty(x.ParentReferenceNo)) + .GroupBy(x => x.ParentReferenceNo) + .ToList(); + + foreach (var group in parentGroups.OrderBy(g => g.Key)) + { + string parentRefNo = group.Key!; + + var children = group.OrderBy(x => x.InvoiceNumber).ToList(); + sortedList.AddRange(children); + foreach (var child in children) + { + processed.Add(child.InvoiceNumber); + } + + var parent = paymentRequests + .Find(x => x.SubmissionConfirmationCode == parentRefNo && string.IsNullOrEmpty(x.ParentReferenceNo)); + + if (parent != null) + { + sortedList.Add(parent); + processed.Add(parent.InvoiceNumber); + } + } + + var standaloneItems = paymentRequests + .Where(x => !processed.Contains(x.InvoiceNumber)) + .OrderBy(x => x.InvoiceNumber) + .ToList(); + sortedList.AddRange(standaloneItems); + + return sortedList; + } + + public async Task PopulateParentChildValidationDataAsync(List form) where T : IPaymentFormItem + { + var childGroups = form + .Where(x => !string.IsNullOrEmpty(x.ParentReferenceNo)) + .GroupBy(x => x.ParentReferenceNo); + + foreach (var childGroup in childGroups) + { + string parentRefNo = childGroup.Key!; + var children = childGroup.ToList(); + + var parentInSubmission = form + .Find(x => x.SubmissionConfirmationCode == parentRefNo && string.IsNullOrEmpty(x.ParentReferenceNo)); + + Guid parentApplicationId; + + if (parentInSubmission != null) + { + parentApplicationId = parentInSubmission.CorrelationId; + } + else + { + var firstChild = await applicationService.GetAsync(children[0].CorrelationId); + var allLinks = await applicationLinksService.GetListByApplicationAsync(firstChild.Id); + var parentLink = allLinks.Find(link => link.LinkType == ApplicationLinkType.Parent && link.ApplicationId != firstChild.Id); + + if (parentLink == null) continue; + + parentApplicationId = parentLink.ApplicationId; + } + + var parentApplication = await applicationService.GetAsync(parentApplicationId); + decimal approvedAmount = parentApplication.ApprovedAmount; + + decimal parentTotalPaidPending = await paymentRequestAppService + .GetTotalPaymentRequestAmountByCorrelationIdAsync(parentApplicationId); + + var parentLinks = await applicationLinksService.GetListByApplicationAsync(parentApplicationId); + var allChildLinks = parentLinks.Where(link => link.LinkType == ApplicationLinkType.Child).ToList(); + + decimal childrenTotalPaidPending = 0; + foreach (var childLink in allChildLinks) + { + decimal childTotal = await paymentRequestAppService + .GetTotalPaymentRequestAmountByCorrelationIdAsync(childLink.ApplicationId); + childrenTotalPaidPending += childTotal; + } + + decimal maximumPaymentAmount = approvedAmount - (parentTotalPaidPending + childrenTotalPaidPending); + + foreach (var child in children) + { + child.MaximumAllowedAmount = maximumPaymentAmount; + child.IsPartOfParentChildGroup = true; + child.ParentApprovedAmount = approvedAmount; + } + + if (parentInSubmission != null) + { + parentInSubmission.MaximumAllowedAmount = maximumPaymentAmount; + parentInSubmission.IsPartOfParentChildGroup = true; + parentInSubmission.ParentApprovedAmount = approvedAmount; + } + } + } + + public async Task> ValidateStandalonePaymentAmountsAsync(List form) where T : IPaymentFormItem + { + List errors = []; + + var standaloneItems = form.Where(x => !x.IsPartOfParentChildGroup).ToList(); + + foreach (var item in standaloneItems) + { + if (item.Amount <= 0) + { + errors.Add($"Payment amount for application must be greater than zero."); + continue; + } + + var application = await applicationService.GetAsync(item.CorrelationId); + decimal currentRemainingAmount = await GetRemainingAmountAsync(application); + + if (item.Amount > currentRemainingAmount) + { + errors.Add($"Payment amount (${item.Amount:N2}) for application {application.ReferenceNo} exceeds the current remaining amount (${currentRemainingAmount:N2}). " + + $"The remaining amount may have changed since the form was loaded. Please refresh and try again."); + } + } + + return errors; + } + + public async Task> ValidateParentChildPaymentAmountsAsync(List form) where T : IPaymentFormItem + { + List errors = []; + + var zeroAmountItems = form + .Where(x => x.IsPartOfParentChildGroup && x.Amount <= 0) + .ToList(); + + foreach (var item in zeroAmountItems) + { + var groupRef = !string.IsNullOrEmpty(item.ParentReferenceNo) + ? item.ParentReferenceNo + : item.SubmissionConfirmationCode; + errors.Add($"Payment amount for application in parent-child group '{groupRef}' must be greater than zero."); + } + + if (errors.Count > 0) return errors; + + var childGroups = form + .Where(x => !string.IsNullOrEmpty(x.ParentReferenceNo)) + .GroupBy(x => x.ParentReferenceNo); + + foreach (var childGroup in childGroups) + { + string parentRefNo = childGroup.Key!; + var children = childGroup.ToList(); + + var parentInSubmission = form + .Find(x => x.SubmissionConfirmationCode == parentRefNo && string.IsNullOrEmpty(x.ParentReferenceNo)); + + Guid parentApplicationId; + decimal currentParentAmount = 0; + + if (parentInSubmission != null) + { + parentApplicationId = parentInSubmission.CorrelationId; + currentParentAmount = parentInSubmission.Amount; + } + else + { + var firstChild = await applicationService.GetAsync(children[0].CorrelationId); + var allLinks = await applicationLinksService.GetListByApplicationAsync(firstChild.Id); + var parentLink = allLinks.Find(link => link.LinkType == ApplicationLinkType.Parent && link.ApplicationId != firstChild.Id); + + if (parentLink == null) + { + errors.Add($"Parent application link not found for reference {parentRefNo}."); + continue; + } + parentApplicationId = parentLink.ApplicationId; + } + + var parentApplication = await applicationService.GetAsync(parentApplicationId); + decimal approvedAmount = parentApplication.ApprovedAmount; + + decimal parentTotalPaidPending = await paymentRequestAppService + .GetTotalPaymentRequestAmountByCorrelationIdAsync(parentApplicationId); + + var parentLinks = await applicationLinksService.GetListByApplicationAsync(parentApplicationId); + var allChildLinks = parentLinks.Where(link => link.LinkType == ApplicationLinkType.Child).ToList(); + + decimal childrenTotalPaidPending = 0; + foreach (var childLink in allChildLinks) + { + decimal childTotal = await paymentRequestAppService + .GetTotalPaymentRequestAmountByCorrelationIdAsync(childLink.ApplicationId); + childrenTotalPaidPending += childTotal; + } + + decimal maximumPaymentAmount = approvedAmount - (parentTotalPaidPending + childrenTotalPaidPending); + + decimal currentChildrenAmount = children.Sum(x => x.Amount); + decimal currentSubmissionTotal = currentParentAmount + currentChildrenAmount; + + if (currentSubmissionTotal > maximumPaymentAmount) + { + errors.Add($"Payment request for parent application {parentRefNo} and its children exceeds the maximum allowed amount. " + + $"Maximum: ${maximumPaymentAmount:N2}, Requested: ${currentSubmissionTotal:N2}. " + + $"(Parent Approved Amount: ${approvedAmount:N2}, Already Paid/Pending for Parent: ${parentTotalPaidPending:N2}, " + + $"Already Paid/Pending for All Children: ${childrenTotalPaidPending:N2})"); + } + } + + return errors; + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/PaymentsModel.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/PaymentsModel.cs index 30fcfd50a0..93987602ba 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/PaymentsModel.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/PaymentsModel.cs @@ -5,7 +5,7 @@ namespace Unity.Payments.Web.Pages.Payments { - public class PaymentsModel + public class PaymentsModel : IPaymentFormItem { [DisplayName("ApplicationPaymentRequest:Amount")] [Required] diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentTags/PaymentTags.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentTags/PaymentTags.js index f464feb197..0b9325eab9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentTags/PaymentTags.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentTags/PaymentTags.js @@ -51,7 +51,6 @@ $(function () { this.arr.push({ Id: id, Name: tagText }); - let tagInput = this; let tag = document.createElement('span'); tag.className = this.options.tagClass + ' ' + tagClass; @@ -61,13 +60,13 @@ $(function () { let closeIcon = document.createElement('a'); closeIcon.innerHTML = '×'; - closeIcon.addEventListener('click', function (e) { + closeIcon.addEventListener('click', (e) => { e.preventDefault(); - let tag = this.parentNode; + let tag = e.currentTarget.parentNode; - let tagIndex = Array.from(tagInput.wrapper.childNodes).indexOf(tag); + let tagIndex = Array.from(this.wrapper.childNodes).indexOf(tag); if (tagIndex !== -1) { - tagInput.deleteTag(tag, tagIndex); + this.deleteTag(tag, tagIndex); } }) @@ -82,23 +81,21 @@ $(function () { } TagsInput.prototype.deleteTag = function (tag, i) { - let self = this; - - if (this.arr[i] && this.arr[i].Name === 'Uncommon Tags') { + if (this.arr[i]?.Name === 'Uncommon Tags') { abp.message.confirm('Are you sure you want to delete all the uncommon tags?') - .then(function (confirmed) { + .then((confirmed) => { if (confirmed) { tag.remove(); - self.arr.splice(i, 1); - self.orignal_input.value = JSON.stringify(self.arr); - updateSelectedTagsInput(self.arr); + this.arr.splice(i, 1); + this.orignal_input.value = JSON.stringify(this.arr); + updateSelectedTagsInput(this.arr); // Expand input if no tags remain - if (self.arr.length === 0) { - self.input.classList.add('expanded'); + if (this.arr.length === 0) { + this.input.classList.add('expanded'); } - return self; + return this; } }); } else { @@ -134,10 +131,8 @@ $(function () { } TagsInput.prototype.addData = function (array) { - let plugin = this; - - array.forEach(function (string) { - plugin.addTag(string); + array.forEach((string) => { + this.addTag(string); }) return this; } @@ -154,14 +149,13 @@ $(function () { this.orignal_input.removeAttribute('hidden'); delete this.orignal_input; - let self = this; - Object.keys(this).forEach(function (key) { - if (self[key] instanceof HTMLElement) - self[key].remove(); + Object.keys(this).forEach((key) => { + if (this[key] instanceof HTMLElement) + this[key].remove(); if (key != 'options') - delete self[key]; + delete this[key]; }); this.initialized = false; @@ -355,5 +349,5 @@ $(function () { duplicate: false } - window.TagsInput = TagsInput; + globalThis.PaymentTagsInput = TagsInput; }); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/PaymentsWebModule.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/PaymentsWebModule.cs index 0aeb0fb4af..330d8be825 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/PaymentsWebModule.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/PaymentsWebModule.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.DependencyInjection; +using Unity.Payments.Web.Pages.Payments; using Unity.Payments.Localization; using Unity.Payments.Web.Menus; using Volo.Abp.AspNetCore.Mvc.Localization; @@ -44,6 +45,7 @@ public override void ConfigureServices(ServiceConfigurationContext context) }); context.Services.AddMapperlyObjectMapper(); + context.Services.AddScoped(); Configure(options => { diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.js index d7ab43374f..be6bdff0a3 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.js @@ -5,7 +5,7 @@ $(function () { }); tagPaymentModal.onOpen(async function () { - let tagInput = new TagsInput({ + let tagInput = new PaymentTagsInput({ selector: 'SelectedTags', duplicate: false, max: 50 diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js index 6db3794f7e..e36b24ac28 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js @@ -409,6 +409,7 @@ data: 'site.addressLine1', className: 'data-table-header', render: function (data, type, full, meta) { + if (!full.site) return ''; return ( nullToEmpty(full.site.addressLine1) + ' ' + @@ -576,6 +577,9 @@ function getPaymentStatusTextColor(status) { case 'Paid': return '#42814A'; + case 'HistoricalPayment': + return '#42814A'; + case 'Failed': return '#CE3E39'; diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/BatchPaymentRequests/PaymentRequestAppService_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/BatchPaymentRequests/PaymentRequestAppService_Tests.cs index 3d6f2bcdae..35c286753a 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/BatchPaymentRequests/PaymentRequestAppService_Tests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/BatchPaymentRequests/PaymentRequestAppService_Tests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Unity.Payments.Domain.AccountCodings; using Unity.Payments.Domain.PaymentRequests; using Unity.Payments.Domain.Suppliers; using Unity.Payments.Domain.Suppliers.ValueObjects; @@ -16,6 +17,7 @@ public class PaymentRequestAppService_Tests : PaymentsApplicationTestBase private readonly IPaymentRequestAppService _paymentRequestAppService; private readonly IPaymentRequestRepository _paymentRequestRepository; private readonly ISupplierRepository _supplierRepository; + private readonly IAccountCodingRepository _accountCodingRepository; private readonly IUnitOfWorkManager _unitOfWorkManager; public PaymentRequestAppService_Tests() @@ -23,14 +25,24 @@ public PaymentRequestAppService_Tests() _paymentRequestAppService = GetRequiredService(); _paymentRequestRepository = GetRequiredService(); _supplierRepository = GetRequiredService(); + _accountCodingRepository = GetRequiredService(); _unitOfWorkManager = GetRequiredService(); } + private async Task CreateAccountCodingAsync() + { + using var uow = _unitOfWorkManager.Begin(); + var accountCoding = AccountCoding.Create("ABC", "ABCDE", "AB001", "AB01", "AB00001"); + await _accountCodingRepository.InsertAsync(accountCoding, true); + await uow.CompleteAsync(); + return accountCoding.Id; + } + [Fact] [Trait("Category", "Integration")] public async Task CreateAsync_CreatesPaymentRequest() { - // Arrange + // Arrange using var uow = _unitOfWorkManager.Begin(); var siteId = Guid.NewGuid(); var newSupplier = new Supplier(Guid.NewGuid(), @@ -55,6 +67,7 @@ public async Task CreateAsync_CreatesPaymentRequest() "ABC123"))); _ = await _supplierRepository.InsertAsync(newSupplier, true); + var accountCodingId = await CreateAccountCodingAsync(); List paymentRequests = [ @@ -68,6 +81,7 @@ public async Task CreateAsync_CreatesPaymentRequest() PayeeName= "", SiteId= siteId, SupplierNumber = "SUP-TEST", + AccountCodingId = accountCodingId, } ]; // Act @@ -88,6 +102,7 @@ public async Task GetListAsync_ReturnsPaymentsList() var supplier = new Supplier(Guid.NewGuid(), "supp", "123"); supplier.AddSite(new Site(Guid.NewGuid(), "123", PaymentGroup.EFT)); var addedSupplier = await _supplierRepository.InsertAsync(supplier); + var accountCodingId = await CreateAccountCodingAsync(); CreatePaymentRequestDto paymentRequestDto = new() { InvoiceNumber = "", @@ -100,7 +115,8 @@ public async Task GetListAsync_ReturnsPaymentsList() CorrelationProvider = "", ReferenceNumber = "UP-XXXX-000000", BatchName = "UNITY_BATCH_1", - BatchNumber = 1 + BatchNumber = 1, + AccountCodingId = accountCodingId }; _ = await _paymentRequestRepository.InsertAsync(new PaymentRequest(Guid.NewGuid(), paymentRequestDto), true); @@ -123,6 +139,7 @@ public async Task GetListAsync_ReturnsPagedPaymentsList() var supplier = new Supplier(Guid.NewGuid(), "supp", "123"); supplier.AddSite(new Site(Guid.NewGuid(), "123", PaymentGroup.EFT)); var addedSupplier = await _supplierRepository.InsertAsync(supplier); + var accountCodingId = await CreateAccountCodingAsync(); CreatePaymentRequestDto paymentRequestDto = new() { InvoiceNumber = "INV-001", @@ -135,7 +152,8 @@ public async Task GetListAsync_ReturnsPagedPaymentsList() CorrelationProvider = "TestProvider", ReferenceNumber = "UP-XXXX-000001", BatchName = "UNITY_BATCH_1", - BatchNumber = 1 + BatchNumber = 1, + AccountCodingId = accountCodingId }; _ = await _paymentRequestRepository.InsertAsync(new PaymentRequest(Guid.NewGuid(), paymentRequestDto), true); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentRollup_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentRollup_Tests.cs index 8a97321dc5..054a52ce55 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentRollup_Tests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentRollup_Tests.cs @@ -2,6 +2,7 @@ using System; using System.ComponentModel; using System.Threading.Tasks; +using Unity.Payments.Domain.AccountCodings; using Unity.Payments.Domain.Suppliers; using Unity.Payments.Enums; using Unity.Payments.PaymentRequests; @@ -15,12 +16,14 @@ public class PaymentRequestRepository_PaymentRollup_Tests : PaymentsApplicationT { private readonly IPaymentRequestRepository _paymentRequestRepository; private readonly ISupplierRepository _supplierRepository; + private readonly IAccountCodingRepository _accountCodingRepository; private readonly IUnitOfWorkManager _unitOfWorkManager; public PaymentRequestRepository_PaymentRollup_Tests() { _paymentRequestRepository = GetRequiredService(); _supplierRepository = GetRequiredService(); + _accountCodingRepository = GetRequiredService(); _unitOfWorkManager = GetRequiredService(); } @@ -376,6 +379,47 @@ public async Task Should_Return_Empty_For_Unknown_CorrelationIds() results.ShouldBeEmpty(); } + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Count_HistoricalPayment_In_TotalPaid() + { + // Arrange + var correlationId = Guid.NewGuid(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertHistoricalPaymentRequestAsync(correlationId, 750m); + + // Act + var results = await _paymentRequestRepository + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].TotalPaid.ShouldBe(750m); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Count_HistoricalPayment_Alongside_FullyPaid_In_TotalPaid() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, + PaymentRequestStatus.Submitted, paymentStatus: "Fully Paid"); + await InsertHistoricalPaymentRequestAsync(correlationId, 500m); + + // Act + var results = await _paymentRequestRepository + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].TotalPaid.ShouldBe(1500m); + } + #endregion #region Helpers @@ -391,6 +435,15 @@ private async Task CreateSupplierAndSiteAsync() return siteId; } + private async Task CreateAccountCodingAsync() + { + using var uow = _unitOfWorkManager.Begin(); + var accountCoding = AccountCoding.Create("ABC", "ABCDE", "AB001", "AB01", "AB00001"); + await _accountCodingRepository.InsertAsync(accountCoding, true); + await uow.CompleteAsync(); + return accountCoding.Id; + } + private async Task InsertPaymentRequestAsync( Guid siteId, Guid correlationId, @@ -399,6 +452,8 @@ private async Task InsertPaymentRequestAsync( string? paymentStatus = null, string? invoiceStatus = null) { + var accountCodingId = await CreateAccountCodingAsync(); + var dto = new CreatePaymentRequestDto { InvoiceNumber = $"INV-{Guid.NewGuid():N}", @@ -411,7 +466,8 @@ private async Task InsertPaymentRequestAsync( CorrelationProvider = "Test", ReferenceNumber = $"REF-{Guid.NewGuid():N}", BatchName = "TEST_BATCH", - BatchNumber = 1 + BatchNumber = 1, + AccountCodingId = accountCodingId }; var paymentRequest = new PaymentRequest(Guid.NewGuid(), dto); @@ -430,5 +486,28 @@ private async Task InsertPaymentRequestAsync( await _paymentRequestRepository.InsertAsync(paymentRequest, true); } + private async Task InsertHistoricalPaymentRequestAsync( + Guid correlationId, + decimal amount) + { + var dto = new CreateHistoricalPaymentRequestDto + { + InvoiceNumber = $"HIST-{Guid.NewGuid():N}", + Amount = amount, + PayeeName = "Test Payee", + ContractNumber = "0000000000", + CorrelationId = correlationId, + CorrelationProvider = "Test", + ReferenceNumber = $"REF-{Guid.NewGuid():N}", + BatchName = "HIST_BATCH", + BatchNumber = 1, + PaidDate = "2025-01-15" + // SiteId, SupplierNumber, AccountCodingId intentionally omitted — optional for historical + }; + + var paymentRequest = new PaymentRequest(Guid.NewGuid(), dto); + await _paymentRequestRepository.InsertAsync(paymentRequest, true); + } + #endregion } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_Tests.cs index 6da52c79ab..f9e72f09a3 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_Tests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_Tests.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using Unity.Payments.Codes; +using Unity.Payments.Domain.AccountCodings; using Unity.Payments.Domain.Suppliers; using Unity.Payments.Enums; using Unity.Payments.PaymentRequests; @@ -17,15 +18,26 @@ public class PaymentRequestRepository_Tests : PaymentsApplicationTestBase { private readonly IPaymentRequestRepository _paymentRequestRepository; private readonly ISupplierRepository _supplierRepository; + private readonly IAccountCodingRepository _accountCodingRepository; private readonly IUnitOfWorkManager _unitOfWorkManager; public PaymentRequestRepository_Tests() { _paymentRequestRepository = GetRequiredService(); _supplierRepository = GetRequiredService(); + _accountCodingRepository = GetRequiredService(); _unitOfWorkManager = GetRequiredService(); } + private async Task CreateAccountCodingAsync() + { + using var uow = _unitOfWorkManager.Begin(); + var accountCoding = AccountCoding.Create("ABC", "ABCDE", "AB001", "AB01", "AB00001"); + await _accountCodingRepository.InsertAsync(accountCoding, true); + await uow.CompleteAsync(); + return accountCoding.Id; + } + #region GetCountByCorrelationId [Fact] @@ -808,6 +820,7 @@ private async Task InsertAndGetPaymentRequestAsync( string? invoiceStatus = null) { var invoiceNumber = customInvoiceNumber ?? $"INV-{Guid.NewGuid():N}"; + var accountCodingId = await CreateAccountCodingAsync(); var dto = new CreatePaymentRequestDto { InvoiceNumber = invoiceNumber, @@ -820,7 +833,8 @@ private async Task InsertAndGetPaymentRequestAsync( CorrelationProvider = "Test", ReferenceNumber = $"REF-{Guid.NewGuid():N}", BatchName = "TEST_BATCH", - BatchNumber = 1 + BatchNumber = 1, + AccountCodingId = accountCodingId }; var paymentRequest = new PaymentRequest(Guid.NewGuid(), dto); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequest_Constructor_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequest_Constructor_Tests.cs new file mode 100644 index 0000000000..7a646fc933 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequest_Constructor_Tests.cs @@ -0,0 +1,181 @@ +using Shouldly; +using System; +using System.ComponentModel; +using Unity.Payments.Codes; +using Unity.Payments.Domain.Exceptions; +using Unity.Payments.Domain.PaymentRequests; +using Unity.Payments.Enums; +using Unity.Payments.PaymentRequests; +using Volo.Abp; +using Xunit; + +namespace Unity.Payments.Domain.PaymentRequests; + +[Category("Domain")] +public class PaymentRequest_Constructor_Tests : PaymentsApplicationTestBase +{ + #region Normal payment constructor — new validations + + [Fact] + public void NormalConstructor_WithEmptySiteId_ThrowsMissingSite() + { + var dto = BuildNormalDto(siteId: Guid.Empty); + Should.Throw(() => new PaymentRequest(Guid.NewGuid(), dto)) + .Code.ShouldBe(ErrorConsts.MissingSite); + } + + [Fact] + public void NormalConstructor_WithNullAccountCodingId_ThrowsMissingAccountCoding() + { + var dto = BuildNormalDto(); + dto.AccountCodingId = null; + Should.Throw(() => new PaymentRequest(Guid.NewGuid(), dto)) + .Code.ShouldBe(ErrorConsts.MissingAccountCoding); + } + + [Fact] + public void NormalConstructor_WithEmptyAccountCodingId_ThrowsMissingAccountCoding() + { + var dto = BuildNormalDto(accountCodingId: Guid.Empty); + Should.Throw(() => new PaymentRequest(Guid.NewGuid(), dto)) + .Code.ShouldBe(ErrorConsts.MissingAccountCoding); + } + + [Fact] + public void NormalConstructor_WithValidData_Succeeds() + { + var dto = BuildNormalDto(); + var payment = new PaymentRequest(Guid.NewGuid(), dto); + payment.ShouldNotBeNull(); + payment.Status.ShouldBe(PaymentRequestStatus.L1Pending); + payment.ExpenseApprovals.Count.ShouldBe(2); + } + + #endregion + + #region Historical payment constructor + + [Fact] + public void HistoricalConstructor_WithZeroAmount_ThrowsZeroPayment() + { + var dto = BuildHistoricalDto(amount: 0m); + Should.Throw(() => new PaymentRequest(Guid.NewGuid(), dto)) + .Code.ShouldBe(ErrorConsts.ZeroPayment); + } + + [Fact] + public void HistoricalConstructor_WithNegativeAmount_ThrowsZeroPayment() + { + var dto = BuildHistoricalDto(amount: -1m); + Should.Throw(() => new PaymentRequest(Guid.NewGuid(), dto)) + .Code.ShouldBe(ErrorConsts.ZeroPayment); + } + + [Fact] + public void HistoricalConstructor_WithNullSiteSupplierAndAccountCoding_DoesNotThrow() + { + var dto = BuildHistoricalDto(); + dto.SiteId.ShouldBeNull(); + dto.SupplierNumber.ShouldBeNull(); + dto.AccountCodingId.ShouldBeNull(); + + Should.NotThrow(() => new PaymentRequest(Guid.NewGuid(), dto)); + } + + [Fact] + public void HistoricalConstructor_SetsStatus_ToHistoricalPayment() + { + var payment = new PaymentRequest(Guid.NewGuid(), BuildHistoricalDto()); + payment.Status.ShouldBe(PaymentRequestStatus.HistoricalPayment); + } + + [Fact] + public void HistoricalConstructor_SetsPaymentStatus_ToPaid() + { + var payment = new PaymentRequest(Guid.NewGuid(), BuildHistoricalDto()); + payment.PaymentStatus.ShouldBe(CasPaymentRequestStatus.Paid); + } + + [Fact] + public void HistoricalConstructor_SetsInvoiceStatus_ToPaid() + { + var payment = new PaymentRequest(Guid.NewGuid(), BuildHistoricalDto()); + payment.InvoiceStatus.ShouldBe(CasPaymentRequestStatus.Paid); + } + + [Fact] + public void HistoricalConstructor_SetsPaymentDate_FromPaidDate() + { + var dto = BuildHistoricalDto(); + dto.PaidDate = "2025-06-15"; + + var payment = new PaymentRequest(Guid.NewGuid(), dto); + + payment.PaymentDate.ShouldBe("2025-06-15"); + } + + [Fact] + public void HistoricalConstructor_CreatesNoExpenseApprovals() + { + var payment = new PaymentRequest(Guid.NewGuid(), BuildHistoricalDto()); + payment.ExpenseApprovals.ShouldBeEmpty(); + } + + [Fact] + public void HistoricalConstructor_WithOptionalFieldsProvided_StoresThem() + { + var siteId = Guid.NewGuid(); + var accountCodingId = Guid.NewGuid(); + var dto = BuildHistoricalDto(); + dto.SiteId = siteId; + dto.SupplierNumber = "SUP-001"; + dto.SupplierName = "Test Supplier"; + dto.AccountCodingId = accountCodingId; + + var payment = new PaymentRequest(Guid.NewGuid(), dto); + + payment.SiteId.ShouldBe(siteId); + payment.SupplierNumber.ShouldBe("SUP-001"); + payment.SupplierName.ShouldBe("Test Supplier"); + payment.AccountCodingId.ShouldBe(accountCodingId); + } + + #endregion + + #region Helpers + + private static CreatePaymentRequestDto BuildNormalDto( + Guid? siteId = null, + Guid? accountCodingId = null) => new() + { + InvoiceNumber = "INV-001", + Amount = 500m, + PayeeName = "Test Payee", + ContractNumber = "C-001", + SupplierNumber = "SUP-001", + SiteId = siteId ?? Guid.NewGuid(), + CorrelationId = Guid.NewGuid(), + CorrelationProvider = "Application", + ReferenceNumber = $"REF-{Guid.NewGuid():N}", + BatchName = "TEST_BATCH", + BatchNumber = 1, + AccountCodingId = accountCodingId ?? Guid.NewGuid() + }; + + private static CreateHistoricalPaymentRequestDto BuildHistoricalDto(decimal amount = 500m) => new() + { + InvoiceNumber = "HIST-INV-001", + Amount = amount, + PayeeName = "Test Payee", + ContractNumber = "C-001", + PaidDate = "2025-01-15", + CorrelationId = Guid.NewGuid(), + CorrelationProvider = "Application", + ReferenceNumber = $"REF-{Guid.NewGuid():N}", + BatchName = "HIST_BATCH", + BatchNumber = 1 + // SiteId, SupplierNumber, SupplierName, AccountCodingId intentionally omitted + }; + + #endregion +} 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 @@
+ + + \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantHistory/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantHistory/Default.js index 66fd3a27b2..d26dbd9525 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantHistory/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantHistory/Default.js @@ -128,6 +128,14 @@ $(function () { } catch (e) { console.warn('Audit date parse error:', e); return d; } } }, + { + title: 'Status', data: 'auditStatus', name: 'auditStatus', className: 'data-table-header', + render: function (d) { + if (!d) return nullPlaceholder; + return d === 'InProgress' ? 'In Progress' : d; + } + }, + { title: "Auditor's Name", data: 'auditorName', name: 'auditorName', className: 'data-table-header', render: (d) => d ?? nullPlaceholder }, { title: 'Audit Note', data: 'auditNote', name: 'auditNote', className: 'data-table-header', render: (d) => d ?? nullPlaceholder }, { title: 'Actions', data: null, name: 'actions', orderable: false, className: 'data-table-header', width: '70px', diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantPayments/ApplicantPaymentsViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantPayments/ApplicantPaymentsViewComponent.cs new file mode 100644 index 0000000000..21770dfde7 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantPayments/ApplicantPaymentsViewComponent.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Mvc; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Unity.GrantManager.ApplicantProfile; +using Unity.GrantManager.Payments; +using Unity.Modules.Shared; +using Volo.Abp.AspNetCore.Mvc; +using Volo.Abp.AspNetCore.Mvc.UI.Bundling; +using Volo.Abp.AspNetCore.Mvc.UI.Widgets; +using Volo.Abp.Authorization.Permissions; +using Volo.Abp.Features; + +namespace Unity.GrantManager.Web.Views.Shared.Components.ApplicantPayments; + +[Widget( + RefreshUrl = "Widget/ApplicantPayments/Refresh", + ScriptTypes = [typeof(ApplicantPaymentsScriptBundleContributor)], + StyleTypes = [typeof(ApplicantPaymentsStyleBundleContributor)], + AutoInitialize = true)] +public class ApplicantPaymentsViewComponent( + IApplicantPaymentsAppService applicantPaymentsAppService, + IFeatureChecker featureChecker, + IPermissionChecker permissionChecker) : AbpViewComponent +{ + public async Task InvokeAsync(Guid applicantId) + { + var emptyModel = new ApplicantPaymentsViewModel { ApplicantId = applicantId }; + + if (!await featureChecker.IsEnabledAsync(PaymentConsts.UnityPaymentsFeature)) + return View(emptyModel); + + if (!await permissionChecker.IsGrantedAsync(UnitySelector.Payment.Summary.Default)) + return View(emptyModel); + + var summary = await applicantPaymentsAppService.GetPaymentSummaryByApplicantIdAsync(applicantId); + + return View(new ApplicantPaymentsViewModel + { + ApplicantId = applicantId, + TotalApprovedAmount = summary.TotalApprovedAmount, + TotalPaidAmount = summary.TotalPaidAmount, + TotalRemainingAmount = summary.TotalRemainingAmount + }); + } +} + +public class ApplicantPaymentsScriptBundleContributor : BundleContributor +{ + public override void ConfigureBundle(BundleConfigurationContext context) + { + context.Files.AddIfNotContains("/Views/Shared/Components/ApplicantPayments/Default.js"); + context.Files.AddIfNotContains("/libs/jquery-maskmoney/dist/jquery.maskMoney.min.js"); + } +} + +public class ApplicantPaymentsStyleBundleContributor : BundleContributor +{ + public override void ConfigureBundle(BundleConfigurationContext context) + { + context.Files.AddIfNotContains("/Views/Shared/Components/ApplicantPayments/Default.css"); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantPayments/ApplicantPaymentsViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantPayments/ApplicantPaymentsViewModel.cs new file mode 100644 index 0000000000..998a7b14af --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantPayments/ApplicantPaymentsViewModel.cs @@ -0,0 +1,18 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Unity.GrantManager.Web.Views.Shared.Components.ApplicantPayments; + +public class ApplicantPaymentsViewModel +{ + public Guid ApplicantId { get; set; } + + [Display(Name = "Total Approved Amount")] + public decimal TotalApprovedAmount { get; set; } + + [Display(Name = "Total Paid Amount")] + public decimal TotalPaidAmount { get; set; } + + [Display(Name = "Total Remaining Amount")] + public decimal TotalRemainingAmount { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantPayments/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantPayments/Default.cshtml new file mode 100644 index 0000000000..0c9a9cdc61 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantPayments/Default.cshtml @@ -0,0 +1,73 @@ +@using Unity.GrantManager.Web.Views.Shared.Components.ApplicantPayments +@using Unity.Modules.Shared +@using Volo.Abp.Authorization.Permissions + +@inject IPermissionChecker PermissionChecker + +@model ApplicantPaymentsViewModel + +@{ + Layout = null; +} + + + +
+ + @* Payment Summary *@ + @if (await PermissionChecker.IsGrantedAsync(UnitySelector.Payment.Summary.Default)) + { +
+
+
Payment Summary
+
+
+
+
+ $ + +
+
+
+
+ $ + +
+
+
+
+ $ + +
+
+
+
+ } + + @* Payment List *@ + @if (await PermissionChecker.IsGrantedAsync(UnitySelector.Payment.PaymentList.Default)) + { +
+
Payment List
+
+ } +
+
+ +
+
+ +
+ +
+ + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantPayments/Default.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantPayments/Default.css new file mode 100644 index 0000000000..75879016f1 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantPayments/Default.css @@ -0,0 +1,39 @@ +.applicant-payments-summary { + border-bottom: 0.25rem solid var(--bc-colors-white-background); +} + +.applicant-payments-toolbar { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.applicant-payments-toolbar .tbl-search { + max-width: 280px; +} + +.applicant-payments-toolbar .dynamic-buttons-div { + margin-left: auto; + position: relative; +} + +.applicant-payments-toolbar .dynamic-buttons-div .dt-button-collection { + left: auto !important; + right: 0 !important; + top: 100% !important; +} + +#ApplicantPaymentRequestListTable_wrapper { + overflow: visible !important; +} + +.applicant-payments-container .dt-container { + overflow: visible !important; +} + +#ApplicantPaymentRequestListTable_wrapper .dt-scroll-body { + max-height: clamp(180px, calc(100vh - 540px), 350px) !important; + overflow-y: auto !important; + overflow-x: auto !important; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantPayments/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantPayments/Default.js new file mode 100644 index 0000000000..b5d6d4e669 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantPayments/Default.js @@ -0,0 +1,244 @@ +function getPaymentIdColumn() { + return { + title: 'Payment ID', + name: 'referenceNumber', + data: 'referenceNumber', + className: 'data-table-header', + index: 0, + }; +} + +function getSubmissionIdColumn() { + return { + title: 'Submission ID', + name: 'applicationReferenceNo', + data: 'applicationReferenceNo', + className: 'data-table-header', + index: 1, + render: function (data, type, row) { + if (type === 'display') { + return ( + '' + + (data || '') + + '' + ); + } + return data; + }, + }; +} + +function getPaidDateColumn() { + return { + title: 'Paid Date', + name: 'paymentDate', + data: 'paymentDate', + className: 'data-table-header', + index: 2, + render: function (data, type) { + if (type !== 'display' && type !== 'filter') return data || ''; + return data || ''; + }, + }; +} + +function getCasPaymentStatusColumn() { + return { + title: 'CAS Payment Status', + name: 'paymentStatus', + data: 'paymentStatus', + className: 'data-table-header', + index: 5, + }; +} + +function getSupplierNumberColumn() { + return { + title: 'Supplier #', + name: 'supplierNumber', + data: 'supplierNumber', + className: 'data-table-header', + index: 6, + }; +} + +function getSupplierNameColumn() { + return { + title: 'Supplier Name', + name: 'supplierName', + data: 'supplierName', + className: 'data-table-header', + index: 7, + }; +} + +function getSiteNumberColumn() { + return { + title: 'Site #', + name: 'siteNumber', + data: 'site.number', + className: 'data-table-header', + defaultContent: '', + index: 8, + }; +} + +function getPaymentStatusTextColor(status) { + switch (status) { + case 'L1Pending': + case 'L2Pending': + case 'L3Pending': + return '#053662'; + case 'L1Declined': + case 'L2Declined': + case 'L3Declined': + case 'Failed': + return '#CE3E39'; + case 'Submitted': + return '#5595D9'; + case 'Paid': + case 'HistoricalPayment': + return '#42814A'; + default: + return '#053662'; + } +} + +$(function () { + const l = abp.localization.getResource('Payments'); + $('.unity-currency-input').maskMoney({}); + $('.unity-currency-input').each(function () { + $(this).maskMoney('mask', this.value); + }); + + const formatter = createNumberFormatter(); + let dt = $('#ApplicantPaymentRequestListTable'); + let dataTable; + const listColumns = getColumns(); + const defaultVisibleColumns = [ + 'referenceNumber', + 'applicationReferenceNo', + 'paymentDate', + 'status', + 'amount', + 'paymentStatus', + 'supplierNumber', + 'supplierName', + 'siteNumber', + ]; + + let actionButtons = [ + { + text: 'Filter', + className: 'custom-table-btn flex-none btn btn-secondary', + action: function () {}, + attr: { id: 'btn-toggle-filter-applicant-payments' }, + }, + { + extend: 'csv', + text: 'Export', + title: 'Applicant Payments', + className: 'custom-table-btn flex-none btn btn-secondary', + exportOptions: { + rows: { search: 'applied' }, + columns: ':visible:not(.notexport)', + orthogonal: 'fullName', + }, + }, + ]; + + let applicantId = document.getElementById('ApplicantPaymentsApplicantId').value; + let inputAction = function () { + return applicantId; + }; + + let responseCallback = function (result) { + if (result.length <= 15) { + $('.dataTables_paginate').hide(); + } + return { + recordsTotal: result.length, + recordsFiltered: result.length, + data: result, + }; + }; + + if (abp.auth.isGranted('Unity.GrantManager.ApplicationManagement.Payment.PaymentList')) { + dataTable = initializeDataTable({ + dt, + defaultVisibleColumns, + listColumns, + maxRowsPerPage: 10, + defaultSortColumn: { name: 'paymentDate', dir: 'desc' }, + dataEndpoint: + unity.grantManager.applicantProfile.applicantPayments + .getPaymentListByApplicantId, + data: inputAction, + responseCallback, + actionButtons, + serverSideEnabled: false, + pagingEnabled: true, + reorderEnabled: true, + languageSetValues: {}, + dataTableName: 'ApplicantPaymentRequestListTable', + externalFilterButtonId: 'btn-toggle-filter-applicant-payments', + dynamicButtonContainerId: 'applicantPaymentsDynamicButtonContainerId', + lengthMenu: [10, 25, 50, -1], + }); + + dataTable.externalSearch('#applicant-payments-search', { delay: 300 }); + + $('#nav-payments-tab').one('click', function () { + dataTable.columns.adjust(); + }); + } + + function getColumns() { + return [ + getPaymentIdColumn(), + getSubmissionIdColumn(), + getPaidDateColumn(), + getStatusColumn(), + getAmountColumn(), + getCasPaymentStatusColumn(), + getSupplierNumberColumn(), + getSupplierNameColumn(), + getSiteNumberColumn(), + ]; + } + + function getStatusColumn() { + return { + title: 'Status', + name: 'status', + data: 'status', + className: 'data-table-header', + index: 3, + render: function (data) { + let statusColor = getPaymentStatusTextColor(data); + return ( + '' + + l('Enum:PaymentRequestStatus.' + data) + + '' + ); + }, + }; + } + + function getAmountColumn() { + return { + title: 'Amount', + name: 'amount', + data: 'amount', + className: 'data-table-header currency-display', + index: 4, + render: function (data) { + return formatter.format(data); + }, + }; + } +}); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml index 2a0c5a69ad..9af7e0bc89 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml @@ -26,7 +26,7 @@
@if (Model.IsAIScoringEnabled && Model.IsAiAssessment) { -