From 17fd5692578fbbef5d7b01b57b3dce22a3bd8f7c Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Thu, 2 Apr 2026 12:05:09 -0700 Subject: [PATCH 01/10] AB#32455 update intake applicant electoral district handling and add corrective scripts --- .../Generate-ElectoralDistrictFixes.ps1 | 186 +++++++++++ .../scripts/Validate-ElectoralDistricts.ps1 | 311 ++++++++++++++++++ .../DetermineElectoralDistrictHandler.cs | 38 ++- .../Applications/ApplicantAddress.cs | 10 +- 4 files changed, 533 insertions(+), 12 deletions(-) create mode 100644 applications/Unity.GrantManager/scripts/Generate-ElectoralDistrictFixes.ps1 create mode 100644 applications/Unity.GrantManager/scripts/Validate-ElectoralDistricts.ps1 diff --git a/applications/Unity.GrantManager/scripts/Generate-ElectoralDistrictFixes.ps1 b/applications/Unity.GrantManager/scripts/Generate-ElectoralDistrictFixes.ps1 new file mode 100644 index 0000000000..79abc7f5db --- /dev/null +++ b/applications/Unity.GrantManager/scripts/Generate-ElectoralDistrictFixes.ps1 @@ -0,0 +1,186 @@ +<# +.SYNOPSIS + Generates PostgreSQL UPDATE statements to fix mismatched electoral districts. + +.DESCRIPTION + Reads the validated CSV output from Validate-ElectoralDistricts.ps1 and generates + SQL UPDATE statements for rows where: + - DistrictMatch is MISMATCH and GeocoderScore >= MinScore: SET to the expected district (main file) + - DistrictMatch is MISMATCH and GeocoderScore < MinScore: SET to NULL (separate file, opt-in) + - AddressType matches the specified type (1=Physical, 2=Mailing) + +.PARAMETER InputCsv + Path to the validated CSV file (output of Validate-ElectoralDistricts.ps1). + +.PARAMETER OutputSql + Path to the high-confidence output .sql file. Defaults to _update.sql. + +.PARAMETER MinScore + Minimum geocoder score to trust. Rows at or above get the expected district; + rows below get NULL (if -IncludeLowConfidence is set). Default: 70. + +.PARAMETER AddressType + AddressType to filter on. 1 = Physical, 2 = Mailing. Default: 1 (Physical). + +.PARAMETER IncludeLowConfidence + When set, generates a separate .sql file for low-confidence rows (score < MinScore) + that sets ApplicantElectoralDistrict to NULL. File is _nullify.sql. + +.EXAMPLE + .\Generate-ElectoralDistrictFixes.ps1 ` + -InputCsv ".\data\electoral_districts_validated.csv" ` + -MinScore 70 ` + -AddressType 1 ` + -IncludeLowConfidence +#> +param( + [Parameter(Mandatory)] + [string]$InputCsv, + + [string]$OutputSql = "", + + [int]$MinScore = 70, + + [ValidateSet("1", "2")] + [string]$AddressType = "1", + + [switch]$IncludeLowConfidence +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +# ── Output path defaults ────────────────────────────────────────────── +$dir = [System.IO.Path]::GetDirectoryName((Resolve-Path $InputCsv)) +$name = [System.IO.Path]::GetFileNameWithoutExtension($InputCsv) + +if (-not $OutputSql) { + $OutputSql = Join-Path $dir "${name}_update.sql" +} +$NullifySql = Join-Path $dir "${name}_nullify.sql" + +# ── Read CSV ────────────────────────────────────────────────────────── +$data = Import-Csv -Path $InputCsv +Write-Host "Loaded $($data.Count) rows from $InputCsv" -ForegroundColor Cyan + +# ── Resolve address type label ──────────────────────────────────────── +$addressTypeLabel = switch ($AddressType) { + "1" { "Physical" } + "2" { "Mailing" } +} +Write-Host "Filtering for AddressType: $AddressType ($addressTypeLabel)" -ForegroundColor Cyan + +# ── Filter all mismatches for this address type ────────────────────── +$allMismatches = @($data | Where-Object { + $_.DistrictMatch -eq "MISMATCH" -and + $_.GeocoderScore -ne "" -and + $_.AddressType -eq $AddressType +}) + +# Split into high-confidence (update to expected) and low-confidence (set to NULL) +$highConfidence = @($allMismatches | Where-Object { [int]$_.GeocoderScore -ge $MinScore }) +$lowConfidence = @($allMismatches | Where-Object { [int]$_.GeocoderScore -lt $MinScore }) + +Write-Host "Found $($allMismatches.Count) total MISMATCH rows for AddressType = $AddressType ($addressTypeLabel)" -ForegroundColor Cyan +Write-Host " High confidence (score >= $MinScore): $($highConfidence.Count) -> will SET to expected district" -ForegroundColor Green +$lowConfMsg = if ($IncludeLowConfidence) { " -> will SET to NULL (separate file)" } else { " (skipped, use -IncludeLowConfidence to generate)" } +Write-Host " Low confidence (score < $MinScore): $($lowConfidence.Count)$lowConfMsg" -ForegroundColor Yellow + +if ($highConfidence.Count -eq 0 -and (-not $IncludeLowConfidence -or $lowConfidence.Count -eq 0)) { + Write-Host "No rows to update. Exiting." -ForegroundColor Yellow + return +} + +# ── Helper ─────────────────────────────────────────────────────────── +function Escape-SqlString { + param([string]$value) + return $value.Replace("'", "''") +} + +# ── Generate high-confidence SQL ───────────────────────────────────── +if ($highConfidence.Count -gt 0) { + $sb = [System.Text.StringBuilder]::new() + [void]$sb.AppendLine("-- Electoral District Fix Script (High Confidence)") + [void]$sb.AppendLine("-- Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')") + [void]$sb.AppendLine("-- Source: $InputCsv") + [void]$sb.AppendLine("-- Filter: MISMATCH rows, AddressType = $AddressType ($addressTypeLabel), Score >= $MinScore") + [void]$sb.AppendLine("-- Total updates: $($highConfidence.Count)") + [void]$sb.AppendLine("") + [void]$sb.AppendLine("BEGIN;") + [void]$sb.AppendLine("") + + foreach ($row in $highConfidence) { + $appId = $row.ApplicationId.Trim() + $currentED = Escape-SqlString $row.ApplicantElectoralDistrict.Trim() + $expectedED = Escape-SqlString $row.ExpectedElectoralDistrict.Trim() + $score = $row.GeocoderScore + $refNo = $row.ReferenceNo + + [void]$sb.AppendLine("-- ReferenceNo: $refNo | Score: $score | '$currentED' -> '$expectedED'") + [void]$sb.AppendLine("UPDATE ""Applications"" SET ""ApplicantElectoralDistrict"" = '$expectedED' WHERE ""Id"" = '$appId';") + [void]$sb.AppendLine("") + } + + [void]$sb.AppendLine("COMMIT;") + $sb.ToString() | Out-File -FilePath $OutputSql -Encoding UTF8 + Write-Host "`nHigh-confidence SQL written to: $OutputSql" -ForegroundColor Green +} +else { + Write-Host "`nNo high-confidence rows to write." -ForegroundColor DarkGray +} + +# ── Generate low-confidence SQL (separate file, opt-in) ────────────── +if ($IncludeLowConfidence -and $lowConfidence.Count -gt 0) { + $sbNull = [System.Text.StringBuilder]::new() + [void]$sbNull.AppendLine("-- Electoral District Nullify Script (Low Confidence)") + [void]$sbNull.AppendLine("-- Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')") + [void]$sbNull.AppendLine("-- Source: $InputCsv") + [void]$sbNull.AppendLine("-- Filter: MISMATCH rows, AddressType = $AddressType ($addressTypeLabel), Score < $MinScore") + [void]$sbNull.AppendLine("-- Total updates: $($lowConfidence.Count)") + [void]$sbNull.AppendLine("-- Action: SET ApplicantElectoralDistrict = NULL (unreliable geocoding)") + [void]$sbNull.AppendLine("") + [void]$sbNull.AppendLine("BEGIN;") + [void]$sbNull.AppendLine("") + + foreach ($row in $lowConfidence) { + $appId = $row.ApplicationId.Trim() + $currentED = Escape-SqlString $row.ApplicantElectoralDistrict.Trim() + $score = $row.GeocoderScore + $refNo = $row.ReferenceNo + + [void]$sbNull.AppendLine("-- ReferenceNo: $refNo | Score: $score | '$currentED' -> NULL") + [void]$sbNull.AppendLine("UPDATE ""Applications"" SET ""ApplicantElectoralDistrict"" = NULL WHERE ""Id"" = '$appId';") + [void]$sbNull.AppendLine("") + } + + [void]$sbNull.AppendLine("COMMIT;") + $sbNull.ToString() | Out-File -FilePath $NullifySql -Encoding UTF8 + Write-Host "Low-confidence SQL written to: $NullifySql" -ForegroundColor Yellow +} +elseif ($IncludeLowConfidence) { + Write-Host "`nNo low-confidence rows to write." -ForegroundColor DarkGray +} + +# ── Summary ─────────────────────────────────────────────────────────── +Write-Host "" +Write-Host "=============================" -ForegroundColor White +Write-Host " SQL Generation Summary" -ForegroundColor White +Write-Host "=============================" -ForegroundColor White +Write-Host "Address type: $AddressType ($addressTypeLabel)" +Write-Host "Score threshold: $MinScore" +Write-Host "-----------------------------" +Write-Host "High confidence: $($highConfidence.Count) (SET to expected)" -ForegroundColor Green +Write-Host "Low confidence: $($lowConfidence.Count) (SET to NULL)" -ForegroundColor Yellow +Write-Host "Total mismatches: $($allMismatches.Count)" +Write-Host "-----------------------------" +if ($highConfidence.Count -gt 0) { + Write-Host "Update file: $OutputSql" -ForegroundColor White +} +if ($IncludeLowConfidence -and $lowConfidence.Count -gt 0) { + Write-Host "Nullify file: $NullifySql" -ForegroundColor White +} +elseif ($lowConfidence.Count -gt 0) { + Write-Host "Nullify file: (not generated - use -IncludeLowConfidence)" -ForegroundColor DarkGray +} +Write-Host "" +Write-Host "Review the SQL file(s), then execute against your PostgreSQL database." -ForegroundColor Yellow diff --git a/applications/Unity.GrantManager/scripts/Validate-ElectoralDistricts.ps1 b/applications/Unity.GrantManager/scripts/Validate-ElectoralDistricts.ps1 new file mode 100644 index 0000000000..2d62ec963a --- /dev/null +++ b/applications/Unity.GrantManager/scripts/Validate-ElectoralDistricts.ps1 @@ -0,0 +1,311 @@ +<# +.SYNOPSIS + Cross-references electoral districts in a CSV against the BC Geocoder API. + +.DESCRIPTION + Reads a CSV with address data, looks up each unique address via the BC Geocoder + location API to get coordinates, then queries the electoral district WFS endpoint. + Deduplicates addresses so identical Street+Street2+City combinations are only + looked up once. Handles rate limiting with exponential backoff. + Outputs a new CSV with all original columns plus the expected electoral district. + +.PARAMETER InputCsv + Path to the input CSV file. + +.PARAMETER OutputCsv + Path to the output CSV file. Defaults to _validated.csv. + +.PARAMETER GeocoderLocationBase + Base URL for the geocoder location API (GEOCODER_LOCATION_API_BASE). + Example: https://geocoder.api.gov.bc.ca + +.PARAMETER GeocoderApiBase + Base URL for the geocoder WFS API (GEOCODER_API_BASE). + Example: https://openmaps.gov.bc.ca/geo/pub/wfs?service=WFS&version=2.0.0&request=GetFeature&typeName= + +.PARAMETER InitialDelayMs + Initial delay between API calls in milliseconds. Default: 250. + +.EXAMPLE + .\Validate-ElectoralDistricts.ps1 ` + -InputCsv ".\data\electoral_districts.csv" ` + -GeocoderLocationBase "https://geocoder.api.gov.bc.ca" ` + -GeocoderApiBase "https://openmaps.gov.bc.ca/geo/pub/wfs?service=WFS&version=2.0.0&request=GetFeature&typeName=" +#> +param( + [Parameter(Mandatory)] + [string]$InputCsv, + + [string]$OutputCsv = "", + + [Parameter(Mandatory)] + [string]$GeocoderLocationBase, + + [Parameter(Mandatory)] + [string]$GeocoderApiBase, + + [int]$InitialDelayMs = 250 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +# ── Output path defaults ────────────────────────────────────────────── +if (-not $OutputCsv) { + $dir = [System.IO.Path]::GetDirectoryName((Resolve-Path $InputCsv)) + $name = [System.IO.Path]::GetFileNameWithoutExtension($InputCsv) + $OutputCsv = Join-Path $dir "${name}_validated.csv" +} + +# ── Read CSV ────────────────────────────────────────────────────────── +$data = Import-Csv -Path $InputCsv +Write-Host "Loaded $($data.Count) rows from $InputCsv" -ForegroundColor Cyan + +# ── Helpers ─────────────────────────────────────────────────────────── +function Test-NullOrEmpty { + param([string]$value) + return [string]::IsNullOrWhiteSpace($value) -or $value.Trim() -eq 'NULL' +} + +function Get-CleanValue { + param([string]$value) + if (Test-NullOrEmpty $value) { return "" } + return $value.Trim() +} + +function Get-AddressKey { + param($row) + $street = Get-CleanValue $row.Street + $street2 = Get-CleanValue $row.Street2 + $city = Get-CleanValue $row.City + return "$street|$street2|$city".ToLowerInvariant() +} + +function Build-AddressString { + param($street, $street2, $city) + $parts = @($street, $street2, $city) | ForEach-Object { Get-CleanValue $_ } | Where-Object { $_ -ne "" } + return ($parts -join ", ") +} + +function Get-AddressTypeName { + param([string]$value) + $clean = Get-CleanValue $value + switch ($clean) { + "1" { return "Physical" } + "2" { return "Mailing" } + default { return "" } + } +} + +# ── Deduplicate addresses ──────────────────────────────────────────── +$addressLookup = [ordered]@{} +$skippedCount = 0 + +foreach ($row in $data) { + $street = Get-CleanValue $row.Street + $street2 = Get-CleanValue $row.Street2 + $city = Get-CleanValue $row.City + + # Skip rows where both street fields and city are empty/NULL + if ($street -eq "" -and $street2 -eq "" -and $city -eq "") { + $skippedCount++ + continue + } + + $key = Get-AddressKey $row + if (-not $addressLookup.Contains($key)) { + $addressLookup[$key] = @{ + Street = $row.Street + Street2 = $row.Street2 + City = $row.City + ExpectedED = $null + Score = $null + FullAddress = $null + Error = $null + } + } +} + +$uniqueCount = $addressLookup.Count +Write-Host "Unique addresses to look up: $uniqueCount (skipped $skippedCount rows with empty address)" -ForegroundColor Cyan + +# ── Rate-limited HTTP caller with exponential backoff ───────────────── +$script:currentDelayMs = $InitialDelayMs +$maxDelayMs = 30000 +$minDelayMs = 100 + +function Invoke-GeocoderRequest { + param( + [string]$Uri, + [int]$MaxRetries = 6 + ) + + for ($attempt = 1; $attempt -le ($MaxRetries + 1); $attempt++) { + try { + $response = Invoke-RestMethod -Uri $Uri -Method Get -ErrorAction Stop + + # On success, gently reduce the inter-call delay + $script:currentDelayMs = [Math]::Max($minDelayMs, [int]([Math]::Floor($script:currentDelayMs * 0.95))) + + return $response + } + catch { + $statusCode = 0 + if ($_.Exception.Response) { + $statusCode = [int]$_.Exception.Response.StatusCode + } + + if (($statusCode -eq 429 -or $statusCode -eq 503) -and $attempt -le $MaxRetries) { + # Exponential backoff + $script:currentDelayMs = [Math]::Min($maxDelayMs, $script:currentDelayMs * 2) + $backoffMs = $script:currentDelayMs * $attempt + Write-Host " Rate limited (HTTP $statusCode). Backing off ${backoffMs}ms (attempt $attempt/$MaxRetries)..." -ForegroundColor Yellow + Start-Sleep -Milliseconds $backoffMs + } + else { + throw + } + } + } +} + +# ── Process each unique address ─────────────────────────────────────── +$processed = 0 +$errorCount = 0 + +# Electoral district WFS parameters (from appsettings.json Geocoder:ElectoralDistrict) +$edFeature = "pub:WHSE_ADMIN_BOUNDARIES.EBC_PROV_ELECTORAL_DIST_SVW" +$edProperty = "ED_NAME" +$edQueryType = "SHAPE" + +foreach ($entry in $addressLookup.GetEnumerator()) { + $processed++ + $addr = $entry.Value + $addressString = Build-AddressString $addr.Street $addr.Street2 $addr.City + + Write-Host "[$processed/$uniqueCount] $addressString" -ForegroundColor Cyan + + try { + # Step 1: Geocode address → coordinates + $encodedAddress = [System.Uri]::EscapeDataString($addressString) + $locationUri = "$GeocoderLocationBase/addresses.json?outputSRS=3005&addressString=$encodedAddress" + + Start-Sleep -Milliseconds $script:currentDelayMs + $locationResult = Invoke-GeocoderRequest -Uri $locationUri + + if (-not $locationResult.features -or $locationResult.features.Count -eq 0) { + Write-Host " No location results" -ForegroundColor Yellow + $addr.Error = "No location results" + $errorCount++ + continue + } + + $coords = $locationResult.features[0].geometry.coordinates + $latitude = $coords[0] # Mirrors C# ResultMapper: coordinates[0] → Latitude + $longitude = $coords[1] # Mirrors C# ResultMapper: coordinates[1] → Longitude + $score = $locationResult.features[0].properties.score + $fullAddr = $locationResult.features[0].properties.fullAddress + + $addr.Score = $score + $addr.FullAddress = $fullAddr + Write-Host " Resolved: $fullAddr (score: $score)" -ForegroundColor DarkGray + + # Step 2: Look up electoral district from coordinates + $edUri = "${GeocoderApiBase}${edFeature}" + + "&srsname=EPSG:4326" + + "&propertyName=${edProperty}" + + "&outputFormat=application/json" + + "&cql_filter=INTERSECTS(${edQueryType},POINT($latitude $longitude))" + + Start-Sleep -Milliseconds $script:currentDelayMs + $edResult = Invoke-GeocoderRequest -Uri $edUri + + if (-not $edResult.features -or $edResult.features.Count -eq 0) { + Write-Host " No electoral district found for coordinates" -ForegroundColor Yellow + $addr.ExpectedED = "" + $addr.Error = "No electoral district for coordinates ($latitude, $longitude)" + $errorCount++ + continue + } + + $expectedED = $edResult.features[0].properties.ED_NAME + $addr.ExpectedED = $expectedED + Write-Host " Electoral District: $expectedED" -ForegroundColor Green + } + catch { + Write-Host " ERROR: $_" -ForegroundColor Red + $addr.Error = $_.ToString() + $errorCount++ + } +} + +# ── Build output CSV ────────────────────────────────────────────────── +Write-Host "`nBuilding output CSV..." -ForegroundColor Cyan + +$output = [System.Collections.Generic.List[PSCustomObject]]::new() + +foreach ($row in $data) { + $key = Get-AddressKey $row + + $expectedED = "" + $lookupError = "" + $geoScore = "" + $geoFullAddr = "" + $matchResult = "SKIPPED" + + if ($addressLookup.Contains($key)) { + $lookup = $addressLookup[$key] + $expectedED = if ($lookup.ExpectedED) { $lookup.ExpectedED } else { "" } + $lookupError = if ($lookup.Error) { $lookup.Error } else { "" } + $geoScore = if ($lookup.Score -ne $null) { $lookup.Score } else { "" } + $geoFullAddr = if ($lookup.FullAddress) { $lookup.FullAddress } else { "" } + + if ($expectedED -and $row.ApplicantElectoralDistrict) { + if ($expectedED.Trim() -eq $row.ApplicantElectoralDistrict.Trim()) { + $matchResult = "MATCH" + } + else { + $matchResult = "MISMATCH" + } + } + else { + $matchResult = "UNKNOWN" + } + } + + $outRow = [ordered]@{} + foreach ($prop in $row.PSObject.Properties) { + $outRow[$prop.Name] = $prop.Value + } + $outRow["AddressTypeName"] = Get-AddressTypeName $row.AddressType + $outRow["ExpectedElectoralDistrict"] = $expectedED + $outRow["DistrictMatch"] = $matchResult + $outRow["GeocoderScore"] = $geoScore + $outRow["GeocoderFullAddress"] = $geoFullAddr + $outRow["LookupError"] = $lookupError + + $output.Add([PSCustomObject]$outRow) +} + +$output | Export-Csv -Path $OutputCsv -NoTypeInformation -Encoding UTF8 + +# ── Summary ─────────────────────────────────────────────────────────── +$matchCount = @($output | Where-Object { $_.DistrictMatch -eq "MATCH" }).Count +$mismatchCount = @($output | Where-Object { $_.DistrictMatch -eq "MISMATCH" }).Count +$unknownCount = @($output | Where-Object { $_.DistrictMatch -eq "UNKNOWN" }).Count +$skippedRows = @($output | Where-Object { $_.DistrictMatch -eq "SKIPPED" }).Count + +Write-Host "" +Write-Host "=============================" -ForegroundColor White +Write-Host " Electoral District Audit" -ForegroundColor White +Write-Host "=============================" -ForegroundColor White +Write-Host "Total rows: $($data.Count)" +Write-Host "Unique addresses: $uniqueCount" +Write-Host "API errors: $errorCount" -ForegroundColor $(if ($errorCount -gt 0) { "Red" } else { "Green" }) +Write-Host "-----------------------------" +Write-Host "MATCH: $matchCount" -ForegroundColor Green +Write-Host "MISMATCH: $mismatchCount" -ForegroundColor $(if ($mismatchCount -gt 0) { "Red" } else { "Green" }) +Write-Host "UNKNOWN: $unknownCount" -ForegroundColor Yellow +Write-Host "SKIPPED: $skippedRows" -ForegroundColor DarkGray +Write-Host "-----------------------------" +Write-Host "Output: $OutputCsv" -ForegroundColor White diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/DetermineElectoralDistrictHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/DetermineElectoralDistrictHandler.cs index f757747e49..f1d92723ee 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/DetermineElectoralDistrictHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/DetermineElectoralDistrictHandler.cs @@ -91,12 +91,35 @@ public async Task DetermineElectoralDistrictAsync(Application? application, Appl } // Extract from geo services - var address = matchedAddressType.GetFullAddress(); + var address = matchedAddressType.GetSearchAddress(); var geoAddressDetails = await geocoderApiService.GetAddressDetailsAsync(address); if (geoAddressDetails == null || geoAddressDetails.Coordinates == null) { - logger.LogWarning("No coordinates found for address: {Address}", address); + logger.LogWarning("No coordinates found for address: {Address} for application {ApplicationId}.", + address, application.Id); + return; + } + + logger.LogInformation( + "Geocoder resolved address for application {ApplicationId}: " + + "Input={Address}, Resolved={ResolvedAddress}, Score={GeocoderScore}, " + + "Coordinates=({Latitude}, {Longitude})", + application.Id, + address, + geoAddressDetails.FullAddress, + geoAddressDetails.Score, + geoAddressDetails.Coordinates.Latitude, + geoAddressDetails.Coordinates.Longitude); + + if (geoAddressDetails.Score < 60) + { + application.ApplicantElectoralDistrict = null; + logger.LogWarning( + "Low geocoder confidence score {GeocoderScore} for application {ApplicationId}. " + + "Input={Address}, Resolved={ResolvedAddress}. " + + "Electoral district set to null due to unreliable geocoding.", + geoAddressDetails.Score, application.Id, address, geoAddressDetails.FullAddress); return; } @@ -105,12 +128,17 @@ public async Task DetermineElectoralDistrictAsync(Application? application, Appl if (electoralDistrict.Name != null) { application.ApplicantElectoralDistrict = electoralDistrict.Name; - logger.LogInformation("Electoral district '{ElectoralDistrict}' determined for address: {Address}", - electoralDistrict.Name, address); + logger.LogInformation( + "Electoral district '{ElectoralDistrict}' determined for application {ApplicationId}. " + + "Address={Address}, GeocoderScore={GeocoderScore}", + electoralDistrict.Name, application.Id, address, geoAddressDetails.Score); } else { - logger.LogWarning("Electoral district could not be determined for address: {Address}", address); + logger.LogWarning( + "Electoral district could not be determined for application {ApplicationId}. " + + "Address={Address}, GeocoderScore={GeocoderScore}", + application.Id, address, geoAddressDetails.Score); } } catch (Exception ex) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicantAddress.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicantAddress.cs index f82c83afa4..d06415e6ca 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicantAddress.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicantAddress.cs @@ -42,19 +42,15 @@ public virtual Application Application public Guid? TenantId { get; set; } /// - /// Returns the address as a single comma-separated string, ordered by relevance. + /// Returns a search-friendly address string (Street, Street2, City) for geocoding lookups. /// - public string GetFullAddress() + public string GetSearchAddress() { var parts = new[] { Street, Street2, - Unit, - City, - Province, - Postal, - Country + City }; var address = string.Join(", ", parts From c56482c0b0e017402f2e044f85005ae0896bc62b Mon Sep 17 00:00:00 2001 From: "Todosichuk, Daryl JEDI:EX" Date: Thu, 2 Apr 2026 12:13:57 -0700 Subject: [PATCH 02/10] AB#32540 add tagging to artifactory push and resolve the deprecation warnings for the test-project workflows --- .github/workflows/docker-build-dev.yml | 8 ++++---- .github/workflows/docker-build-test.yml | 8 ++++---- .github/workflows/pr-check-dev-branch.yml | 6 +++--- .github/workflows/pr-check-main-branch.yml | 6 +++--- .github/workflows/pr-check-test-branch.yml | 6 +++--- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/workflows/docker-build-dev.yml b/.github/workflows/docker-build-dev.yml index e1968299ec..22096b09d9 100644 --- a/.github/workflows/docker-build-dev.yml +++ b/.github/workflows/docker-build-dev.yml @@ -125,10 +125,10 @@ jobs: echo "$JFROG_PASSWORD" | docker login -u "$JFROG_USERNAME" --password-stdin $JFROG_SERVICE - name: Push application images to Artifactory container registry run: | - docker tag unity-grantmanager-dbmigrator $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator - docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator - docker tag unity-grantmanager-web $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web - docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web + docker tag unity-grantmanager-dbmigrator $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator:latest + docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator:latest + docker tag unity-grantmanager-web $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web:latest + docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web:latest - name: Disconnect docker from JFrog Artifactory run: | docker logout diff --git a/.github/workflows/docker-build-test.yml b/.github/workflows/docker-build-test.yml index 96a43e594d..f6a35b804a 100644 --- a/.github/workflows/docker-build-test.yml +++ b/.github/workflows/docker-build-test.yml @@ -158,10 +158,10 @@ jobs: echo "$JFROG_PASSWORD" | docker login -u "$JFROG_USERNAME" --password-stdin $JFROG_SERVICE - name: Push application images to Artifactory container registry run: | - docker tag unity-grantmanager-dbmigrator $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator - docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator - docker tag unity-grantmanager-web $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web - docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web + docker tag unity-grantmanager-dbmigrator $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator:latest + docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator:latest + docker tag unity-grantmanager-web $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web:latest + docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web:latest - name: Disconnect docker from JFrog Artifactory run: | docker logout diff --git a/.github/workflows/pr-check-dev-branch.yml b/.github/workflows/pr-check-dev-branch.yml index 80f6731abd..6e5b709ff2 100644 --- a/.github/workflows/pr-check-dev-branch.yml +++ b/.github/workflows/pr-check-dev-branch.yml @@ -69,7 +69,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/setup-dotnet@v4 + - uses: actions/setup-dotnet@v5 with: dotnet-version: "9.0.x" @@ -85,7 +85,7 @@ jobs: --logger "trx;LogFileName=${NAME}.trx" \ --results-directory TestResults - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: test-output-${{ strategy.job-index }} path: TestResults/ @@ -133,7 +133,7 @@ jobs: echo "failed=$FAILED" >> $GITHUB_OUTPUT echo "skipped=$SKIPPED" >> $GITHUB_OUTPUT - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: merged-test-results path: merged/ diff --git a/.github/workflows/pr-check-main-branch.yml b/.github/workflows/pr-check-main-branch.yml index cbb297ba79..d81966efe6 100644 --- a/.github/workflows/pr-check-main-branch.yml +++ b/.github/workflows/pr-check-main-branch.yml @@ -65,7 +65,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/setup-dotnet@v4 + - uses: actions/setup-dotnet@v5 with: dotnet-version: "9.0.x" @@ -81,7 +81,7 @@ jobs: --logger "trx;LogFileName=${NAME}.trx" \ --results-directory TestResults - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: test-output-${{ strategy.job-index }} path: TestResults/ @@ -129,7 +129,7 @@ jobs: echo "failed=$FAILED" >> $GITHUB_OUTPUT echo "skipped=$SKIPPED" >> $GITHUB_OUTPUT - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: merged-test-results path: merged/ diff --git a/.github/workflows/pr-check-test-branch.yml b/.github/workflows/pr-check-test-branch.yml index 7deb972735..9fea720bbd 100644 --- a/.github/workflows/pr-check-test-branch.yml +++ b/.github/workflows/pr-check-test-branch.yml @@ -67,7 +67,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/setup-dotnet@v4 + - uses: actions/setup-dotnet@v5 with: dotnet-version: "9.0.x" @@ -83,7 +83,7 @@ jobs: --logger "trx;LogFileName=${NAME}.trx" \ --results-directory TestResults - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: test-output-${{ strategy.job-index }} path: TestResults/ @@ -131,7 +131,7 @@ jobs: echo "failed=$FAILED" >> $GITHUB_OUTPUT echo "skipped=$SKIPPED" >> $GITHUB_OUTPUT - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: merged-test-results path: merged/ From d353be31e0ebad33f9daaa97dd48eb662da88401 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 2 Apr 2026 13:06:12 -0700 Subject: [PATCH 03/10] hotfix/AB#32532-OneTimeConsidertation-FixNullable --- .../Views/Shared/Components/ApplicantHistory/Default.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 507ae4c905..61450ef2d4 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 @@ -46,8 +46,8 @@ $(function () { { title: 'Funding Year', data: 'fundingYear', name: 'fundingYear', className: 'data-table-header', width: '80px', render: (d) => d ?? nullPlaceholder }, { title: 'Renewed Funding', data: 'renewedFunding', name: 'renewedFunding', className: 'data-table-header', width: '110px', render: (d) => d === true ? 'Yes' : 'No' }, { title: 'Approved Amount', data: 'approvedAmount', name: 'approvedAmount', className: 'data-table-header currency-display', render: (d) => formatCurrency(d) }, - { title: 'Reconsideration Amount', data: 'reconsiderationAmount', name: 'reconsiderationAmount', className: 'data-table-header currency-display', render: (d) => formatCurrency(d) }, { title: 'One-Time Consideration', data: 'oneTimeConsideration', name: 'oneTimeConsideration', className: 'data-table-header currency-display', render: (d) => formatCurrency(d) }, + { title: 'Reconsideration Amount', data: 'reconsiderationAmount', name: 'reconsiderationAmount', className: 'data-table-header currency-display', render: (d) => formatCurrency(d) }, { title: 'Total Grant Amount', data: 'totalGrantAmount', name: 'totalGrantAmount', className: 'data-table-header currency-display', render: (d) => formatCurrency(d) }, { title: 'Notes', data: 'fundingNotes', name: 'fundingNotes', className: 'data-table-header', width: '200px', From 9f5171482dc72d085f9403157dcbb486e01575d7 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 2 Apr 2026 13:26:41 -0700 Subject: [PATCH 04/10] hotfix/AB#32532-OneTimeConsidertation-ChangeOrder --- .../History/CreateUpdateFundingHistoryDto.cs | 2 +- .../History/FundingHistoryDto.cs | 2 +- .../Applications/FundingHistory.cs | 2 +- .../CreateFundingHistoryModal.cshtml | 15 ++++++++------- .../EditFundingHistoryModal.cshtml | 16 +++++++++------- .../EditFundingHistoryModal.cshtml.cs | 6 +++--- .../FundingHistoryModalViewModel.cs | 8 ++++---- 7 files changed, 27 insertions(+), 24 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/CreateUpdateFundingHistoryDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/CreateUpdateFundingHistoryDto.cs index e6ea8baa78..9de866e604 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/CreateUpdateFundingHistoryDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/CreateUpdateFundingHistoryDto.cs @@ -9,8 +9,8 @@ public class CreateUpdateFundingHistoryDto public string? FundingYear { get; set; } public bool? RenewedFunding { get; set; } public decimal? ApprovedAmount { get; set; } + public decimal? OneTimeConsideration { get; set; } public decimal? ReconsiderationAmount { get; set; } - public decimal? OneTimeConsideration { get; set; } public decimal? TotalGrantAmount { get; set; } public string? FundingNotes { get; set; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/FundingHistoryDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/FundingHistoryDto.cs index 7c5660dbd8..dfdf5afaad 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/FundingHistoryDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/FundingHistoryDto.cs @@ -10,8 +10,8 @@ public class FundingHistoryDto : AuditedEntityDto public string? FundingYear { get; set; } public bool? RenewedFunding { get; set; } public decimal? ApprovedAmount { get; set; } + public decimal? OneTimeConsideration { get; set; } public decimal? ReconsiderationAmount { get; set; } - public decimal? OneTimeConsideration { get; set; } public decimal? TotalGrantAmount { get; set; } public string? FundingNotes { get; set; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/FundingHistory.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/FundingHistory.cs index 9925c356ce..5a601ac7c8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/FundingHistory.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/FundingHistory.cs @@ -11,8 +11,8 @@ public class FundingHistory : AuditedAggregateRoot, IMultiTenant public string? FundingYear { get; set; } public bool? RenewedFunding { get; set; } public decimal? ApprovedAmount { get; set; } + public decimal? OneTimeConsideration { get; set; } public decimal? ReconsiderationAmount { get; set; } - public decimal? OneTimeConsideration { get; set; } public decimal? TotalGrantAmount { get; set; } public string? FundingNotes { get; set; } public Guid? TenantId { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/CreateFundingHistoryModal.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/CreateFundingHistoryModal.cshtml index bfda1ec3f2..6db3da1854 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/CreateFundingHistoryModal.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/CreateFundingHistoryModal.cshtml @@ -49,21 +49,22 @@
$ - - One-Time Consideration + + value="@Model.FundingHistoryForm?.OneTimeConsideration" />
+
$ - - Reconsideration Amount + + value="@Model.FundingHistoryForm?.ReconsiderationAmount" />
-
+
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/EditFundingHistoryModal.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/EditFundingHistoryModal.cshtml index 4c50b48d8f..983d0d4cef 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/EditFundingHistoryModal.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/EditFundingHistoryModal.cshtml @@ -50,21 +50,23 @@
$ - - One-Time Consideration + + value="@Model.FundingHistoryForm?.OneTimeConsideration" />
-
+
+
$ - - Reconsideration Amount + + value="@Model.FundingHistoryForm?.ReconsiderationAmount" />
+
$ diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/EditFundingHistoryModal.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/EditFundingHistoryModal.cshtml.cs index 6685e83806..6abe31a19c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/EditFundingHistoryModal.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/EditFundingHistoryModal.cshtml.cs @@ -32,8 +32,8 @@ public async Task OnGetAsync(Guid id) FundingYear = record.FundingYear, RenewedFunding = record.RenewedFunding, ApprovedAmount = record.ApprovedAmount, - ReconsiderationAmount = record.ReconsiderationAmount, - OneTimeConsideration = record.OneTimeConsideration, + OneTimeConsideration = record.OneTimeConsideration, + ReconsiderationAmount = record.ReconsiderationAmount, TotalGrantAmount = record.TotalGrantAmount, FundingNotes = record.FundingNotes }; @@ -48,8 +48,8 @@ public async Task OnPostAsync() FundingYear = FundingHistoryForm.FundingYear, RenewedFunding = FundingHistoryForm.RenewedFunding, ApprovedAmount = FundingHistoryForm.ApprovedAmount, + OneTimeConsideration = FundingHistoryForm.OneTimeConsideration, ReconsiderationAmount = FundingHistoryForm.ReconsiderationAmount, - OneTimeConsideration = FundingHistoryForm.OneTimeConsideration, TotalGrantAmount = FundingHistoryForm.TotalGrantAmount, FundingNotes = FundingHistoryForm.FundingNotes }; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/FundingHistoryModalViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/FundingHistoryModalViewModel.cs index 111f994821..814b9ed8f5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/FundingHistoryModalViewModel.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/FundingHistoryModalViewModel.cs @@ -23,13 +23,13 @@ public class FundingHistoryModalViewModel [DataType(DataType.Currency)] public decimal? ApprovedAmount { get; set; } - [DisplayName("Reconsideration Amount")] + [DisplayName("One-Time Consideration")] [DataType(DataType.Currency)] - public decimal? ReconsiderationAmount { get; set; } + public decimal? OneTimeConsideration { get; set; } - [DisplayName("One-Time Consideration")] + [DisplayName("Reconsideration Amount")] [DataType(DataType.Currency)] - public decimal? OneTimeConsideration { get; set; } + public decimal? ReconsiderationAmount { get; set; } [DisplayName("Total Grant Amount")] [DataType(DataType.Currency)] From af2c25f0917e13dbad1fdc00bdb1bcb931d2391a Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Thu, 2 Apr 2026 15:54:13 -0700 Subject: [PATCH 05/10] AB#32350 explicit filter by 'Paid' --- .../PaymentInfoDataProvider.cs | 9 ++++-- .../PaymentInfoDataProviderTests.cs | 32 ++++++++++--------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs index fc73e7f825..aabb424b30 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs @@ -47,7 +47,9 @@ from submission in submissionsQuery join application in applicationsQuery on submission.ApplicationId equals application.Id where submission.OidcSub == normalizedSubject select new { application.Id, application.ReferenceNo } - ).Distinct().ToDictionaryAsync(a => a.Id, a => a.ReferenceNo); + ) + .Distinct() + .ToDictionaryAsync(a => a.Id, a => a.ReferenceNo); if (applicationLookup.Count == 0) return dto; @@ -55,7 +57,8 @@ join application in applicationsQuery on submission.ApplicationId equals applica var paymentsQueryable = await paymentRequestRepository.GetQueryableAsync(); var paymentDetails = await paymentsQueryable - .Where(pr => applicationLookup.Keys.Contains(pr.CorrelationId)) + .Where(pr => applicationLookup.Keys.Contains(pr.CorrelationId) + && pr.PaymentStatus == "Paid") .ToListAsync(); dto.Payments.AddRange(paymentDetails.Select(p => new PaymentInfoItemDto @@ -65,7 +68,7 @@ join application in applicationsQuery on submission.ApplicationId equals applica ReferenceNo = applicationLookup.TryGetValue(p.CorrelationId, out var refNo) ? refNo : string.Empty, Amount = p.Amount, PaymentDate = p.PaymentDate, - PaymentStatus = p.Status.ToString() + PaymentStatus = "Paid" })); } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/PaymentInfoDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/PaymentInfoDataProviderTests.cs index c3429dc54e..395d0d6de2 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/PaymentInfoDataProviderTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/PaymentInfoDataProviderTests.cs @@ -9,7 +9,6 @@ using Unity.GrantManager.Applications; using Unity.GrantManager.TestHelpers; using Unity.Payments.Domain.PaymentRequests; -using Unity.Payments.Enums; using Unity.Payments.PaymentRequests; using Volo.Abp.Domain.Entities; using Volo.Abp.Domain.Repositories; @@ -84,7 +83,7 @@ private static Application CreateApplication(Guid id, string referenceNo = "") return entity; } - private static PaymentRequest CreatePaymentRequest(Guid correlationId, decimal amount = 1000m, string invoiceNumber = "INV-001") + private static PaymentRequest CreatePaymentRequest(Guid correlationId, decimal amount = 1000m, string invoiceNumber = "INV-001", string? paymentStatus = "Paid") { var siteId = Guid.NewGuid(); var dto = new CreatePaymentRequestDto @@ -99,7 +98,12 @@ private static PaymentRequest CreatePaymentRequest(Guid correlationId, decimal a CorrelationId = correlationId, CorrelationProvider = "Application" }; - return new PaymentRequest(Guid.NewGuid(), dto); + var paymentRequest = new PaymentRequest(Guid.NewGuid(), dto); + if (paymentStatus is not null) + { + paymentRequest.SetPaymentStatus(paymentStatus); + } + return paymentRequest; } [Fact] @@ -170,7 +174,6 @@ public async Task GetDataAsync_ShouldMapPaymentFields() var payment = CreatePaymentRequest(applicationId, 5000m); payment.SetPaymentDate("15-Jan-2025"); - payment.SetPaymentRequestStatus(PaymentRequestStatus.Paid); SetupQueryables( [CreateSubmission(applicationId, "TESTUSER")], @@ -294,28 +297,27 @@ public async Task GetDataAsync_ShouldHandleEmptyInvoiceNumber() dto.Payments[0].PaymentNumber.ShouldBe(string.Empty); } - [Theory] - [InlineData(PaymentRequestStatus.L1Pending, "L1Pending")] - [InlineData(PaymentRequestStatus.Submitted, "Submitted")] - [InlineData(PaymentRequestStatus.Paid, "Paid")] - [InlineData(PaymentRequestStatus.Failed, "Failed")] - public async Task GetDataAsync_ShouldMapPaymentStatus(PaymentRequestStatus status, string expectedStatusString) + [Fact] + public async Task GetDataAsync_ShouldOnlyReturnPaidPayments() { var request = CreateRequest(); var applicationId = Guid.NewGuid(); - var payment = CreatePaymentRequest(applicationId); - payment.SetPaymentRequestStatus(status); - SetupQueryables( [CreateSubmission(applicationId, "TESTUSER")], [CreateApplication(applicationId, "REF-001")], - [payment]); + [ + CreatePaymentRequest(applicationId, 1000m, paymentStatus: "Paid"), + CreatePaymentRequest(applicationId, 2000m, paymentStatus: null), + CreatePaymentRequest(applicationId, 3000m, paymentStatus: "Pending"), + CreatePaymentRequest(applicationId, 4000m, paymentStatus: "Failed") + ]); var result = await _provider.GetDataAsync(request); var dto = result.ShouldBeOfType(); - dto.Payments[0].PaymentStatus.ShouldBe(expectedStatusString); + dto.Payments.Count.ShouldBe(1); + dto.Payments[0].Amount.ShouldBe(1000m); } [Fact] From 6a280f19fdb05b1052290e68e85f38e141a7ddd6 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Mon, 6 Apr 2026 08:55:04 -0700 Subject: [PATCH 06/10] AB#32350 correct the filter string for paid --- .../ApplicantProfile/PaymentInfoDataProvider.cs | 6 ++++-- .../Applicants/PaymentInfoDataProviderTests.cs | 6 +++--- .../applicant-portal/applicant-profile-data-providers.md | 6 +++--- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs index aabb424b30..e4e057744e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs @@ -29,6 +29,8 @@ public class PaymentInfoDataProvider( /// public async Task GetDataAsync(ApplicantProfileInfoRequest request) { + const string FullyPaidStatus = "Fully Paid"; + var dto = new ApplicantPaymentInfoDto { Payments = [] @@ -58,7 +60,7 @@ join application in applicationsQuery on submission.ApplicationId equals applica var paymentsQueryable = await paymentRequestRepository.GetQueryableAsync(); var paymentDetails = await paymentsQueryable .Where(pr => applicationLookup.Keys.Contains(pr.CorrelationId) - && pr.PaymentStatus == "Paid") + && pr.PaymentStatus == FullyPaidStatus) .ToListAsync(); dto.Payments.AddRange(paymentDetails.Select(p => new PaymentInfoItemDto @@ -68,7 +70,7 @@ join application in applicationsQuery on submission.ApplicationId equals applica ReferenceNo = applicationLookup.TryGetValue(p.CorrelationId, out var refNo) ? refNo : string.Empty, Amount = p.Amount, PaymentDate = p.PaymentDate, - PaymentStatus = "Paid" + PaymentStatus = FullyPaidStatus })); } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/PaymentInfoDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/PaymentInfoDataProviderTests.cs index 395d0d6de2..2488d08ca7 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/PaymentInfoDataProviderTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/PaymentInfoDataProviderTests.cs @@ -83,7 +83,7 @@ private static Application CreateApplication(Guid id, string referenceNo = "") return entity; } - private static PaymentRequest CreatePaymentRequest(Guid correlationId, decimal amount = 1000m, string invoiceNumber = "INV-001", string? paymentStatus = "Paid") + private static PaymentRequest CreatePaymentRequest(Guid correlationId, decimal amount = 1000m, string invoiceNumber = "INV-001", string? paymentStatus = "Fully Paid") { var siteId = Guid.NewGuid(); var dto = new CreatePaymentRequestDto @@ -190,7 +190,7 @@ public async Task GetDataAsync_ShouldMapPaymentFields() item.ReferenceNo.ShouldBe("REF-001"); item.Amount.ShouldBe(5000m); item.PaymentDate.ShouldBe("2025-01-15"); - item.PaymentStatus.ShouldBe("Paid"); + item.PaymentStatus.ShouldBe("Fully Paid"); } [Fact] @@ -307,7 +307,7 @@ public async Task GetDataAsync_ShouldOnlyReturnPaidPayments() [CreateSubmission(applicationId, "TESTUSER")], [CreateApplication(applicationId, "REF-001")], [ - CreatePaymentRequest(applicationId, 1000m, paymentStatus: "Paid"), + CreatePaymentRequest(applicationId, 1000m, paymentStatus: "Fully Paid"), CreatePaymentRequest(applicationId, 2000m, paymentStatus: null), CreatePaymentRequest(applicationId, 3000m, paymentStatus: "Pending"), CreatePaymentRequest(applicationId, 4000m, paymentStatus: "Failed") diff --git a/documentation/applicant-portal/applicant-profile-data-providers.md b/documentation/applicant-portal/applicant-profile-data-providers.md index 8cc4eafa7d..75dcabc94b 100644 --- a/documentation/applicant-portal/applicant-profile-data-providers.md +++ b/documentation/applicant-portal/applicant-profile-data-providers.md @@ -422,7 +422,7 @@ flowchart LR **Source**: `PaymentRequest` entity (from `Unity.Payments` module), linked via `ApplicationFormSubmission` → `Application` where `PaymentRequest.CorrelationId` matches the application ID. -**Query**: Normalizes the OIDC subject, then joins `ApplicationFormSubmission` → `Application` to build a lookup of `ApplicationId → ReferenceNo`. Payment requests whose `CorrelationId` is in that set are returned with the application's `ReferenceNo` resolved from the lookup. +**Query**: Normalizes the OIDC subject, then joins `ApplicationFormSubmission` → `Application` to build a lookup of `ApplicationId → ReferenceNo`. Payment requests whose `CorrelationId` is in that set **and** whose CAS `PaymentStatus` is `"Fully Paid"` are returned with the application's `ReferenceNo` resolved from the lookup. **Response DTO**: `ApplicantPaymentInfoDto` @@ -436,7 +436,7 @@ flowchart LR "referenceNo": "REF-001", "amount": 5000.00, "paymentDate": "2025-01-15", - "paymentStatus": "Paid" + "paymentStatus": "Fully Paid" } ] } @@ -451,7 +451,7 @@ flowchart LR | `ReferenceNo` | `Application.ReferenceNo` | `string` | Application reference number, resolved via `CorrelationId → Application` lookup | | `Amount` | `PaymentRequest.Amount` | `decimal` | Requested payment amount | | `PaymentDate` | `PaymentRequest.PaymentDate` | `string?` | Date string populated during CAS reconciliation | -| `PaymentStatus` | `PaymentRequest.Status` | `string` | Enum converted to string (e.g. `L1Pending`, `Submitted`, `Paid`, `Failed`) | +| `PaymentStatus` | Constant `"Fully Paid"` | `string` | Always `"Fully Paid"` — only payments with CAS `PaymentStatus` of `"Fully Paid"` are returned | **Cross-module note**: This provider queries the `PaymentRequest` entity directly from the `Unity.Payments` module via `IRepository`. The `CorrelationId` on `PaymentRequest` corresponds to the `Application.Id` in the grant manager domain. From ea58a28585a7878630e80e00d80a4e1486292443 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Mon, 6 Apr 2026 11:31:49 -0700 Subject: [PATCH 07/10] AB#32350 co-pilot suggestions --- .../PaymentInfoDataProvider.cs | 10 +++-- .../PaymentInfoDataProviderTests.cs | 39 ++++++++++++++++--- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs index e4e057744e..973cc86a38 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs @@ -5,6 +5,7 @@ using Unity.GrantManager.ApplicantProfile.ProfileData; using Unity.GrantManager.Applications; using Unity.Payments.Domain.PaymentRequests; +using Unity.Payments.Codes; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; using Volo.Abp.MultiTenancy; @@ -29,8 +30,6 @@ public class PaymentInfoDataProvider( /// public async Task GetDataAsync(ApplicantProfileInfoRequest request) { - const string FullyPaidStatus = "Fully Paid"; - var dto = new ApplicantPaymentInfoDto { Payments = [] @@ -58,10 +57,13 @@ join application in applicationsQuery on submission.ApplicationId equals applica // Payment info is secured via feature flags and permissions, so direct query for this data instead of using module service var paymentsQueryable = await paymentRequestRepository.GetQueryableAsync(); +#pragma warning disable CA1862 // EF Core does not support StringComparison overloads - https://github.com/dotnet/efcore/issues/1222 var paymentDetails = await paymentsQueryable .Where(pr => applicationLookup.Keys.Contains(pr.CorrelationId) - && pr.PaymentStatus == FullyPaidStatus) + && pr.PaymentStatus != null + && pr.PaymentStatus.Trim().ToUpper() == CasPaymentRequestStatus.FullyPaid.ToUpper()) .ToListAsync(); +#pragma warning restore CA1862 dto.Payments.AddRange(paymentDetails.Select(p => new PaymentInfoItemDto { @@ -70,7 +72,7 @@ join application in applicationsQuery on submission.ApplicationId equals applica ReferenceNo = applicationLookup.TryGetValue(p.CorrelationId, out var refNo) ? refNo : string.Empty, Amount = p.Amount, PaymentDate = p.PaymentDate, - PaymentStatus = FullyPaidStatus + PaymentStatus = CasPaymentRequestStatus.FullyPaid })); } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/PaymentInfoDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/PaymentInfoDataProviderTests.cs index 2488d08ca7..a5e8872213 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/PaymentInfoDataProviderTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/PaymentInfoDataProviderTests.cs @@ -8,6 +8,7 @@ using Unity.GrantManager.ApplicantProfile.ProfileData; using Unity.GrantManager.Applications; using Unity.GrantManager.TestHelpers; +using Unity.Payments.Codes; using Unity.Payments.Domain.PaymentRequests; using Unity.Payments.PaymentRequests; using Volo.Abp.Domain.Entities; @@ -83,7 +84,7 @@ private static Application CreateApplication(Guid id, string referenceNo = "") return entity; } - private static PaymentRequest CreatePaymentRequest(Guid correlationId, decimal amount = 1000m, string invoiceNumber = "INV-001", string? paymentStatus = "Fully Paid") + private static PaymentRequest CreatePaymentRequest(Guid correlationId, decimal amount = 1000m, string invoiceNumber = "INV-001", string? paymentStatus = CasPaymentRequestStatus.FullyPaid) { var siteId = Guid.NewGuid(); var dto = new CreatePaymentRequestDto @@ -190,7 +191,7 @@ public async Task GetDataAsync_ShouldMapPaymentFields() item.ReferenceNo.ShouldBe("REF-001"); item.Amount.ShouldBe(5000m); item.PaymentDate.ShouldBe("2025-01-15"); - item.PaymentStatus.ShouldBe("Fully Paid"); + item.PaymentStatus.ShouldBe(CasPaymentRequestStatus.FullyPaid); } [Fact] @@ -307,10 +308,10 @@ public async Task GetDataAsync_ShouldOnlyReturnPaidPayments() [CreateSubmission(applicationId, "TESTUSER")], [CreateApplication(applicationId, "REF-001")], [ - CreatePaymentRequest(applicationId, 1000m, paymentStatus: "Fully Paid"), + CreatePaymentRequest(applicationId, 1000m, paymentStatus: CasPaymentRequestStatus.FullyPaid), CreatePaymentRequest(applicationId, 2000m, paymentStatus: null), - CreatePaymentRequest(applicationId, 3000m, paymentStatus: "Pending"), - CreatePaymentRequest(applicationId, 4000m, paymentStatus: "Failed") + CreatePaymentRequest(applicationId, 3000m, paymentStatus: CasPaymentRequestStatus.NotPaid), + CreatePaymentRequest(applicationId, 4000m, paymentStatus: CasPaymentRequestStatus.ErrorFromCas) ]); var result = await _provider.GetDataAsync(request); @@ -320,6 +321,34 @@ public async Task GetDataAsync_ShouldOnlyReturnPaidPayments() dto.Payments[0].Amount.ShouldBe(1000m); } + [Theory] + [InlineData("FULLY PAID")] + [InlineData("fully paid")] + [InlineData("Fully Paid")] + [InlineData(" Fully Paid ")] + [InlineData(" FULLY PAID ")] + public async Task GetDataAsync_ShouldMatchFullyPaidCaseInsensitiveWithWhitespace(string paymentStatus) + { + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [CreateApplication(applicationId, "REF-001")], + [ + CreatePaymentRequest(applicationId, 1500m, paymentStatus: paymentStatus), + CreatePaymentRequest(applicationId, 2000m, paymentStatus: null), + CreatePaymentRequest(applicationId, 3000m, paymentStatus: CasPaymentRequestStatus.NotPaid) + ]); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Payments.Count.ShouldBe(1); + dto.Payments[0].Amount.ShouldBe(1500m); + dto.Payments[0].PaymentStatus.ShouldBe(CasPaymentRequestStatus.FullyPaid); + } + [Fact] public void Key_ShouldMatchExpected() { From f27d68b3e66bda134ac6502f8555b8d534e8a4b7 Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Mon, 6 Apr 2026 16:01:54 -0700 Subject: [PATCH 08/10] AB#32538: Prevent creation of multiple Sites with Same Number --- .../Handlers/UpsertSupplierHandler.cs | 166 +++++++++--------- .../Suppliers/SiteAppService.cs | 13 ++ .../Components/SupplierInfo/SupplierInfo.js | 6 + 3 files changed, 105 insertions(+), 80 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Handlers/UpsertSupplierHandler.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Handlers/UpsertSupplierHandler.cs index 475368d829..053fcdf634 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Handlers/UpsertSupplierHandler.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Handlers/UpsertSupplierHandler.cs @@ -1,6 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -12,9 +12,9 @@ using Volo.Abp.DependencyInjection; using Volo.Abp.EventBus; using Volo.Abp.EventBus.Local; - -namespace Unity.Payments.Handlers -{ + +namespace Unity.Payments.Handlers +{ public class UpsertSupplierHandler(ISupplierAppService supplierAppService, SiteAppService siteAppService, ILogger logger, @@ -22,7 +22,7 @@ public class UpsertSupplierHandler(ISupplierAppService supplierAppService, IApplicationRepository applicationRepository) : ILocalEventHandler, ITransientDependency { - public async Task HandleEventAsync(UpsertSupplierEto eventData) + public async Task HandleEventAsync(UpsertSupplierEto eventData) { SupplierDto supplierDto = await GetSupplierFromEvent(eventData); var existingSites = await siteAppService.GetSitesBySupplierIdAsync(supplierDto.Id); @@ -35,34 +35,40 @@ public async Task HandleEventAsync(UpsertSupplierEto eventData) await localEventBus.PublishAsync( new ApplicantSupplierEto { - SupplierId = supplierDto.Id, - ApplicantId = eventData.CorrelationId, - ExistingSitesDictionary = existingSitesDictionary, - SiteEtos = eventData.SiteEtos - } - ); - } - + SupplierId = supplierDto.Id, + ApplicantId = eventData.CorrelationId, + ExistingSitesDictionary = existingSitesDictionary, + SiteEtos = eventData.SiteEtos + } + ); + } + private async Task> UpsertSitesFromEventDtoAsync( Dictionary existingSitesDictionary, Guid supplierId, UpsertSupplierEto upsertSupplierEto, PaymentGroup defaultPaymentGroup) { - foreach (var siteEto in upsertSupplierEto.SiteEtos) + // Deduplicate incoming SiteEtos by SupplierSiteCode — CAS can return duplicate site codes + var uniqueSiteEtos = upsertSupplierEto.SiteEtos + .GroupBy(s => s.SupplierSiteCode) + .Select(g => g.First()) + .ToList(); + + foreach (var siteEto in uniqueSiteEtos) { var siteDto = supplierAppService.GetSiteDtoFromSiteEto(siteEto, supplierId, defaultPaymentGroup); - if (existingSitesDictionary.TryGetValue(siteDto.Number, out var existingSite)) - { - siteDto.Id = existingSite.Id; - await siteAppService.UpdateAsync(siteDto); - } - else - { - await siteAppService.InsertAsync(siteDto); - } - } + if (existingSitesDictionary.TryGetValue(siteDto.Number, out var existingSite)) + { + siteDto.Id = existingSite.Id; + await siteAppService.UpdateAsync(siteDto); + } + else + { + await siteAppService.InsertAsync(siteDto); + } + } return existingSitesDictionary; } @@ -107,57 +113,57 @@ private async Task GetSupplierFromEvent(UpsertSupplierEto eventData { var existing = await supplierAppService.GetBySupplierNumberAsync(eventData.Number); logger.LogInformation("Upserting supplier from event data: {Existing}", existing); - - // This is subject to some business rules and a domain implementation - if (existing != null) - { - existing.Number = eventData.Number; - UpdateSupplierDto updateSupplierDto = GetUpdateSupplierDtoFromEvent(eventData); - SupplierDto updatedSupplierDto = await supplierAppService.UpdateAsync(existing.Id, updateSupplierDto); - return updatedSupplierDto; - } - - CreateSupplierDto createSupplierDto = GetCreateSupplierDtoFromEvent(eventData); - SupplierDto supplierDto = await supplierAppService.CreateAsync(createSupplierDto); - - return supplierDto; - } - - - private static UpdateSupplierDto GetUpdateSupplierDtoFromEvent(UpsertSupplierEto eventData) - { - return new UpdateSupplierDto() - { - Name = eventData.Name, - Number = eventData.Number, - Subcategory = eventData.Subcategory, - ProviderId = eventData.ProviderId, - BusinessNumber = eventData.BusinessNumber, - Status = eventData.Status, - SupplierProtected = eventData.SupplierProtected, - StandardIndustryClassification = eventData.StandardIndustryClassification, - LastUpdatedInCAS = eventData.LastUpdatedInCAS, - CorrelationId = eventData.CorrelationId, - CorrelationProvider = eventData.CorrelationProvider, - }; - } - - private static CreateSupplierDto GetCreateSupplierDtoFromEvent(UpsertSupplierEto eventData) - { - return new CreateSupplierDto() - { - Name = eventData.Name, - Number = eventData.Number, - Subcategory = eventData.Subcategory, - ProviderId = eventData.ProviderId, - BusinessNumber = eventData.BusinessNumber, - Status = eventData.Status, - SupplierProtected = eventData.SupplierProtected, - StandardIndustryClassification = eventData.StandardIndustryClassification, - LastUpdatedInCAS = eventData.LastUpdatedInCAS, - CorrelationId = eventData.CorrelationId, - CorrelationProvider = eventData.CorrelationProvider, - }; - } - } -} + + // This is subject to some business rules and a domain implementation + if (existing != null) + { + existing.Number = eventData.Number; + UpdateSupplierDto updateSupplierDto = GetUpdateSupplierDtoFromEvent(eventData); + SupplierDto updatedSupplierDto = await supplierAppService.UpdateAsync(existing.Id, updateSupplierDto); + return updatedSupplierDto; + } + + CreateSupplierDto createSupplierDto = GetCreateSupplierDtoFromEvent(eventData); + SupplierDto supplierDto = await supplierAppService.CreateAsync(createSupplierDto); + + return supplierDto; + } + + + private static UpdateSupplierDto GetUpdateSupplierDtoFromEvent(UpsertSupplierEto eventData) + { + return new UpdateSupplierDto() + { + Name = eventData.Name, + Number = eventData.Number, + Subcategory = eventData.Subcategory, + ProviderId = eventData.ProviderId, + BusinessNumber = eventData.BusinessNumber, + Status = eventData.Status, + SupplierProtected = eventData.SupplierProtected, + StandardIndustryClassification = eventData.StandardIndustryClassification, + LastUpdatedInCAS = eventData.LastUpdatedInCAS, + CorrelationId = eventData.CorrelationId, + CorrelationProvider = eventData.CorrelationProvider, + }; + } + + private static CreateSupplierDto GetCreateSupplierDtoFromEvent(UpsertSupplierEto eventData) + { + return new CreateSupplierDto() + { + Name = eventData.Name, + Number = eventData.Number, + Subcategory = eventData.Subcategory, + ProviderId = eventData.ProviderId, + BusinessNumber = eventData.BusinessNumber, + Status = eventData.Status, + SupplierProtected = eventData.SupplierProtected, + StandardIndustryClassification = eventData.StandardIndustryClassification, + LastUpdatedInCAS = eventData.LastUpdatedInCAS, + CorrelationId = eventData.CorrelationId, + CorrelationProvider = eventData.CorrelationProvider, + }; + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SiteAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SiteAppService.cs index b9286fee55..eff8091e93 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SiteAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SiteAppService.cs @@ -5,6 +5,7 @@ using Volo.Abp.Features; using Microsoft.Extensions.Logging; using Volo.Abp; +using System.Linq; namespace Unity.Payments.Suppliers { @@ -40,6 +41,18 @@ public virtual async Task InsertAsync(SiteDto siteDto) { try { + // Guard against duplicates — if a site with the same Number already exists + // for this supplier, update it instead of creating a second row + var existing = (await siteRepository.GetBySupplierAsync(siteDto.SupplierId)) + .Find(s => s.Number == siteDto.Number); + + if (existing != null) + { + logger.LogWarning("Site with Number {Number} already exists for SupplierId {SupplierId}. Updating instead of inserting.", siteDto.Number, siteDto.SupplierId); + siteDto.Id = existing.Id; + return await UpdateAsync(siteDto); + } + Site site = new Site(siteDto); await siteRepository.InsertAsync(site, true); return site.Id; diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/SupplierInfo/SupplierInfo.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/SupplierInfo/SupplierInfo.js index 6e9b9346f5..b4a2d9f208 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/SupplierInfo/SupplierInfo.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/SupplierInfo/SupplierInfo.js @@ -212,6 +212,9 @@ $(function () { return; } + const $btn = $(this); + $btn.attr('disabled', 'disabled'); + const applicantId = $('#PaymentInfo_ApplicantId').val(); const applicationId = $('#PaymentInfoViewApplicationId').val() || ''; $.ajax({ @@ -222,6 +225,9 @@ $(function () { console.error('Error loading sites:', error); abp.notify.error('Failed to refresh sites'); }, + complete: function () { + $btn.removeAttr('disabled'); + }, }); }); } From 1a5eacb012e18538e5fcf91c4f764465826ce21a Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Mon, 6 Apr 2026 16:13:40 -0700 Subject: [PATCH 09/10] AB#32538: Fix sonarqube issues --- .../src/Unity.Payments.Application/Suppliers/SiteAppService.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SiteAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SiteAppService.cs index eff8091e93..a3cca3fa53 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SiteAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SiteAppService.cs @@ -5,7 +5,6 @@ using Volo.Abp.Features; using Microsoft.Extensions.Logging; using Volo.Abp; -using System.Linq; namespace Unity.Payments.Suppliers { From 05a1981d60d10e6773c6d5a0d46afe2f9b368b7a Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Tue, 7 Apr 2026 11:24:01 -0700 Subject: [PATCH 10/10] AB#32455 update scripts as recommended via copilot --- .../scripts/ApplicantElectoralUpdate.md | 64 +++++++++++++++++++ .../Generate-ElectoralDistrictFixes.ps1 | 16 ++++- .../scripts/GetElectoralDistrictData.sql | 12 ++++ .../scripts/Validate-ElectoralDistricts.ps1 | 17 +++-- 4 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 applications/Unity.GrantManager/scripts/ApplicantElectoralUpdate.md create mode 100644 applications/Unity.GrantManager/scripts/GetElectoralDistrictData.sql diff --git a/applications/Unity.GrantManager/scripts/ApplicantElectoralUpdate.md b/applications/Unity.GrantManager/scripts/ApplicantElectoralUpdate.md new file mode 100644 index 0000000000..f9218514d9 --- /dev/null +++ b/applications/Unity.GrantManager/scripts/ApplicantElectoralUpdate.md @@ -0,0 +1,64 @@ +# Applicant Electoral District Update + +## Overview + +This process cross-references application electoral districts against the BC Geocoder API and generates SQL fix scripts for any mismatches. + +## Steps + +### 1. Extract Data from the Database + +Run `GetElectoralDistrictData.sql` against the database and save the results as a CSV file in the `data/` folder: + +``` +data/electoral_districts.csv +``` + +The query returns all applications joined with their applicant addresses, including `ApplicationId`, `ReferenceNo`, `ApplicantElectoralDistrict`, `Street`, `Street2`, `City`, and `AddressType`. + +### 2. Validate Electoral Districts + +Run `Validate-ElectoralDistricts.ps1` with the extracted CSV. This geocodes each unique address via the BC Geocoder API, looks up the electoral district from the WFS endpoint, and compares it to the value stored in the database. + +```powershell +.\Validate-ElectoralDistricts.ps1 ` + -InputCsv ".\data\electoral_districts.csv" ` + -GeocoderLocationBase "https://geocoder.api.gov.bc.ca" ` + -GeocoderApiBase "https://openmaps.gov.bc.ca/geo/pub/wfs?service=WFS&version=2.0.0&request=GetFeature&typeName=" +``` + +This produces a validated CSV with match results: + +``` +data/electoral_districts_validated.csv +``` + +Each row includes the expected electoral district, geocoder score, and a `DistrictMatch` column (`MATCH`, `MISMATCH`, `UNKNOWN`, or `SKIPPED`). + +### 3. Generate SQL Fix Scripts + +Run `Generate-ElectoralDistrictFixes.ps1` against the validated CSV. This produces SQL UPDATE statements for mismatched rows. + +```powershell +.\Generate-ElectoralDistrictFixes.ps1 ` + -InputCsv ".\data\electoral_districts_validated.csv" ` + -MinScore 70 ` + -AddressType 1 ` + -IncludeLowConfidence +``` + +This generates two SQL scripts: + +| File | Description | +|------|-------------| +| `electoral_districts_validated_update.sql` | High-confidence updates (score >= 70) — sets the district to the expected value | +| `electoral_districts_validated_nullify.sql` | Low-confidence updates (score < 70) — sets the district to NULL | + +Both scripts use conditional updates that only apply when the current database value still matches the value at extract time, preventing overwrites of legitimate changes. + +### 4. Apply the Fix Scripts + +Review the generated SQL files, then execute them against the database: + +1. **Always apply the high-confidence script first** (`_update.sql`). +2. **Optionally apply the low-confidence script** (`_nullify.sql`) if you want to clear unreliable district values. diff --git a/applications/Unity.GrantManager/scripts/Generate-ElectoralDistrictFixes.ps1 b/applications/Unity.GrantManager/scripts/Generate-ElectoralDistrictFixes.ps1 index 79abc7f5db..4dbe27756c 100644 --- a/applications/Unity.GrantManager/scripts/Generate-ElectoralDistrictFixes.ps1 +++ b/applications/Unity.GrantManager/scripts/Generate-ElectoralDistrictFixes.ps1 @@ -111,13 +111,19 @@ if ($highConfidence.Count -gt 0) { foreach ($row in $highConfidence) { $appId = $row.ApplicationId.Trim() + $parsedGuid = [System.Guid]::Empty + if (-not [System.Guid]::TryParse($appId, [ref]$parsedGuid)) { + Write-Host " SKIPPED: Invalid ApplicationId '$appId' (ReferenceNo: $($row.ReferenceNo))" -ForegroundColor Red + continue + } + $appId = $parsedGuid.ToString() $currentED = Escape-SqlString $row.ApplicantElectoralDistrict.Trim() $expectedED = Escape-SqlString $row.ExpectedElectoralDistrict.Trim() $score = $row.GeocoderScore $refNo = $row.ReferenceNo [void]$sb.AppendLine("-- ReferenceNo: $refNo | Score: $score | '$currentED' -> '$expectedED'") - [void]$sb.AppendLine("UPDATE ""Applications"" SET ""ApplicantElectoralDistrict"" = '$expectedED' WHERE ""Id"" = '$appId';") + [void]$sb.AppendLine("UPDATE ""Applications"" SET ""ApplicantElectoralDistrict"" = '$expectedED' WHERE ""Id"" = '$appId' AND ""ApplicantElectoralDistrict"" = '$currentED';") [void]$sb.AppendLine("") } @@ -144,12 +150,18 @@ if ($IncludeLowConfidence -and $lowConfidence.Count -gt 0) { foreach ($row in $lowConfidence) { $appId = $row.ApplicationId.Trim() + $parsedGuid = [System.Guid]::Empty + if (-not [System.Guid]::TryParse($appId, [ref]$parsedGuid)) { + Write-Host " SKIPPED: Invalid ApplicationId '$appId' (ReferenceNo: $($row.ReferenceNo))" -ForegroundColor Red + continue + } + $appId = $parsedGuid.ToString() $currentED = Escape-SqlString $row.ApplicantElectoralDistrict.Trim() $score = $row.GeocoderScore $refNo = $row.ReferenceNo [void]$sbNull.AppendLine("-- ReferenceNo: $refNo | Score: $score | '$currentED' -> NULL") - [void]$sbNull.AppendLine("UPDATE ""Applications"" SET ""ApplicantElectoralDistrict"" = NULL WHERE ""Id"" = '$appId';") + [void]$sbNull.AppendLine("UPDATE ""Applications"" SET ""ApplicantElectoralDistrict"" = NULL WHERE ""Id"" = '$appId' AND ""ApplicantElectoralDistrict"" = '$currentED';") [void]$sbNull.AppendLine("") } diff --git a/applications/Unity.GrantManager/scripts/GetElectoralDistrictData.sql b/applications/Unity.GrantManager/scripts/GetElectoralDistrictData.sql new file mode 100644 index 0000000000..1fa5aa3a0b --- /dev/null +++ b/applications/Unity.GrantManager/scripts/GetElectoralDistrictData.sql @@ -0,0 +1,12 @@ +SELECT ap."CreationTime", +ap."Id" as "ApplicationId", +ap."ReferenceNo", +ap."ApplicantElectoralDistrict", +ad."Id" as "AddressId", +ad."Street", +ad."Street2", +ad."City", +ad."AddressType" +FROM public."Applications" ap +LEFT JOIN "ApplicantAddresses" ad on ad."ApplicationId" = ap."Id" +ORDER BY ap."CreationTime" DESC \ No newline at end of file diff --git a/applications/Unity.GrantManager/scripts/Validate-ElectoralDistricts.ps1 b/applications/Unity.GrantManager/scripts/Validate-ElectoralDistricts.ps1 index 2d62ec963a..5889bf4c2a 100644 --- a/applications/Unity.GrantManager/scripts/Validate-ElectoralDistricts.ps1 +++ b/applications/Unity.GrantManager/scripts/Validate-ElectoralDistricts.ps1 @@ -44,7 +44,7 @@ param( [Parameter(Mandatory)] [string]$GeocoderApiBase, - [int]$InitialDelayMs = 250 + [int]$InitialDelayMs = 100 ) Set-StrictMode -Version Latest @@ -158,9 +158,8 @@ function Invoke-GeocoderRequest { if (($statusCode -eq 429 -or $statusCode -eq 503) -and $attempt -le $MaxRetries) { # Exponential backoff $script:currentDelayMs = [Math]::Min($maxDelayMs, $script:currentDelayMs * 2) - $backoffMs = $script:currentDelayMs * $attempt - Write-Host " Rate limited (HTTP $statusCode). Backing off ${backoffMs}ms (attempt $attempt/$MaxRetries)..." -ForegroundColor Yellow - Start-Sleep -Milliseconds $backoffMs + Write-Host " Rate limited (HTTP $statusCode). Backing off $($script:currentDelayMs)ms (attempt $attempt/$MaxRetries)..." -ForegroundColor Yellow + Start-Sleep -Milliseconds $script:currentDelayMs } else { throw @@ -201,8 +200,8 @@ foreach ($entry in $addressLookup.GetEnumerator()) { } $coords = $locationResult.features[0].geometry.coordinates - $latitude = $coords[0] # Mirrors C# ResultMapper: coordinates[0] → Latitude - $longitude = $coords[1] # Mirrors C# ResultMapper: coordinates[1] → Longitude + $coordX = $coords[0] # EPSG:3005 easting (mirrors C# ResultMapper: coordinates[0]) + $coordY = $coords[1] # EPSG:3005 northing (mirrors C# ResultMapper: coordinates[1]) $score = $locationResult.features[0].properties.score $fullAddr = $locationResult.features[0].properties.fullAddress @@ -212,10 +211,10 @@ foreach ($entry in $addressLookup.GetEnumerator()) { # Step 2: Look up electoral district from coordinates $edUri = "${GeocoderApiBase}${edFeature}" + - "&srsname=EPSG:4326" + + "&srsname=EPSG:3005" + "&propertyName=${edProperty}" + "&outputFormat=application/json" + - "&cql_filter=INTERSECTS(${edQueryType},POINT($latitude $longitude))" + "&cql_filter=INTERSECTS(${edQueryType},POINT($coordX $coordY))" Start-Sleep -Milliseconds $script:currentDelayMs $edResult = Invoke-GeocoderRequest -Uri $edUri @@ -223,7 +222,7 @@ foreach ($entry in $addressLookup.GetEnumerator()) { if (-not $edResult.features -or $edResult.features.Count -eq 0) { Write-Host " No electoral district found for coordinates" -ForegroundColor Yellow $addr.ExpectedED = "" - $addr.Error = "No electoral district for coordinates ($latitude, $longitude)" + $addr.Error = "No electoral district for coordinates ($coordX, $coordY)" $errorCount++ continue }