diff --git a/client/wfprev-war/src/main/angular/src/app/components/list-panel/projects-list/projects-list.component.spec.ts b/client/wfprev-war/src/main/angular/src/app/components/list-panel/projects-list/projects-list.component.spec.ts index af7e3daa6..f86245c57 100644 --- a/client/wfprev-war/src/main/angular/src/app/components/list-panel/projects-list/projects-list.component.spec.ts +++ b/client/wfprev-war/src/main/angular/src/app/components/list-panel/projects-list/projects-list.component.spec.ts @@ -35,19 +35,19 @@ describe('ProjectsListComponent', () => { }; const mockProjectList = [ - { - projectNumber: 1, - projectName: 'Project 1', - forestRegionOrgUnitId: 101, + { + projectNumber: 1, + projectName: 'Project 1', + forestRegionOrgUnitId: 101, totalPlannedProjectSizeHa: 100, latitude: 49.2827, longitude: -123.1207, projectGuid: 'guid1' }, - { - projectNumber: 2, - projectName: 'Project 2', - forestRegionOrgUnitId: 102, + { + projectNumber: 2, + projectName: 'Project 2', + forestRegionOrgUnitId: 102, totalPlannedProjectSizeHa: 200, latitude: 49.2849, longitude: -123.1217, @@ -61,7 +61,7 @@ describe('ProjectsListComponent', () => { let mockRouter: jasmine.SpyObj; beforeEach(async () => { - mockProjectService = jasmine.createSpyObj('ProjectService', ['fetchProjects', 'getFeatures']); + mockProjectService = jasmine.createSpyObj('ProjectService', ['fetchProjects', 'getFeatures', 'downloadProjects']); mockProjectService.fetchProjects.and.returnValue(of({ _embedded: { project: mockProjectList, @@ -109,7 +109,7 @@ describe('ProjectsListComponent', () => { spyOn(L, 'markerClusterGroup').and.returnValue(mockMarkerClusterGroup); spyOn(L, 'marker').and.returnValue(mockMarker); spyOn(L, 'polygon').and.returnValue(mockPolygon); - + await TestBed.configureTestingModule({ imports: [ ProjectsListComponent, @@ -137,15 +137,15 @@ describe('ProjectsListComponent', () => { expect(component).toBeTruthy(); }); - it('should render the correct number of projects', () => { - component.displayedProjects = mockProjectList; - fixture.detectChanges(); + it('should render the correct number of projects', () => { + component.displayedProjects = mockProjectList; + fixture.detectChanges(); - const projectItems = fixture.debugElement.queryAll(By.css('.project-name')); - expect(projectItems.length).toBe(2); - expect(projectItems[0].nativeElement.textContent).toContain('Project 1'); - expect(projectItems[1].nativeElement.textContent).toContain('Project 2'); -}); + const projectItems = fixture.debugElement.queryAll(By.css('.project-name')); + expect(projectItems.length).toBe(2); + expect(projectItems[0].nativeElement.textContent).toContain('Project 1'); + expect(projectItems[1].nativeElement.textContent).toContain('Project 2'); + }); it('should load code tables on init', () => { @@ -167,14 +167,14 @@ describe('ProjectsListComponent', () => { mockProjectService.fetchProjects.and.returnValue(throwError(() => new Error('Error fetching projects'))); component.loadProjects(); fixture.detectChanges(); - expect(component.displayedProjects ).toEqual([]); + expect(component.displayedProjects).toEqual([]); }); it('should open the dialog to create a new project and reload projects if successful', () => { spyOn(component, 'loadProjects'); - + component.createNewProject(); - + expect(mockDialog.open).toHaveBeenCalledWith( CreateNewProjectDialogComponent, { @@ -222,7 +222,7 @@ describe('ProjectsListComponent', () => { // Map Function Tests describe('loadCoordinatesOnMap', () => { beforeEach(() => { - component.displayedProjects = mockProjectList; + component.displayedProjects = mockProjectList; mockMarker.getLatLng.and.returnValue({ lat: 49.2827, lng: -123.1207 }); }); @@ -236,9 +236,9 @@ describe('ProjectsListComponent', () => { })); it('should filter out projects with null coordinates', fakeAsync(() => { - component.displayedProjects = [ + component.displayedProjects = [ ...mockProjectList, - { + { projectNumber: 3, projectName: 'Project 3', latitude: null, @@ -246,7 +246,7 @@ describe('ProjectsListComponent', () => { projectGuid: 'guid-z' } ]; - + component.loadCoordinatesOnMap(); tick(); @@ -254,7 +254,7 @@ describe('ProjectsListComponent', () => { })); it('should not create markers when projectList is empty', fakeAsync(() => { - component.displayedProjects = []; + component.displayedProjects = []; component.loadCoordinatesOnMap(); tick(); @@ -281,7 +281,7 @@ describe('ProjectsListComponent', () => { it('should reset previously active marker', () => { const oldMarker = jasmine.createSpyObj('marker', ['setIcon']); component.activeMarker = oldMarker; - + const project = { latitude: 49.2827, longitude: -123.1207 }; component.highlightProjectPolygons(project); @@ -315,9 +315,9 @@ describe('ProjectsListComponent', () => { mockDialog.open.and.returnValue({ afterClosed: () => of({ success: true, projectGuid: 'new-guid' }) } as any); - + component.createNewProject(); - + expect(mockDialog.open).toHaveBeenCalledWith( CreateNewProjectDialogComponent, { @@ -326,21 +326,21 @@ describe('ProjectsListComponent', () => { hasBackdrop: true, } ); - + expect(mockRouter.navigate).toHaveBeenCalledWith( [ResourcesRoutes.EDIT_PROJECT], { queryParams: { projectGuid: 'new-guid' } } ); }); - + it('should reload projects if no projectGuid is returned after project creation', () => { spyOn(component, 'loadProjects'); mockDialog.open.and.returnValue({ afterClosed: () => of({ success: true }) } as any); - + component.createNewProject(); - + expect(component.loadProjects).toHaveBeenCalled(); }); @@ -390,7 +390,7 @@ describe('ProjectsListComponent', () => { const result = component.getDescription('planFiscalStatusCode', 'PS1'); expect(result).toBe('Planned'); }); - + it('should return activity category description from code table', () => { component.activityCategoryCode = [{ activityCategoryCode: 'AC1', description: 'Clearing' }]; const result = component.getDescription('activityCategoryCode', 'AC1'); @@ -468,7 +468,7 @@ describe('ProjectsListComponent', () => { expect(component.allProjects.length).toBe(2); expect(component.allProjects[0].projectName).toBe('A Project'); - expect(component.displayedProjects.length).toBe(2); + expect(component.displayedProjects.length).toBe(2); expect(component.displayedProjects[0].projectName).toBe('A Project'); expect(component.isLoading).toBeFalse(); expect(component.sharedService.updateDisplayedProjects).toHaveBeenCalledWith(component.displayedProjects); @@ -670,7 +670,7 @@ describe('ProjectsListComponent', () => { mockClickedMarker.getLatLng.and.returnValue({ lat: 49.2827, lng: -123.1207 }); (L.marker as jasmine.Spy).and.returnValue(mockClickedMarker); - + (L.polygon as jasmine.Spy).and.callFake(() => { return [mockPolygon1, mockPolygon2][(L.polygon as jasmine.Spy).calls.count() - 1] || mockPolygon1; }); @@ -722,7 +722,7 @@ describe('ProjectsListComponent', () => { cb(); // simulate map click } }, - addLayer: () => {} + addLayer: () => { } } } }); @@ -861,16 +861,27 @@ describe('ProjectsListComponent', () => { }); it('should show progress, trigger download, and show success message', fakeAsync(() => { + component.displayedProjects = [ + { + projectGuid: 'guid1', + projectFiscals: [ + { fiscalYear: 2023, projectPlanFiscalGuid: 'pf-1a' }, + { fiscalYear: 2022, projectPlanFiscalGuid: 'pf-1b' }, + ], + }, + { + projectGuid: 'guid2', + projectFiscals: [], + }, + ] as any; + const mockBlob = new Blob(['test data'], { type: 'text/csv' }); spyOn(window.URL, 'createObjectURL').and.returnValue('blob:url'); spyOn(document, 'createElement').and.callThrough(); - const projectGuids = ['guid1', 'guid2']; - mockProjectService.downloadProjects = jasmine - .createSpy() - .and.returnValue(of(mockBlob)); + mockProjectService.downloadProjects.and.returnValue(of(mockBlob)); - component.downloadProjects(projectGuids, 'csv'); + component.onDownload('csv'); tick(); expect(mockSnackBar.open).toHaveBeenCalledWith( @@ -878,23 +889,32 @@ describe('ProjectsListComponent', () => { 'Close', jasmine.any(Object) ); - expect(mockSnackBar.open).toHaveBeenCalledWith( Messages.fileDownloadSuccess, 'Close', jasmine.any(Object) ); + const bodyArg = mockProjectService.downloadProjects.calls.mostRecent().args[0] as any; + expect(bodyArg.reportType).toBe('csv'); + expect(bodyArg.projects).toEqual(jasmine.arrayContaining([ + { projectGuid: 'guid1', projectFiscalGuids: ['pf-1a', 'pf-1b'] }, + { projectGuid: 'guid2' } + ])); + expect(window.URL.createObjectURL).toHaveBeenCalledWith(mockBlob); })); it('should show failure message when download fails', fakeAsync(() => { - mockProjectService.downloadProjects = jasmine - .createSpy() - .and.returnValue(throwError(() => new Error('Download failed'))); + component.displayedProjects = [ + { projectGuid: 'guid1', projectFiscals: [] } + ] as any; - component.downloadProjects(['guid1'], 'excel'); + mockProjectService.downloadProjects.and.returnValue( + throwError(() => new Error('Download failed')) + ); + component.onDownload('csv'); tick(); expect(mockSnackBar.open).toHaveBeenCalledWith( @@ -902,28 +922,184 @@ describe('ProjectsListComponent', () => { 'Close', jasmine.any(Object) ); - expect(mockSnackBar.open).toHaveBeenCalledWith( Messages.fileDownloadFailure, 'Close', jasmine.any(Object) ); + + const bodyArg = mockProjectService.downloadProjects.calls.mostRecent().args[0] as any; + expect(bodyArg.reportType).toBe('csv'); + expect(bodyArg.projects).toEqual([{ projectGuid: 'guid1' }]); })); it('onDownload should call downloadProjects with correct params', () => { - spyOn(component, 'downloadProjects'); component.displayedProjects = [ - { projectGuid: 'guid1' }, - { projectGuid: 'guid2' } + { projectGuid: 'guid1', projectFiscals: [] }, + { projectGuid: 'guid2', projectFiscals: [] } ]; + mockProjectService.downloadProjects.and.returnValue(of(new Blob())); + component.onDownload('csv'); - expect(component.downloadProjects).toHaveBeenCalledWith( - ['guid1', 'guid2'], - 'csv' - ); + const bodyArg = mockProjectService.downloadProjects.calls.mostRecent().args[0] as any; + + expect(bodyArg.reportType).toBe('csv'); + expect(bodyArg.projects).toEqual([ + { projectGuid: 'guid1' }, + { projectGuid: 'guid2' } + ]); + }); + }); + + describe('getDisplayedFiscalYears', () => { + beforeEach(() => { + component.resultCount = 2; + }); + + it('returns the top N fiscal years in descending order (numbers only)', () => { + const project = { + projectFiscals: [ + { fiscalYear: 2021 }, + { fiscalYear: 2023 }, + { fiscalYear: 2022 }, + { fiscalYear: 'not-a-number' }, + { fiscalYear: null }, + ], + }; + + const years = component.getDisplayedFiscalYears(project); + expect(years).toEqual([2023, 2022]); + }); + + it('returns empty array when there are no valid fiscal years', () => { + const project = { + projectFiscals: [{ fiscalYear: undefined }, { fiscalYear: 'x' }], + }; + expect(component.getDisplayedFiscalYears(project)).toEqual([]); + }); + + it('handles missing projectFiscals gracefully', () => { + expect(component.getDisplayedFiscalYears({} as any)).toEqual([]); + expect(component.getDisplayedFiscalYears(null as any)).toEqual([]); + }); + }); + + describe('getDisplayedProjectFiscalGuids', () => { + beforeEach(() => { + component.resultCount = 2; + }); + + it('returns guids only for displayed years and only when guid exists', () => { + const project = { + projectFiscals: [ + { fiscalYear: 2023, projectPlanFiscalGuid: 'pf-a' }, // included (top 2) + { fiscalYear: 2022, projectPlanFiscalGuid: 'pf-b' }, // included (top 2) + { fiscalYear: 2021, projectPlanFiscalGuid: 'pf-c' }, // NOT included (not in top 2) + { fiscalYear: 2020 }, // no guid -> ignored + ], + }; + + const guids = component.getDisplayedProjectFiscalGuids(project); + expect(guids).toEqual(['pf-a', 'pf-b']); + }); + + it('returns empty array when there are no matching displayed years', () => { + component.resultCount = 0; // show 0 years + const project = { + projectFiscals: [ + { fiscalYear: 2023, projectPlanFiscalGuid: 'pf-a' }, + ], + }; + expect(component.getDisplayedProjectFiscalGuids(project)).toEqual([]); + }); + + it('handles missing projectFiscals', () => { + expect(component.getDisplayedProjectFiscalGuids({} as any)).toEqual([]); + expect(component.getDisplayedProjectFiscalGuids(null as any)).toEqual([]); + }); + }); + + describe('buildProjectsPayloadFromDisplayed', () => { + beforeEach(() => { + component.resultCount = 3; + }); + + it('coalesces by projectGuid and unions displayed fiscal guids', () => { + component.displayedProjects = [ + { + projectGuid: 'guid1', + projectFiscals: [ + { fiscalYear: 2023, projectPlanFiscalGuid: 'pf-1a' }, + { fiscalYear: 2022, projectPlanFiscalGuid: 'pf-1b' }, + ], + }, + { + projectGuid: 'guid1', + projectFiscals: [ + { fiscalYear: 2022, projectPlanFiscalGuid: 'pf-1b' }, + { fiscalYear: 2021, projectPlanFiscalGuid: 'pf-1c' }, + ], + }, + { + projectGuid: 'guid2', + projectFiscals: [], + }, + { + projectFiscals: [{ fiscalYear: 2024, projectPlanFiscalGuid: 'pf-x' }], + }, + ] as any; + + const payload = component.buildProjectsPayloadFromDisplayed(); + + expect(payload).toEqual(jasmine.arrayContaining([ + { + projectGuid: 'guid1', + projectFiscalGuids: jasmine.arrayContaining(['pf-1a', 'pf-1b', 'pf-1c']), + }, + { projectGuid: 'guid2' }, + ])); + + const guid1 = payload.find(p => p.projectGuid === 'guid1')!; + const unique = new Set(guid1.projectFiscalGuids); + expect(unique.size).toBe(guid1.projectFiscalGuids!.length); + }); + + it('omits projectFiscalGuids key when none are displayed for that project', () => { + component.displayedProjects = [ + { projectGuid: 'g1', projectFiscals: [] }, + { projectGuid: 'g2' }, + ] as any; + + const payload = component.buildProjectsPayloadFromDisplayed(); + + expect(payload).toEqual(jasmine.arrayContaining([ + { projectGuid: 'g1' }, + { projectGuid: 'g2' }, + ])); + + expect((payload.find(p => p.projectGuid === 'g1') as any).projectFiscalGuids).toBeUndefined(); + expect((payload.find(p => p.projectGuid === 'g2') as any).projectFiscalGuids).toBeUndefined(); + }); + + it('respects resultCount when deciding which fiscals are included', () => { + component.resultCount = 1; + component.displayedProjects = [ + { + projectGuid: 'g1', + projectFiscals: [ + { fiscalYear: 2023, projectPlanFiscalGuid: 'top' }, + { fiscalYear: 2022, projectPlanFiscalGuid: 'lower' }, + ], + }, + ] as any; + + const payload = component.buildProjectsPayloadFromDisplayed(); + expect(payload).toEqual([ + { projectGuid: 'g1', projectFiscalGuids: ['top'] }, + ]); }); }); diff --git a/client/wfprev-war/src/main/angular/src/app/components/list-panel/projects-list/projects-list.component.ts b/client/wfprev-war/src/main/angular/src/app/components/list-panel/projects-list/projects-list.component.ts index 385d648dc..f8571af9b 100644 --- a/client/wfprev-war/src/main/angular/src/app/components/list-panel/projects-list/projects-list.component.ts +++ b/client/wfprev-war/src/main/angular/src/app/components/list-panel/projects-list/projects-list.component.ts @@ -21,6 +21,7 @@ import { StatusBadgeComponent } from 'src/app/components/shared/status-badge/sta import { CodeTableKeys, CodeTableNames, DownloadFileExtensions, DownloadTypes, Messages, WildfireOrgUnitTypeCodes } from 'src/app/utils/constants'; import { DownloadButtonComponent } from 'src/app/components/shared/download-button/download-button.component'; import { MatSnackBar } from '@angular/material/snack-bar'; +import { ReportRequest } from '../../models'; @Component({ @@ -522,44 +523,74 @@ export class ProjectsListComponent implements OnInit { return PlanFiscalStatusIcons[status]; } - onDownload(type: string): void { - const projectGuids = this.displayedProjects.map(p => p.projectGuid); - this.downloadProjects(projectGuids, type); - } - downloadProjects(projectGuids: string[], type: string): void { - const snackRef = this.snackbarService.open(Messages.fileDownloadInProgress, 'Close', { - duration: undefined, - panelClass: 'snackbar-info' - }); +getDisplayedFiscalYears(project: any): number[] { + const fiscalsDesc = this.getSortedProjectFiscalsDesc(project); + const shown = fiscalsDesc.slice(0, this.resultCount); + return shown + .map((f: any) => f?.fiscalYear) + .filter((y: any): y is number => typeof y === 'number'); +} - this.projectService.downloadProjects(projectGuids, type).subscribe({ - next: (blob) => { - snackRef.dismiss(); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - const extension = type === DownloadTypes.EXCEL - ? DownloadFileExtensions.EXCEL - : DownloadFileExtensions.CSV; - a.download = `projects.${extension}`; - a.href = url; - a.click(); - window.URL.revokeObjectURL(url); - - this.snackbarService.open(Messages.fileDownloadSuccess, 'Close', { - duration: 5000, - panelClass: 'snackbar-success' - }); - }, - error: (err) => { - snackRef.dismiss(); - console.error('Download failed', err); - this.snackbarService.open(Messages.fileDownloadFailure, 'Close', { - duration: 5000, - panelClass: 'snackbar-error' - }); - } - }); +getDisplayedProjectFiscalGuids(project: any): string[] { + const wantedYears = new Set(this.getDisplayedFiscalYears(project)); + return (project?.projectFiscals ?? []) + .filter((f: any) => wantedYears.has(f.fiscalYear) && !!f.projectPlanFiscalGuid) + .map((f: any) => f.projectPlanFiscalGuid as string); +} + +// Build request.projects from displayedProjects, coalescing by projectGuid +buildProjectsPayloadFromDisplayed(): { projectGuid: string; projectFiscalGuids?: string[] }[] { + const byProject = new Map>(); + + for (const p of this.displayedProjects) { + const guid = p.projectGuid as string; + if (!guid) continue; + + const fiscals = this.getDisplayedProjectFiscalGuids(p); + if (!byProject.has(guid)) byProject.set(guid, new Set()); + + // If there are displayed fiscals, union them; if not, we’ll send just the projectGuid later. + for (const fg of fiscals) byProject.get(guid)!.add(fg); + } + + // Convert to payload list: include projectFiscalGuids only when we actually have some + const payload: { projectGuid: string; projectFiscalGuids?: string[] }[] = []; + for (const [guid, set] of byProject.entries()) { + const list = Array.from(set); + payload.push(list.length > 0 ? { projectGuid: guid, projectFiscalGuids: list } : { projectGuid: guid }); } + return payload; +} +onDownload(type: string): void { + const body: ReportRequest = { + reportType: type === DownloadTypes.EXCEL ? 'xlsx' : 'csv', + projects: this.buildProjectsPayloadFromDisplayed() + }; + + const snackRef = this.snackbarService.open(Messages.fileDownloadInProgress, 'Close', { + duration: undefined, + panelClass: 'snackbar-info' + }); + + this.projectService.downloadProjects(body).subscribe({ + next: (blob) => { + snackRef.dismiss(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + const ext = body.reportType === 'xlsx' ? DownloadFileExtensions.EXCEL : DownloadFileExtensions.CSV; + a.download = `projects.${ext}`; + a.href = url; + a.click(); + window.URL.revokeObjectURL(url); + this.snackbarService.open(Messages.fileDownloadSuccess, 'Close', { duration: 5000, panelClass: 'snackbar-success' }); + }, + error: (err) => { + snackRef.dismiss(); + console.error('Download failed', err); + this.snackbarService.open(Messages.fileDownloadFailure, 'Close', { duration: 5000, panelClass: 'snackbar-error' }); + } + }); +} } diff --git a/client/wfprev-war/src/main/angular/src/app/components/models.ts b/client/wfprev-war/src/main/angular/src/app/components/models.ts index cbcd10775..b4279fb81 100644 --- a/client/wfprev-war/src/main/angular/src/app/components/models.ts +++ b/client/wfprev-war/src/main/angular/src/app/components/models.ts @@ -334,3 +334,15 @@ export interface LayerSettings { wfnewsApiKey: string; openmaps:string; } + +export type ReportType = 'csv' | 'xlsx'; + +export interface ReportProject { + projectGuid: string; + projectFiscalGuids?: string[]; +} + +export interface ReportRequest { + reportType: ReportType; + projects: ReportProject[]; +} diff --git a/client/wfprev-war/src/main/angular/src/app/services/project-services.spec.ts b/client/wfprev-war/src/main/angular/src/app/services/project-services.spec.ts index 344b9c2e5..e18a23837 100644 --- a/client/wfprev-war/src/main/angular/src/app/services/project-services.spec.ts +++ b/client/wfprev-war/src/main/angular/src/app/services/project-services.spec.ts @@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { AppConfigService } from 'src/app/services/app-config.service'; import { TokenService } from 'src/app/services/token.service'; -import { EvaluationCriteriaSummaryModel, FeaturesResponse, Project, ProjectBoundary, ProjectFiscal } from 'src/app/components/models'; +import { EvaluationCriteriaSummaryModel, FeaturesResponse, Project, ProjectBoundary, ProjectFiscal, ReportRequest } from 'src/app/components/models'; import { ProjectService } from 'src/app/services/project-services'; import { HttpEventType } from '@angular/common/http'; import { of } from 'rxjs'; @@ -25,9 +25,9 @@ describe('ProjectService', () => { localStorageTokenKey: 'oauth', allowLocalExpiredToken: false, baseUrl: 'http://mock-base-url.com', - acronym: 'TEST', - version: '1.0.0', - environment: 'test', + acronym: 'TEST', + version: '1.0.0', + environment: 'test', }, webade: { oauth2Url: 'http://mock-oauth-url.com', @@ -53,41 +53,41 @@ describe('ProjectService', () => { boundaryGeometry: { type: "MultiPolygon", coordinates: [ - [ - [ + [ + [ [-124, 49], [-125, 50], [-126, 49], - [-124, 49], + [-124, 49], ] ] - ] as Position[][][] + ] as Position[][][] }, locationGeometry: [-124, 49], }; - const projectGuid = '12345'; - const projectPlanFiscalGuid = 'fiscal-6789'; - - const updatedFiscal: ProjectFiscal = { - projectGuid, - projectPlanFiscalGuid, - fiscalYear: 2024, - projectFiscalName: 'Updated Fiscal', - activityCategoryCode: 'RX_DEV', - planFiscalStatusCode: {planFiscalStatusCode:'COMPLETE'}, - projectPlanStatusCode: 'ACTIVE', - proposalTypeCode: 'NEW', - isApprovedInd: false, - isDelayedInd: false, - totalCostEstimateAmount: 0, - fiscalPlannedProjectSizeHa: 0, - fiscalPlannedCostPerHaAmt: 0, - fiscalReportedSpendAmount: 0, - fiscalActualAmount: 0, - fiscalActualCostPerHaAmt: 0, - firstNationsDelivPartInd: false, - firstNationsEngagementInd: false - }; + const projectGuid = '12345'; + const projectPlanFiscalGuid = 'fiscal-6789'; + + const updatedFiscal: ProjectFiscal = { + projectGuid, + projectPlanFiscalGuid, + fiscalYear: 2024, + projectFiscalName: 'Updated Fiscal', + activityCategoryCode: 'RX_DEV', + planFiscalStatusCode: { planFiscalStatusCode: 'COMPLETE' }, + projectPlanStatusCode: 'ACTIVE', + proposalTypeCode: 'NEW', + isApprovedInd: false, + isDelayedInd: false, + totalCostEstimateAmount: 0, + fiscalPlannedProjectSizeHa: 0, + fiscalPlannedCostPerHaAmt: 0, + fiscalReportedSpendAmount: 0, + fiscalActualAmount: 0, + fiscalActualCostPerHaAmt: 0, + firstNationsDelivPartInd: false, + firstNationsEngagementInd: false + }; beforeEach(() => { @@ -585,28 +585,28 @@ describe('ProjectService', () => { it('should delete a project boundary', () => { const projectGuid = 'project-123'; const projectBoundaryGuid = 'boundary-456'; - + service.deleteProjectBoundary(projectGuid, projectBoundaryGuid).subscribe((response) => { - expect(response).toBeTruthy(); + expect(response).toBeTruthy(); }); - + const req = httpMock.expectOne(`http://mock-api.com/wfprev-api/projects/${projectGuid}/projectBoundary/${projectBoundaryGuid}`); expect(req.request.method).toBe('DELETE'); expect(req.request.headers.get('Authorization')).toBe('Bearer mock-token'); - req.flush({}); + req.flush({}); }); - + it('should handle errors when deleting a project boundary', () => { const projectGuid = 'project-123'; const projectBoundaryGuid = 'boundary-456'; - + service.deleteProjectBoundary(projectGuid, projectBoundaryGuid).subscribe({ next: () => fail('Should have failed with an error'), error: (error) => { expect(error.message).toBe('Failed to delete project boundary'); } }); - + const req = httpMock.expectOne(`http://mock-api.com/wfprev-api/projects/${projectGuid}/projectBoundary/${projectBoundaryGuid}`); req.flush('Error', { status: 500, statusText: 'Server Error' }); }); @@ -616,11 +616,11 @@ describe('ProjectService', () => { const projectPlanFiscalGuid = 'fiscal-6789'; const activityGuid = 'activity-001'; const mockBoundaries = { _embedded: { activityBoundary: [{ id: 'abc' }] } }; - + service.getActivityBoundaries(projectGuid, projectPlanFiscalGuid, activityGuid).subscribe((boundaries) => { expect(boundaries).toEqual(mockBoundaries); }); - + const req = httpMock.expectOne( `http://mock-api.com/wfprev-api/projects/${projectGuid}/projectFiscals/${projectPlanFiscalGuid}/activities/${activityGuid}/activityBoundary` ); @@ -633,14 +633,14 @@ describe('ProjectService', () => { const projectGuid = '12345'; const projectPlanFiscalGuid = 'fiscal-6789'; const activityGuid = 'activity-001'; - + service.getActivityBoundaries(projectGuid, projectPlanFiscalGuid, activityGuid).subscribe({ next: () => fail('Should have failed with an error'), error: (error) => { expect(error.message).toBe('Failed to fetch activity boundaries'); } }); - + const req = httpMock.expectOne( `http://mock-api.com/wfprev-api/projects/${projectGuid}/projectFiscals/${projectPlanFiscalGuid}/activities/${activityGuid}/activityBoundary` ); @@ -652,11 +652,11 @@ describe('ProjectService', () => { const fiscalGuid = 'fiscal-1'; const activityGuid = 'activity-1'; const mockBoundary = { boundarySizeHa: 12 }; - + service.createActivityBoundary(projectGuid, fiscalGuid, activityGuid, mockBoundary).subscribe(response => { expect(response).toEqual(mockBoundary); }); - + const req = httpMock.expectOne(`http://mock-api.com/wfprev-api/projects/${projectGuid}/projectFiscals/${fiscalGuid}/activities/${activityGuid}/activityBoundary`); expect(req.request.method).toBe('POST'); expect(req.request.body).toEqual(mockBoundary); @@ -668,14 +668,14 @@ describe('ProjectService', () => { const fiscalGuid = 'fiscal-1'; const activityGuid = 'activity-1'; const mockBoundary = { boundarySizeHa: 12 }; - + service.createActivityBoundary(projectGuid, fiscalGuid, activityGuid, mockBoundary).subscribe({ next: () => fail('Should have failed'), error: (err) => { expect(err.message).toBe('Failed to create activity boundary'); } }); - + const req = httpMock.expectOne(`http://mock-api.com/wfprev-api/projects/${projectGuid}/projectFiscals/${fiscalGuid}/activities/${activityGuid}/activityBoundary`); req.flush('Error', { status: 500, statusText: 'Server Error' }); }); @@ -685,11 +685,11 @@ describe('ProjectService', () => { const fiscalGuid = 'fiscal-1'; const activityGuid = 'activity-1'; const boundaryGuid = 'boundary-1'; - + service.deleteActivityBoundary(projectGuid, fiscalGuid, activityGuid, boundaryGuid).subscribe(response => { expect(response).toBeTruthy(); }); - + const req = httpMock.expectOne(`http://mock-api.com/wfprev-api/projects/${projectGuid}/projectFiscals/${fiscalGuid}/activities/${activityGuid}/activityBoundary/${boundaryGuid}`); expect(req.request.method).toBe('DELETE'); req.flush({}); @@ -700,14 +700,14 @@ describe('ProjectService', () => { const fiscalGuid = 'fiscal-1'; const activityGuid = 'activity-1'; const boundaryGuid = 'boundary-1'; - + service.deleteActivityBoundary(projectGuid, fiscalGuid, activityGuid, boundaryGuid).subscribe({ next: () => fail('Should have failed'), error: (err) => { expect(err.message).toBe('Failed to delete activity boundary'); } }); - + const req = httpMock.expectOne(`http://mock-api.com/wfprev-api/projects/${projectGuid}/projectFiscals/${fiscalGuid}/activities/${activityGuid}/activityBoundary/${boundaryGuid}`); req.flush('Error', { status: 500, statusText: 'Server Error' }); }); @@ -715,11 +715,11 @@ describe('ProjectService', () => { it('should download a document by file ID', () => { const fileId = 'file-123'; const mockBlob = new Blob(['test content'], { type: 'application/pdf' }); - + service.downloadDocument(fileId).subscribe(response => { expect(response).toEqual(mockBlob); }); - + const req = httpMock.expectOne(`http://mock-wfdm-api.com/documents/${fileId}/bytes`); expect(req.request.method).toBe('GET'); expect(req.request.headers.get('Authorization')).toBe('Bearer mock-token'); @@ -769,9 +769,9 @@ describe('ProjectService', () => { const req = httpMock.expectOne((request) => { return request.url === 'http://mock-api.com/wfprev-api/features' && - request.params.has('programAreaGuid') && - request.params.has('fiscalYear') && - request.params.get('searchText') === 'fuel'; + request.params.has('programAreaGuid') && + request.params.has('fiscalYear') && + request.params.get('searchText') === 'fuel'; }); expect(req.request.method).toBe('GET'); @@ -793,7 +793,7 @@ describe('ProjectService', () => { req.flush('Error', { status: 500, statusText: 'Server Error' }); }); - it('should fetch evaluation criteria summaries', () => { + it('should fetch evaluation criteria summaries', () => { const projectGuid = 'project-1'; const mockResponse = { summaries: [] }; @@ -888,32 +888,51 @@ describe('ProjectService', () => { }); it('should download projects and return blob', () => { - const projectGuids = ['guid1', 'guid2']; - const type = 'csv'; + const body: ReportRequest = { + reportType: 'csv', + projects: [ + { projectGuid: 'guid1' }, + { projectGuid: 'guid2' } + ] + }; + const mockBlob = new Blob(['test data'], { type: 'text/csv' }); - service.downloadProjects(projectGuids, type).subscribe((result) => { + service.downloadProjects(body).subscribe((result) => { expect(result).toEqual(mockBlob); }); const req = httpMock.expectOne('http://mock-api.com/wfprev-api/reports'); expect(req.request.method).toBe('POST'); expect(req.request.responseType).toBe('blob'); + expect(req.request.body).toEqual(body); req.flush(mockBlob); }); it('should handle error when downloading projects', () => { - const projectGuids = ['guid1', 'guid2']; - const type = 'csv'; + const body: ReportRequest = { + reportType: 'csv', + projects: [ + { projectGuid: 'guid1' }, + { projectGuid: 'guid2' } + ] + }; - service.downloadProjects(projectGuids, type).subscribe({ + service.downloadProjects(body).subscribe({ next: () => fail('Should have failed'), error: (err) => { + expect(err).toBeTruthy(); expect(err.message).toBe('Failed to download projects'); } }); const req = httpMock.expectOne('http://mock-api.com/wfprev-api/reports'); + expect(req.request.method).toBe('POST'); + expect(req.request.responseType).toBe('blob'); + expect(req.request.body).toEqual(body); + expect(req.request.headers.get('Accept')).toBe('application/octet-stream'); + expect(req.request.headers.get('Content-Type')).toBe('application/json'); + const errorBlob = new Blob(['Error'], { type: 'text/plain' }); req.flush(errorBlob, { status: 500, statusText: 'Server Error' }); }); diff --git a/client/wfprev-war/src/main/angular/src/app/services/project-services.ts b/client/wfprev-war/src/main/angular/src/app/services/project-services.ts index 907192f92..83b5b6157 100644 --- a/client/wfprev-war/src/main/angular/src/app/services/project-services.ts +++ b/client/wfprev-war/src/main/angular/src/app/services/project-services.ts @@ -2,7 +2,7 @@ import { HttpClient, HttpRequest, HttpHeaders, HttpEventType, HttpResponse, Http import { Injectable } from "@angular/core"; import { UUID } from "angular2-uuid"; import { catchError, map, Observable, throwError } from "rxjs"; -import { ActivityBoundary, EvaluationCriteriaSummaryModel, FeaturesResponse, Project, ProjectBoundary, ProjectFiscal } from "src/app/components/models"; +import { ActivityBoundary, EvaluationCriteriaSummaryModel, FeaturesResponse, Project, ProjectBoundary, ProjectFiscal, ReportRequest } from "src/app/components/models"; import { AppConfigService } from "src/app/services/app-config.service"; import { TokenService } from "src/app/services/token.service"; @@ -229,7 +229,7 @@ export class ProjectService { getActivityBoundaries(projectGuid: string, projectPlanFiscalGuid: string, activityGuid: string): Observable { const baseUrl = `${this.appConfigService.getConfig().rest['wfprev']}/wfprev-api/projects`; const url = `${baseUrl}/${projectGuid}/projectFiscals/${projectPlanFiscalGuid}/activities/${activityGuid}/activityBoundary`; - + return this.httpClient.get(url, { headers: { Authorization: `Bearer ${this.tokenService.getOauthToken()}`, @@ -435,12 +435,12 @@ export class ProjectService { downloadDocument(fileId: string): Observable { const url = `${this.appConfigService.getConfig().rest['wfdm']}/documents/${fileId}/bytes`; const headers = new HttpHeaders({ - Authorization: `Bearer ${this.tokenService.getOauthToken()}`, + Authorization: `Bearer ${this.tokenService.getOauthToken()}`, }); - + return this.httpClient.get(url, { - headers: headers, - responseType: 'blob' + headers: headers, + responseType: 'blob' }); } @@ -455,7 +455,7 @@ export class ProjectService { searchText?: string; }): Observable { const baseUrl = `${this.appConfigService.getConfig().rest['wfprev']}/wfprev-api/features`; - + let httpParams = new HttpParams(); if (params) { for (const key in params) { @@ -467,7 +467,7 @@ export class ProjectService { } } } - + return this.httpClient.get(baseUrl, { headers: { Authorization: `Bearer ${this.tokenService.getOauthToken()}`, @@ -487,13 +487,13 @@ export class ProjectService { return this.httpClient.get(url, { headers: { - Authorization: `Bearer ${this.tokenService.getOauthToken()}`, + Authorization: `Bearer ${this.tokenService.getOauthToken()}`, } }).pipe( map((response: any) => response), catchError((error) => { - console.error("Error fetching evaluation criteria summaries", error); - return throwError(() => new Error("Failed to fetch evaluation criteria summaries")); + console.error("Error fetching evaluation criteria summaries", error); + return throwError(() => new Error("Failed to fetch evaluation criteria summaries")); }) ); } @@ -501,7 +501,7 @@ export class ProjectService { createEvaluationCriteriaSummary( projectGuid: string, criteriaSummary: EvaluationCriteriaSummaryModel - ): Observable { + ): Observable { const baseUrl = `${this.appConfigService.getConfig().rest['wfprev']}/wfprev-api/projects`; const url = `${baseUrl}/${projectGuid}/evaluationCriteriaSummary`; @@ -509,13 +509,13 @@ export class ProjectService { url, criteriaSummary, { - headers: { - Authorization: `Bearer ${this.tokenService.getOauthToken()}` - } + headers: { + Authorization: `Bearer ${this.tokenService.getOauthToken()}` + } } ).pipe( - map(response => response), - catchError(error => { + map(response => response), + catchError(error => { console.error("Error creating evaluation criteria summary", error); return throwError(() => new Error("Failed to create evaluation criteria summary")); }) @@ -526,39 +526,35 @@ export class ProjectService { projectGuid: string, summaryGuid: string, criteriaSummary: EvaluationCriteriaSummaryModel - ): Observable { + ): Observable { const url = `${this.appConfigService.getConfig().rest['wfprev']}/wfprev-api/projects/${projectGuid}/evaluationCriteriaSummary/${summaryGuid}`; - + return this.httpClient.put(url, criteriaSummary, { headers: { - Authorization: `Bearer ${this.tokenService.getOauthToken()}`, + Authorization: `Bearer ${this.tokenService.getOauthToken()}`, } }).pipe( catchError(error => { - console.error("Error updating evaluation criteria summary", error); - return throwError(() => new Error("Failed to update evaluation criteria summary")); + console.error("Error updating evaluation criteria summary", error); + return throwError(() => new Error("Failed to update evaluation criteria summary")); }) ); } - downloadProjects(projectGuids: string[], reportType: string): Observable { - // this will need to update after api is done + downloadProjects(body: ReportRequest): Observable { const url = `${this.appConfigService.getConfig().rest['wfprev']}/wfprev-api/reports`; - const payload = { - projectGuids, - reportType - }; - return this.httpClient.post(url, payload, { + return this.httpClient.post(url, body, { headers: { - Authorization: `Bearer ${this.tokenService.getOauthToken()}`, - 'Content-Type': 'application/json', + Authorization: `Bearer ${this.tokenService.getOauthToken()}`, + 'Content-Type': 'application/json', + 'Accept': 'application/octet-stream' }, - responseType: 'blob', + responseType: 'blob' as 'json' }).pipe( catchError((error) => { - console.error("Error downloading projects", error); - return throwError(() => new Error("Failed to download projects")); + console.error('Error downloading projects', error); + return throwError(() => new Error('Failed to download projects')); }) ); } diff --git a/db/app_wf1_prev-changelog.json b/db/app_wf1_prev-changelog.json index e825c806f..98d7556b4 100644 --- a/db/app_wf1_prev-changelog.json +++ b/db/app_wf1_prev-changelog.json @@ -1726,7 +1726,34 @@ ], "rollback": [] } + }, + { + "changeSet": { + "id": "01_00_38_00", + "author": "ssylver", + "tagDatabase": { "tag" : "version_01_00_38_00" }, + "changes": [ + { + "sqlFile": { + "dbms": "postgresql", "endDelimiter": ";", + "path": "scripts/01_00_38/00/ddl/WFPREV.ddl.create.uuid_namespace.sql" + } + }, + { + "sqlFile": { + "dbms": "postgresql", "endDelimiter": ";", + "path": "scripts/01_00_38/00/ddl/WFPREV.ddl.create.project_fuel_management_vw.sql" + } + }, + { + "sqlFile": { + "dbms": "postgresql", "endDelimiter": ";", + "path": "scripts/01_00_38/00/ddl/WFPREV.ddl.create.project_crx_vw.sql" + } + } + ], + "rollback": [] + } } - - ] + ] } diff --git a/db/scripts/01_00_38/00/ddl/WFPREV.ddl.create.project_crx_vw.sql b/db/scripts/01_00_38/00/ddl/WFPREV.ddl.create.project_crx_vw.sql new file mode 100644 index 000000000..ef1e62004 --- /dev/null +++ b/db/scripts/01_00_38/00/ddl/WFPREV.ddl.create.project_crx_vw.sql @@ -0,0 +1,134 @@ +DROP VIEW IF EXISTS wfprev.project_crx_vw; + +CREATE or REPLACE VIEW wfprev.project_crx_vw AS +SELECT + -- unique_row_guid is transient and only used to satisfy repository methods. + -- It cannot be used to look up records in code; we don’t query by this field. +uuid_generate_v5( + wfprev.uuid_namespace(), + concat_ws('|', + coalesce(ppf.project_plan_fiscal_guid::text, 'NULL'), + coalesce(p.project_guid::text, 'NULL'), + coalesce(ppf.fiscal_year::text, 'NULL'), + coalesce(ppf.project_fiscal_name, 'NULL'), + coalesce(ppf.project_fiscal_description, 'NULL') + ) + ) AS unique_row_guid, + p.project_guid, + p.project_type_code, + ptc.description AS project_type_description, + p.project_name, + p.program_area_guid, + p.forest_region_org_unit_id, + frou.org_unit_name AS forest_region_org_unit_name, + p.forest_district_org_unit_id, + fdou.org_unit_name AS forest_district_org_unit_name, + p.bc_parks_region_org_unit_id, + bcpr.org_unit_name AS bc_parks_region_org_unit_name, + p.bc_parks_section_org_unit_id, + bcps.org_unit_name AS bc_parks_section_org_unit_name, + p.fire_centre_org_unit_id, + fc.org_unit_name AS fire_centre_org_unit_name, -- todo does not match data returned by API + p.site_unit_name AS planning_unit_name, + p.total_actual_project_size_ha AS gross_project_area_ha, + p.closest_community_name, + p.project_lead, + ppf.project_plan_fiscal_guid, + ppf.business_area_comment, + ppf.proposal_type_code, + prtc.description AS proposal_type_description, + ppf.project_fiscal_name, + ppf.project_fiscal_description, + ppf.fiscal_year, + ppf.activity_category_code, + acc.description AS activity_category_description, + ppf.plan_fiscal_status_code, + pfsc.description AS plan_fiscal_status_description, + fs.funding_stream, + ppf.total_cost_estimate_amount, + ppf.fiscal_ancillary_fund_amount, + ppf.fiscal_reported_spend_amount, ppf.fiscal_actual_amount, + ppf.fiscal_planned_project_size_ha, + ppf.fiscal_completed_size_ha, + (SELECT SUM(CASE WHEN is_spatial_added_ind THEN 1 ELSE 0 END)||'/'||COUNT(*) + FROM wfprev.activity act + WHERE act.project_plan_fiscal_guid = ppf.project_plan_fiscal_guid + ) AS spatial_submitted, + CASE + WHEN ppf.first_nations_engagement_ind THEN 'Y' + ELSE 'N' + END AS first_nations_engagement, + CASE + WHEN ppf.first_nations_deliv_part_ind THEN 'Y' + ELSE 'N' + END AS first_nations_deliv_partners, + ppf.first_nations_partner, + ppf.other_partner, + ppf.cfs_project_code, + p.results_project_code, + ppf.results_opening_id, + p.primary_objective_type_code, + potc.description AS primary_objective_type_description, + p.secondary_objective_type_code, + sotc.description AS secondary_objective_type_description, + ppf.endorsement_timestamp, + ppf.approved_timestamp, + ecs.outside_wui_ind, + ecs.wui_risk_class_code, + rcc.description AS wui_risk_class_description, + ecs.local_wui_risk_class_code, + lrcc.description AS local_wui_risk_class_description, + (SELECT COALESCE(SUM(COALESCE(filter_section_score,0)),0) + FROM wfprev.eval_criteria_sect_summ ecss + WHERE ecss.eval_criteria_summary_guid = ecs.eval_criteria_summary_guid + AND ecss.eval_criteria_sect_code = 'RCL' + ) AS total_rcl_filter_section_score, + (SELECT string_agg(filter_section_comment, '| ') + FROM wfprev.eval_criteria_sect_summ ecss + WHERE ecss.eval_criteria_summary_guid = ecs.eval_criteria_summary_guid + AND ecss.eval_criteria_sect_code = 'RCL' + ) AS rcl_filter_section_comment, + (SELECT COALESCE(SUM(COALESCE(filter_section_score,0)),0) + FROM wfprev.eval_criteria_sect_summ ecss + WHERE ecss.eval_criteria_summary_guid = ecs.eval_criteria_summary_guid + AND ecss.eval_criteria_sect_code = 'BDF' + ) AS total_bdf_filter_section_score, + (SELECT string_agg(filter_section_comment, '| ') + FROM wfprev.eval_criteria_sect_summ ecss + WHERE ecss.eval_criteria_summary_guid = ecs.eval_criteria_summary_guid + AND ecss.eval_criteria_sect_code = 'BDF' + ) AS bdf_filter_section_comment, + (SELECT COALESCE(SUM(COALESCE(filter_section_score,0)),0) + FROM wfprev.eval_criteria_sect_summ ecss + WHERE ecss.eval_criteria_summary_guid = ecs.eval_criteria_summary_guid + AND ecss.eval_criteria_sect_code = 'COLL_IMP' + ) AS total_collimp_filter_section_score, + (SELECT string_agg(filter_section_comment, '| ') + FROM wfprev.eval_criteria_sect_summ ecss + WHERE ecss.eval_criteria_summary_guid = ecs.eval_criteria_summary_guid + AND ecss.eval_criteria_sect_code = 'COLL_IMP' + ) AS collimp_filter_section_comment, + (SELECT COALESCE(SUM(COALESCE(filter_section_score,0)),0) + FROM wfprev.eval_criteria_sect_summ ecss + WHERE ecss.eval_criteria_summary_guid = ecs.eval_criteria_summary_guid + AND ecss.eval_criteria_sect_code IN ( 'COLL_IMP', 'BDF', 'RCL') + ) AS total_filter_section_score +FROM wfprev.project p + JOIN wfprev.project_type_code ptc ON ptc.project_type_code = p.project_type_code + JOIN wfprev.forest_org_unit frou ON frou.org_unit_identifier = p.forest_region_org_unit_id + LEFT JOIN wfprev.forest_org_unit fdou ON fdou.org_unit_identifier = p.forest_district_org_unit_id + LEFT JOIN wfprev.bc_parks_org_unit bcpr ON bcpr.org_unit_identifier = p.bc_parks_region_org_unit_id + LEFT JOIN wfprev.bc_parks_org_unit bcps ON bcps.org_unit_identifier = p.bc_parks_section_org_unit_id + LEFT JOIN wfprev.wildfire_org_unit fc ON fc.org_unit_identifier = p.fire_centre_org_unit_id + LEFT JOIN wfprev.project_plan_fiscal ppf ON ppf.project_guid = p.project_guid + LEFT JOIN wfprev.proposal_type_code prtc ON prtc.proposal_type_code = ppf.proposal_type_code + LEFT JOIN wfprev.activity_category_code acc ON acc.activity_category_code = ppf.activity_category_code + LEFT JOIN wfprev.plan_fiscal_status_code pfsc ON pfsc.plan_fiscal_status_code = ppf.plan_fiscal_status_code + LEFT JOIN wfprev.funding_stream fs ON fs.funding_stream_guid = p.funding_stream_guid + LEFT JOIN wfprev.objective_type_code potc ON potc.objective_type_code = p.primary_objective_type_code + LEFT JOIN wfprev.objective_type_code sotc ON sotc.objective_type_code = p.secondary_objective_type_code + LEFT JOIN wfprev.eval_criteria_summary ecs ON ecs.project_guid = p.project_guid + LEFT JOIN wfprev.wui_risk_class_code rcc ON rcc.wui_risk_class_code = ecs.wui_risk_class_code + LEFT JOIN wfprev.wui_risk_class_code lrcc ON lrcc.wui_risk_class_code = ecs.local_wui_risk_class_code +WHERE p.project_type_code = 'CULT_RX_FR' +ORDER BY p.project_guid, p.project_name, ppf.fiscal_year; \ No newline at end of file diff --git a/db/scripts/01_00_38/00/ddl/WFPREV.ddl.create.project_fuel_management_vw.sql b/db/scripts/01_00_38/00/ddl/WFPREV.ddl.create.project_fuel_management_vw.sql new file mode 100644 index 000000000..c8b82315b --- /dev/null +++ b/db/scripts/01_00_38/00/ddl/WFPREV.ddl.create.project_fuel_management_vw.sql @@ -0,0 +1,129 @@ +DROP VIEW IF EXISTS wfprev.project_fuel_management_vw; + +CREATE or REPLACE VIEW wfprev.project_fuel_management_vw AS +SELECT + -- unique_row_guid is transient and only used to satisfy repository methods. + -- It cannot be used to look up records in code; we don’t query by this field. +uuid_generate_v5( + wfprev.uuid_namespace(), + concat_ws('|', + coalesce(ppf.project_plan_fiscal_guid::text, 'NULL'), + coalesce(p.project_guid::text, 'NULL'), + coalesce(ppf.fiscal_year::text, 'NULL'), + coalesce(ppf.project_fiscal_name, 'NULL'), + coalesce(ppf.project_fiscal_description, 'NULL') + ) + ) AS unique_row_guid, + p.project_guid, + p.project_type_code, + ptc.description AS project_type_description, + p.project_name, + p.program_area_guid, + p.forest_region_org_unit_id, + frou.org_unit_name AS forest_region_org_unit_name, + p.forest_district_org_unit_id, + fdou.org_unit_name AS forest_district_org_unit_name, + p.bc_parks_region_org_unit_id, + bcpr.org_unit_name AS bc_parks_region_org_unit_name, + p.bc_parks_section_org_unit_id, + bcps.org_unit_name AS bc_parks_section_org_unit_name, + p.fire_centre_org_unit_id, + fc.org_unit_name AS fire_centre_org_unit_name, -- todo does not match + ppf.business_area_comment, + p.site_unit_name AS planning_unit_name, + p.total_actual_project_size_ha AS gross_project_area_ha, + p.closest_community_name, p.project_lead, + ppf.proposal_type_code, + prtc.description AS proposal_type_description, + ppf.project_fiscal_name, + ppf.project_fiscal_description, + ppf.fiscal_year, + ppf.activity_category_code, + acc.description AS activity_category_description, + ppf.plan_fiscal_status_code, + pfsc.description AS plan_fiscal_status_description, + fs.funding_stream, + ppf.total_cost_estimate_amount, + ppf.project_plan_fiscal_guid, + ppf.fiscal_ancillary_fund_amount, + ppf.fiscal_reported_spend_amount, + ppf.fiscal_actual_amount, + ppf.fiscal_planned_project_size_ha, + ppf.fiscal_completed_size_ha, + (SELECT SUM(CASE WHEN is_spatial_added_ind THEN 1 ELSE 0 END)||'/'||COUNT(*) + FROM wfprev.activity act + WHERE act.project_plan_fiscal_guid = ppf.project_plan_fiscal_guid + ) AS spatial_submitted, + CASE + WHEN ppf.first_nations_engagement_ind THEN 'Y' + ELSE 'N' + END AS first_nations_engagement, + CASE + WHEN ppf.first_nations_deliv_part_ind THEN 'Y' + ELSE 'N' + END AS first_nations_deliv_partners, + ppf.first_nations_partner, + ppf.other_partner, + ppf.cfs_project_code, + p.results_project_code, + ppf.results_opening_id, + p.primary_objective_type_code, + potc.description AS primary_objective_type_description, + p.secondary_objective_type_code, + sotc.description AS secondary_objective_type_description, + ppf.endorsement_timestamp, + ppf.approved_timestamp, + ecs.wui_risk_class_code, + rcc.description AS wui_risk_class_description, + ecs.local_wui_risk_class_code, + lrcc.description AS local_wui_risk_class_description, + ecs.local_wui_risk_class_rationale, + (SELECT COALESCE(SUM(COALESCE(filter_section_score,0)),0) + FROM wfprev.eval_criteria_sect_summ ecss + WHERE ecss.eval_criteria_summary_guid = ecs.eval_criteria_summary_guid + AND ecss.eval_criteria_sect_code = 'COARSE_FLT' + ) AS total_coarse_filter_section_score, + (SELECT COALESCE(SUM(COALESCE(filter_section_score,0)),0) + FROM wfprev.eval_criteria_sect_summ ecss + WHERE ecss.eval_criteria_summary_guid = ecs.eval_criteria_summary_guid + AND ecss.eval_criteria_sect_code = 'MEDIUM_FLT' + ) AS total_medium_filter_section_score, + (SELECT string_agg(filter_section_comment, '| ') + FROM wfprev.eval_criteria_sect_summ ecss + WHERE ecss.eval_criteria_summary_guid = ecs.eval_criteria_summary_guid + AND ecss.eval_criteria_sect_code = 'MEDIUM_FLT' + ) AS medium_filter_section_comment, + (SELECT COALESCE(SUM(COALESCE(filter_section_score,0)),0) + FROM wfprev.eval_criteria_sect_summ ecss + WHERE ecss.eval_criteria_summary_guid = ecs.eval_criteria_summary_guid + AND ecss.eval_criteria_sect_code = 'FINE_FLT' + ) AS total_fine_filter_section_score, + (SELECT string_agg(filter_section_comment, '| ') + FROM wfprev.eval_criteria_sect_summ ecss + WHERE ecss.eval_criteria_summary_guid = ecs.eval_criteria_summary_guid + AND ecss.eval_criteria_sect_code = 'FINE_FLT' + ) AS fine_filter_section_comment, + (SELECT COALESCE(SUM(COALESCE(filter_section_score,0)),0) + FROM wfprev.eval_criteria_sect_summ ecss + WHERE ecss.eval_criteria_summary_guid = ecs.eval_criteria_summary_guid + AND ecss.eval_criteria_sect_code IN ( 'FINE_FLT', 'MEDIUM_FLT', 'COARSE_FLT' ) + ) AS total_filter_section_score +FROM wfprev.project p + LEFT JOIN wfprev.project_type_code ptc ON ptc.project_type_code = p.project_type_code + LEFT JOIN wfprev.forest_org_unit frou ON frou.org_unit_identifier = p.forest_region_org_unit_id + LEFT JOIN wfprev.forest_org_unit fdou ON fdou.org_unit_identifier = p.forest_district_org_unit_id + LEFT JOIN wfprev.bc_parks_org_unit bcpr ON bcpr.org_unit_identifier = p.bc_parks_region_org_unit_id + LEFT JOIN wfprev.bc_parks_org_unit bcps ON bcps.org_unit_identifier = p.bc_parks_section_org_unit_id + LEFT JOIN wfprev.wildfire_org_unit fc ON fc.org_unit_identifier = p.fire_centre_org_unit_id + LEFT JOIN wfprev.project_plan_fiscal ppf ON ppf.project_guid = p.project_guid + LEFT JOIN wfprev.proposal_type_code prtc ON prtc.proposal_type_code = ppf.proposal_type_code + LEFT JOIN wfprev.activity_category_code acc ON acc.activity_category_code = ppf.activity_category_code + LEFT JOIN wfprev.plan_fiscal_status_code pfsc ON pfsc.plan_fiscal_status_code = ppf.plan_fiscal_status_code + LEFT JOIN wfprev.funding_stream fs ON fs.funding_stream_guid = p.funding_stream_guid + LEFT JOIN wfprev.objective_type_code potc ON potc.objective_type_code = p.primary_objective_type_code + LEFT JOIN wfprev.objective_type_code sotc ON sotc.objective_type_code = p.secondary_objective_type_code + LEFT JOIN wfprev.eval_criteria_summary ecs ON ecs.project_guid = p.project_guid + LEFT JOIN wfprev.wui_risk_class_code rcc ON rcc.wui_risk_class_code = ecs.wui_risk_class_code + LEFT JOIN wfprev.wui_risk_class_code lrcc ON lrcc.wui_risk_class_code = ecs.local_wui_risk_class_code +WHERE p.project_type_code = 'FUEL_MGMT' +ORDER BY p.project_guid, p.project_name, ppf.fiscal_year; \ No newline at end of file diff --git a/db/scripts/01_00_38/00/ddl/WFPREV.ddl.create.uuid_namespace.sql b/db/scripts/01_00_38/00/ddl/WFPREV.ddl.create.uuid_namespace.sql new file mode 100644 index 000000000..41484e832 --- /dev/null +++ b/db/scripts/01_00_38/00/ddl/WFPREV.ddl.create.uuid_namespace.sql @@ -0,0 +1,8 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE OR REPLACE FUNCTION wfprev.uuid_namespace() RETURNS uuid +LANGUAGE sql IMMUTABLE AS $$ + SELECT '9a1f9b6d-2b9b-4c35-9f7f-7a2b9d7c1a11'::uuid +$$; +COMMENT ON FUNCTION wfprev.uuid_namespace() IS + 'Fixed namespace UUID for deterministic v5 keys in WFPREV report views'; diff --git a/postman/Prevention.postman_collection.json b/postman/Prevention.postman_collection.json index 4e577e990..f4a018dce 100644 --- a/postman/Prevention.postman_collection.json +++ b/postman/Prevention.postman_collection.json @@ -3515,7 +3515,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\n \"reportType\": \"csv\",\n \"projectGuids\": [\"{{projectGuid}}\"]\n}", + "raw": "{\n \"reportType\": \"csv\",\n \"projects\": [\n {\n \"projectGuid\": \"{{projectGuid}}\",\n \"projectFiscalGuids\": [\"{{projectPlanFiscalGuid}}\"]\n }\n ]\n}", "options": { "raw": { "language": "json" @@ -3564,7 +3564,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\n \"reportType\": \"xlsx\",\n \"projectGuids\": [\"{{projectGuid}}\"]\n}", + "raw": "{\n \"reportType\": \"xlsx\",\n \"projects\": [\n {\n \"projectGuid\": \"{{projectGuid}}\",\n \"projectFiscalGuids\": [\"{{projectPlanFiscalGuid}}\"]\n }\n ]\n}", "options": { "raw": { "language": "json" diff --git a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/controllers/ReportController.java b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/controllers/ReportController.java index e9a40ae7b..f1394bdba 100644 --- a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/controllers/ReportController.java +++ b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/controllers/ReportController.java @@ -41,27 +41,26 @@ public class ReportController { ) public ResponseEntity generateReport(@Valid @RequestBody ReportRequestModel request) throws ServiceException, IOException, InterruptedException { final String type = request.getReportType(); - final String rid = java.util.UUID.randomUUID().toString().substring(0, 8); - log.info("[{}] /reports start (type={})", rid, type); + log.info("/reports start (type={})", type); try { if ("XLSX".equalsIgnoreCase(type)) { byte[] bytes; long t0 = System.currentTimeMillis(); - log.info("[{}] exportXlsx -> begin", rid); + log.info("exportXlsx -> begin"); try (var baos = new java.io.ByteArrayOutputStream(1 << 20)) { // 1MB initial cap - reportService.exportXlsx(request.getProjectGuids(), baos, rid); // pass rid through + reportService.exportXlsx(request, baos); bytes = baos.toByteArray(); } long t1 = System.currentTimeMillis(); - log.info("[{}] exportXlsx -> end ({} ms, {} bytes)", rid, (t1 - t0), bytes.length); + log.info("exportXlsx -> end ({} ms, {} bytes)", (t1 - t0), bytes.length); StreamingResponseBody stream = out -> { - log.info("[{}] stream -> write begin", rid); + log.info("stream -> write begin"); out.write(bytes); out.flush(); - log.info("[{}] stream -> write end", rid); + log.info("stream -> write end"); }; return ResponseEntity.ok() @@ -74,19 +73,19 @@ public ResponseEntity generateReport(@Valid @RequestBody byte[] bytes; long t0 = System.currentTimeMillis(); - log.info("[{}] writeCsvZipFromEntities -> begin", rid); + log.info("writeCsvZipFromEntities -> begin"); try (var baos = new java.io.ByteArrayOutputStream(1 << 20)) { - reportService.writeCsvZipFromEntities(request.getProjectGuids(), baos); // pass rid through + reportService.writeCsvZipFromEntities(request, baos); bytes = baos.toByteArray(); } long t1 = System.currentTimeMillis(); - log.info("[{}] writeCsvZipFromEntities -> end ({} ms, {} bytes)", rid, (t1 - t0), bytes.length); + log.info("writeCsvZipFromEntities -> end ({} ms, {} bytes)", (t1 - t0), bytes.length); StreamingResponseBody stream = out -> { - log.info("[{}] stream(zip) -> write begin", rid); + log.info("stream(zip) -> write begin"); out.write(bytes); out.flush(); - log.info("[{}] stream(zip) -> write end", rid); + log.info("stream(zip) -> write end"); }; return ResponseEntity.ok() @@ -96,15 +95,20 @@ public ResponseEntity generateReport(@Valid @RequestBody .body(stream); } else { - log.warn("[{}] bad report type: {}", rid, type); + log.warn("Bad report type: {}", type); return ResponseEntity.badRequest() .contentType(MediaType.TEXT_PLAIN) .body(out -> out.write("Only reportType=XLSX or CSV is supported.".getBytes())); } - } catch (Throwable t) { - throw t; - } finally { - log.info("[{}] /reports end", rid); + }catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw ie; + }catch (ServiceException | IOException e) { + throw e; + }catch (Exception e) { + throw new ServiceException("Project Download failed: " + e.getMessage(), e); + }finally { + log.info("/reports end"); } } diff --git a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/entities/CulturalPrescribedFireReportEntity.java b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/entities/CulturalPrescribedFireReportEntity.java index cd259c17b..6ff6dc90c 100644 --- a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/entities/CulturalPrescribedFireReportEntity.java +++ b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/entities/CulturalPrescribedFireReportEntity.java @@ -10,7 +10,6 @@ import java.io.Serializable; import java.math.BigDecimal; -import java.time.OffsetDateTime; import java.util.Date; import java.util.UUID; @@ -22,6 +21,9 @@ public class CulturalPrescribedFireReportEntity implements Serializable { private static final long serialVersionUID = 1L; @Id + @Column(name = "unique_row_guid") + private UUID uniqueRowGuid; + @Column(name = "project_plan_fiscal_guid") private UUID projectPlanFiscalGuid; diff --git a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/entities/FuelManagementReportEntity.java b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/entities/FuelManagementReportEntity.java index 3b074d286..330bd8feb 100644 --- a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/entities/FuelManagementReportEntity.java +++ b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/entities/FuelManagementReportEntity.java @@ -10,7 +10,6 @@ import java.io.Serializable; import java.math.BigDecimal; -import java.time.OffsetDateTime; import java.util.Date; import java.util.UUID; @@ -22,6 +21,9 @@ public class FuelManagementReportEntity implements Serializable { private static final long serialVersionUID = 1L; @Id + @Column(name = "unique_row_guid") + private UUID uniqueRowGuid; + @Column(name = "project_plan_fiscal_guid") private UUID projectPlanFiscalGuid; diff --git a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/models/ReportRequestModel.java b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/models/ReportRequestModel.java index a887390de..987600dee 100644 --- a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/models/ReportRequestModel.java +++ b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/models/ReportRequestModel.java @@ -7,6 +7,12 @@ @Data public class ReportRequestModel { - private List projectGuids; private String reportType; -} + private List projects; + + @Data + public static class Project { + private UUID projectGuid; + private List projectFiscalGuids; + } +} \ No newline at end of file diff --git a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/repositories/CulturalPrescribedFireReportRepository.java b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/repositories/CulturalPrescribedFireReportRepository.java index 816dd522f..69ddac657 100644 --- a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/repositories/CulturalPrescribedFireReportRepository.java +++ b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/repositories/CulturalPrescribedFireReportRepository.java @@ -5,12 +5,13 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Collection; import java.util.List; import java.util.UUID; @Repository public interface CulturalPrescribedFireReportRepository extends JpaRepository { - List findByProjectGuidIn(List projectGuids); + List findByProjectGuid(UUID projectGuid); - List findByProjectPlanFiscalGuidIn(List projectGuids); -} + List findByProjectGuidAndProjectPlanFiscalGuidIn(UUID projectGuid, Collection fiscalGuids); +} \ No newline at end of file diff --git a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/repositories/FuelManagementReportRepository.java b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/repositories/FuelManagementReportRepository.java index 12e61f982..f530614a9 100644 --- a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/repositories/FuelManagementReportRepository.java +++ b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/repositories/FuelManagementReportRepository.java @@ -4,12 +4,13 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Collection; import java.util.List; import java.util.UUID; @Repository public interface FuelManagementReportRepository extends JpaRepository { - List findByProjectGuidIn(List projectGuids); + List findByProjectGuid(UUID projectGuid); - List findByProjectPlanFiscalGuidIn(List projectGuids); -} + List findByProjectGuidAndProjectPlanFiscalGuidIn(UUID projectGuid, Collection fiscalGuids); +} \ No newline at end of file diff --git a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/services/ReportService.java b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/services/ReportService.java index c6c33610a..30ee12e5b 100644 --- a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/services/ReportService.java +++ b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/services/ReportService.java @@ -1,5 +1,18 @@ package ca.bc.gov.nrs.wfprev.services; +import ca.bc.gov.nrs.wfprev.common.exceptions.ServiceException; +import ca.bc.gov.nrs.wfprev.data.entities.CulturalPrescribedFireReportEntity; +import ca.bc.gov.nrs.wfprev.data.entities.FuelManagementReportEntity; +import ca.bc.gov.nrs.wfprev.data.models.ReportRequestModel; +import ca.bc.gov.nrs.wfprev.data.repositories.CulturalPrescribedFireReportRepository; +import ca.bc.gov.nrs.wfprev.data.repositories.FuelManagementReportRepository; +import ca.bc.gov.nrs.wfprev.data.repositories.ProgramAreaRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + import java.io.BufferedWriter; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -11,28 +24,13 @@ import java.net.http.HttpResponse; import java.time.ZoneId; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.UUID; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - -import ca.bc.gov.nrs.wfprev.common.exceptions.ServiceException; -import ca.bc.gov.nrs.wfprev.data.entities.CulturalPrescribedFireReportEntity; -import ca.bc.gov.nrs.wfprev.data.entities.FuelManagementReportEntity; -import ca.bc.gov.nrs.wfprev.data.entities.ProjectEntity; -import ca.bc.gov.nrs.wfprev.data.entities.ProjectFiscalEntity; -import ca.bc.gov.nrs.wfprev.data.repositories.CulturalPrescribedFireReportRepository; -import ca.bc.gov.nrs.wfprev.data.repositories.FuelManagementReportRepository; -import ca.bc.gov.nrs.wfprev.data.repositories.ProgramAreaRepository; -import ca.bc.gov.nrs.wfprev.data.repositories.ProjectRepository; -import lombok.extern.slf4j.Slf4j; - @Slf4j @Component public class ReportService { @@ -47,50 +45,77 @@ public class ReportService { private static final String FISCAL_QUERY_STRING = "&tab=fiscal&fiscalGuid="; - DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd") + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd") .withZone(ZoneId.systemDefault()); + private static final String HECTARE_FORMAT = "%,d ha"; + private final FuelManagementReportRepository fuelManagementRepository; private final CulturalPrescribedFireReportRepository culturalPrescribedFireReportRepository; - private final ProjectRepository projectRepository; private final ProgramAreaRepository programAreaRepository; public ReportService(FuelManagementReportRepository fuelManagementRepository, - CulturalPrescribedFireReportRepository culturalPrescribedFireReportRepository, - ProjectRepository projectRepository, ProgramAreaRepository programAreaRepository) { + CulturalPrescribedFireReportRepository culturalPrescribedFireReportRepository, ProgramAreaRepository programAreaRepository) { this.fuelManagementRepository = fuelManagementRepository; this.culturalPrescribedFireReportRepository = culturalPrescribedFireReportRepository; - this.projectRepository = projectRepository; this.programAreaRepository = programAreaRepository; } - public void exportXlsx(List projectGuids, OutputStream outputStream, String rid) - throws ServiceException, IOException, InterruptedException { - long tStart = System.currentTimeMillis(); - log.info("[{}] [xlsx] start projectGuids={}", rid, projectGuids.size()); - - // Fetch projects and fiscal GUIDs - List projects = projectRepository.findByProjectGuidIn(projectGuids); - List projectPlanFiscalGuids = projects.stream() - .flatMap(p -> p.getProjectFiscals().stream()) - .map(ProjectFiscalEntity::getProjectPlanFiscalGuid) - .distinct() - .toList(); - - // Fetch report rows - List fuelData = fuelManagementRepository - .findByProjectPlanFiscalGuidIn(projectPlanFiscalGuids); - List crxData = culturalPrescribedFireReportRepository - .findByProjectPlanFiscalGuidIn(projectPlanFiscalGuids); - - if (fuelData.isEmpty() && crxData.isEmpty()) { - log.info("[{}] [xlsx] no data for given projectGuids", rid); - throw new IllegalArgumentException("No data found for the provided projectGuids."); + private static class ReportDataBundle { + List fuel; + List crx; + + ReportDataBundle(List fuel, + List crx) { + this.fuel = fuel; + this.crx = crx; + } + } + + private ReportDataBundle resolveReportData(ReportRequestModel request) { + List fuel = new ArrayList<>(); + List crx = new ArrayList<>(); + + if (request == null || request.getProjects() == null || request.getProjects().isEmpty()) { + throw new IllegalArgumentException("At least one project is required"); + } + + for (ReportRequestModel.Project p : request.getProjects()) { + UUID projectGuid = Objects.requireNonNull(p.getProjectGuid(), "projectGuid is required"); + List fiscals = p.getProjectFiscalGuids(); + + if (fiscals != null && !fiscals.isEmpty()) { + crx.addAll(culturalPrescribedFireReportRepository + .findByProjectGuidAndProjectPlanFiscalGuidIn(projectGuid, fiscals)); + fuel.addAll(fuelManagementRepository + .findByProjectGuidAndProjectPlanFiscalGuidIn(projectGuid, fiscals)); + } else { + // Now includes rows where project_plan_fiscal_guid IS NULL + crx.addAll(culturalPrescribedFireReportRepository.findByProjectGuid(projectGuid)); + fuel.addAll(fuelManagementRepository.findByProjectGuid(projectGuid)); + } } - // Set computed fields - fuelData.forEach(this::setFuelManagementFields); - crxData.forEach(this::setCrxFields); + return new ReportDataBundle(fuel, crx); + } + + + public void exportXlsx(ReportRequestModel request, OutputStream outputStream) + throws ServiceException, IOException, InterruptedException { + ReportDataBundle data = resolveReportData(request); + + // Remove nulls up front (defensive) + data.fuel.removeIf(Objects::isNull); + data.crx.removeIf(Objects::isNull); + + // Pre-process + data.fuel.forEach(this::setFuelManagementFields); + data.crx.forEach(this::setCrxFields); + + // If absolutely nothing to write, fail early + if (data.fuel.isEmpty() && data.crx.isEmpty()) { + throw new IllegalArgumentException("No fiscal data found for the provided projects"); + } // Build Lambda request model LambdaReportRequest lambdaRequest = new LambdaReportRequest(); @@ -98,8 +123,8 @@ public void exportXlsx(List projectGuids, OutputStream outputStream, Strin report.setReportType("XLSX"); report.setReportName("project-report"); LambdaReportRequest.XlsxReportData xlsxData = new LambdaReportRequest.XlsxReportData(); - xlsxData.setFuelManagementReportData(fuelData); - xlsxData.setCulturePrescribedFireReportData(crxData); + xlsxData.setFuelManagementReportData(data.fuel); + xlsxData.setCulturePrescribedFireReportData(data.crx); report.setXlsxReportData(xlsxData); lambdaRequest.setReports(java.util.List.of(report)); @@ -147,49 +172,56 @@ public void exportXlsx(List projectGuids, OutputStream outputStream, Strin byte[] xlsxBytes = java.util.Base64.getDecoder().decode(file.getContent()); outputStream.write(xlsxBytes); outputStream.flush(); - log.info("[{}] [xlsx] export complete ({} ms)", rid, (System.currentTimeMillis() - tStart)); - } - public void writeCsvZipFromEntities(List projectGuids, OutputStream zipOutStream) throws ServiceException { - List fuelRecords = fuelManagementRepository.findByProjectGuidIn(projectGuids); - List crxRecords = culturalPrescribedFireReportRepository - .findByProjectGuidIn(projectGuids); + public void writeCsvZipFromEntities(ReportRequestModel request, OutputStream zipOutStream) throws ServiceException { + ReportDataBundle data = resolveReportData(request); - for (FuelManagementReportEntity f : fuelRecords) { - setFuelManagementFields(f); - } - for (CulturalPrescribedFireReportEntity c : crxRecords) { - setCrxFields(c); + // Remove nulls up front + data.fuel.removeIf(Objects::isNull); + data.crx.removeIf(Objects::isNull); + + // Pre-process + data.fuel.forEach(this::setFuelManagementFields); + data.crx.forEach(this::setCrxFields); + + // If absolutely nothing to write, fail early + if (data.fuel.isEmpty() && data.crx.isEmpty()) { + throw new IllegalArgumentException("No fiscal data found for the provided projects"); } try (ZipOutputStream zipOut = new ZipOutputStream(zipOutStream)) { - ByteArrayOutputStream fuelCsvOut = new ByteArrayOutputStream(); - try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(fuelCsvOut))) { - writer.write(getFuelCsvHeader()); - writer.newLine(); - - for (FuelManagementReportEntity e : fuelRecords) { - writer.write(String.join(",", getFuelCsvRow(e))); + // Only write Fuel CSV if there are rows + if (!data.fuel.isEmpty()) { + ByteArrayOutputStream fuelCsvOut = new ByteArrayOutputStream(); + try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(fuelCsvOut))) { + writer.write(getFuelCsvHeader()); writer.newLine(); + for (FuelManagementReportEntity e : data.fuel) { + writer.write(String.join(",", getFuelCsvRow(e))); + writer.newLine(); + } } + addToZip(zipOut, "fuel-management-projects.csv", fuelCsvOut.toByteArray()); } - addToZip(zipOut, "fuel-management-projects.csv", fuelCsvOut.toByteArray()); - - ByteArrayOutputStream crxCsvOut = new ByteArrayOutputStream(); - try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(crxCsvOut))) { - writer.write(getCrxCsvHeader()); - writer.newLine(); - for (CulturalPrescribedFireReportEntity c : crxRecords) { - writer.write(String.join(",", getCrxCsvRow(c))); + // Only write Cultural Prescribed CSV if there are rows + if (!data.crx.isEmpty()) { + ByteArrayOutputStream crxCsvOut = new ByteArrayOutputStream(); + try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(crxCsvOut))) { + writer.write(getCrxCsvHeader()); writer.newLine(); + for (CulturalPrescribedFireReportEntity c : data.crx) { + writer.write(String.join(",", getCrxCsvRow(c))); + writer.newLine(); + } } + addToZip(zipOut, "cultural-prescribed-fire-projects.csv", crxCsvOut.toByteArray()); } - addToZip(zipOut, "cultural-prescribed-fire-projects.csv", crxCsvOut.toByteArray()); - - } catch (Exception e) { + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("No fiscal data found for the provided projects."); + } catch (IOException e) { log.info("Failed to generate CSV report: {}", e.getMessage(), e); throw new ServiceException("Failed to generate CSV report", e); } @@ -204,12 +236,14 @@ private String safe(Object value) { private void setFuelManagementFields(FuelManagementReportEntity entity) { String urlPrefix = baseUrl + PROJECT_URL_PREFIX; - if (entity.getProjectGuid() != null) { - entity.setLinkToProject(urlPrefix + entity.getProjectGuid()); + if (entity != null) { + if (entity.getProjectGuid() != null) { + entity.setLinkToProject(urlPrefix + entity.getProjectGuid()); + } + if (entity.getProjectPlanFiscalGuid() != null) { entity.setLinkToFiscalActivity( urlPrefix + entity.getProjectGuid() + FISCAL_QUERY_STRING + entity.getProjectPlanFiscalGuid()); - } if (entity.getProgramAreaGuid() != null) { @@ -218,33 +252,29 @@ private void setFuelManagementFields(FuelManagementReportEntity entity) { } // 2025 -> 2025/26 format - String fiscalYear = formatFiscalYearIfNumeric(entity.getFiscalYear()); - if (fiscalYear != null) { - entity.setFiscalYear(fiscalYear); - } + entity.setFiscalYear(formatFiscalYearIfNumeric(entity.getFiscalYear())); } } private void setCrxFields(CulturalPrescribedFireReportEntity entity) { String urlPrefix = baseUrl + PROJECT_URL_PREFIX; - if (entity.getProjectGuid() != null) { - entity.setLinkToProject(urlPrefix + entity.getProjectGuid()); - if (entity.getProjectPlanFiscalGuid() != null) { - entity.setLinkToFiscalActivity( - urlPrefix + entity.getProjectGuid() + FISCAL_QUERY_STRING + entity.getProjectPlanFiscalGuid()); + if (entity != null) { + if(entity.getProjectGuid() != null) { + entity.setLinkToProject(urlPrefix + entity.getProjectGuid()); + if (entity.getProjectPlanFiscalGuid() != null) { + entity.setLinkToFiscalActivity( + urlPrefix + entity.getProjectGuid() + FISCAL_QUERY_STRING + entity.getProjectPlanFiscalGuid()); + } } - } - if (entity.getProgramAreaGuid() != null) { - programAreaRepository.findById(entity.getProgramAreaGuid()) - .ifPresent(programArea -> entity.setBusinessArea(programArea.getProgramAreaName())); - } + if (entity.getProgramAreaGuid() != null) { + programAreaRepository.findById(entity.getProgramAreaGuid()) + .ifPresent(programArea -> entity.setBusinessArea(programArea.getProgramAreaName())); + } - // 2025 -> 2025/26 format - String fiscalYear = formatFiscalYearIfNumeric(entity.getFiscalYear()); - if (fiscalYear != null) { - entity.setFiscalYear(fiscalYear); + // 2025 -> 2025/26 format + entity.setFiscalYear(formatFiscalYearIfNumeric(entity.getFiscalYear())); } } @@ -279,16 +309,16 @@ private String getFuelCsvHeader() { private List getFuelCsvRow(FuelManagementReportEntity e) { return List.of( - safe(String.format("=HYPERLINK(\"%s\", \"%s Project Link\")", - e.getLinkToProject(), e.getProjectName())), - safe(String.format("=HYPERLINK(\"%s\", \"%s Fiscal Activity Link\")", - e.getLinkToFiscalActivity(), e.getProjectFiscalName())), + safe(e.getProjectName() != null ? String.format("=HYPERLINK(\"%s\", \"%s Project Link\")", + e.getLinkToProject(), e.getProjectName()) : ""), + safe(e.getProjectFiscalName() != null ? String.format("=HYPERLINK(\"%s\", \"%s Fiscal Activity Link\")", + e.getLinkToFiscalActivity(), e.getProjectFiscalName()) : ""), safe(e.getProjectTypeDescription()), safe(e.getProjectName()), safe(e.getForestRegionOrgUnitName()), safe(e.getForestDistrictOrgUnitName()), safe(e.getBcParksRegionOrgUnitName()), safe(e.getBcParksSectionOrgUnitName()), safe(e.getFireCentreOrgUnitName()), safe(e.getBusinessArea()), safe(e.getPlanningUnitName()), safe(e.getGrossProjectAreaHa() != null - ? String.format("%,d ha", e.getGrossProjectAreaHa().intValue()) + ? String.format(HECTARE_FORMAT, e.getGrossProjectAreaHa().intValue()) : ""), safe(e.getClosestCommunityName()), safe(e.getProjectLead()), safe(e.getProposalTypeDescription()), safe(e.getProjectFiscalName()), @@ -299,10 +329,10 @@ private List getFuelCsvRow(FuelManagementReportEntity e) { safe(formatMonetaryFields(e.getFiscalReportedSpendAmount())), safe(formatMonetaryFields(e.getFiscalActualAmount())), safe(e.getFiscalPlannedProjectSizeHa() != null - ? String.format("%,d ha", e.getFiscalPlannedProjectSizeHa().intValue()) + ? String.format(HECTARE_FORMAT, e.getFiscalPlannedProjectSizeHa().intValue()) : ""), safe(e.getFiscalCompletedSizeHa() != null - ? String.format("%,d ha", e.getFiscalCompletedSizeHa().intValue()) + ? String.format(HECTARE_FORMAT, e.getFiscalCompletedSizeHa().intValue()) : ""), safe(String.format("=\"%s\"", e.getSpatialSubmitted())), safe(e.getFirstNationsEngagement()), safe(e.getFirstNationsDelivPartners()), @@ -350,16 +380,16 @@ private String getCrxCsvHeader() { private List getCrxCsvRow(CulturalPrescribedFireReportEntity c) { return List.of( - safe(String.format("=HYPERLINK(\"%s\", \"%s Project Link\")", - c.getLinkToProject(), c.getProjectName())), - safe(String.format("=HYPERLINK(\"%s\", \"%s Fiscal Activity Link\")", - c.getLinkToFiscalActivity(), c.getProjectFiscalName())), + safe(c.getProjectName() != null ? String.format("=HYPERLINK(\"%s\", \"%s Project Link\")", + c.getLinkToProject(), c.getProjectName()) : ""), + safe(c.getProjectFiscalName() != null ? String.format("=HYPERLINK(\"%s\", \"%s Fiscal Activity Link\")", + c.getLinkToFiscalActivity(), c.getProjectFiscalName()) : ""), safe(c.getProjectTypeDescription()), safe(c.getProjectName()), safe(c.getForestRegionOrgUnitName()), safe(c.getForestDistrictOrgUnitName()), safe(c.getBcParksRegionOrgUnitName()), safe(c.getBcParksSectionOrgUnitName()), safe(c.getFireCentreOrgUnitName()), safe(c.getBusinessArea()), safe(c.getPlanningUnitName()), safe(c.getGrossProjectAreaHa() != null - ? String.format("%,d ha", c.getGrossProjectAreaHa().intValue()) + ? String.format(HECTARE_FORMAT, c.getGrossProjectAreaHa().intValue()) : ""), safe(c.getClosestCommunityName()), safe(c.getProjectLead()), safe(c.getProposalTypeDescription()), safe(c.getProjectFiscalName()), @@ -370,10 +400,10 @@ private List getCrxCsvRow(CulturalPrescribedFireReportEntity c) { safe(formatMonetaryFields(c.getFiscalReportedSpendAmount())), safe(formatMonetaryFields(c.getFiscalActualAmount())), safe(c.getFiscalPlannedProjectSizeHa() != null - ? String.format("%,d ha", c.getFiscalPlannedProjectSizeHa().intValue()) + ? String.format(HECTARE_FORMAT, c.getFiscalPlannedProjectSizeHa().intValue()) : ""), safe(c.getFiscalCompletedSizeHa() != null - ? String.format("%,d ha", c.getFiscalCompletedSizeHa().intValue()) + ? String.format(HECTARE_FORMAT, c.getFiscalCompletedSizeHa().intValue()) : ""), safe(String.format("=\"%s\"", c.getSpatialSubmitted())), safe(c.getFirstNationsEngagement()), safe(c.getFirstNationsDelivPartners()), @@ -387,7 +417,7 @@ private List getCrxCsvRow(CulturalPrescribedFireReportEntity c) { safe(c.getApprovedTimestamp() != null ? DATE_FORMAT.format(c.getApprovedTimestamp().toInstant()) : ""), - safe(c.getOutsideWuiInd() ? "Y" : "N"), + safe((c.getOutsideWuiInd() != null && c.getOutsideWuiInd()) ? "Y" : "N"), safe(c.getWuiRiskClassDescription()), safe(c.getLocalWuiRiskClassDescription()), safe(c.getTotalRclFilterSectionScore()), safe(c.getRclFilterSectionComment()), safe(c.getTotalBdfFilterSectionScore()), @@ -402,14 +432,14 @@ private static String formatMonetaryFields(Number n) { return new java.text.DecimalFormat("$#,##0").format(n); } - private String formatFiscalYearIfNumeric(Object fiscalYear) { + private String formatFiscalYearIfNumeric(String fiscalYear) { if (fiscalYear == null) - return null; + return ""; try { - int year = Integer.parseInt(fiscalYear.toString()); + int year = Integer.parseInt(fiscalYear); return year + "/" + String.format("%02d", (year + 1) % 100); } catch (NumberFormatException e) { - return null; // keep original value unchanged + return ""; } } @@ -510,4 +540,5 @@ public void setFuelManagementReportData(List data) { } } } + } diff --git a/server/wfprev-api/src/main/resources/native-image/reflection-config.json b/server/wfprev-api/src/main/resources/native-image/reflection-config.json index 32a549b0f..914125e3c 100644 --- a/server/wfprev-api/src/main/resources/native-image/reflection-config.json +++ b/server/wfprev-api/src/main/resources/native-image/reflection-config.json @@ -1536,5 +1536,14 @@ "allPublicFields": true, "allDeclaredMethods": true, "allPublicMethods": true + }, + { + "name": "ca.bc.gov.nrs.wfprev.services.ReportService$ReportDataBundle", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredFields": true, + "allPublicFields": true, + "allDeclaredMethods": true, + "allPublicMethods": true } ] \ No newline at end of file diff --git a/server/wfprev-api/src/main/resources/native-image/serialization-config.json b/server/wfprev-api/src/main/resources/native-image/serialization-config.json index 0e005699c..6c78d8187 100644 --- a/server/wfprev-api/src/main/resources/native-image/serialization-config.json +++ b/server/wfprev-api/src/main/resources/native-image/serialization-config.json @@ -105,5 +105,6 @@ { "name": "ca.bc.gov.nrs.wfprev.services.ReportService$LambdaReportRequest$Report" }, { "name": "ca.bc.gov.nrs.wfprev.services.ReportService$LambdaReportRequest$XlsxReportData" }, { "name": "ca.bc.gov.nrs.wfprev.services.ReportService$LambdaReportResponse" }, - { "name": "ca.bc.gov.nrs.wfprev.services.ReportService$LambdaReportResponse$File" } + { "name": "ca.bc.gov.nrs.wfprev.services.ReportService$LambdaReportResponse$File" }, + { "name": "ca.bc.gov.nrs.wfprev.services.ReportService$ReportDataBundle" } ] diff --git a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/ReportControllerTest.java b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/ReportControllerTest.java index 6d880f1f0..8ae2e2895 100644 --- a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/ReportControllerTest.java +++ b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/ReportControllerTest.java @@ -1,15 +1,10 @@ package ca.bc.gov.nrs.wfprev; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -import java.util.Collections; -import java.util.List; -import java.util.UUID; - +import ca.bc.gov.nrs.wfprev.controllers.ReportController; +import ca.bc.gov.nrs.wfprev.data.models.ReportRequestModel; +import ca.bc.gov.nrs.wfprev.services.ReportService; +import com.nimbusds.jose.shaded.gson.Gson; +import com.nimbusds.jose.shaded.gson.GsonBuilder; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -24,12 +19,22 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; -import com.nimbusds.jose.shaded.gson.Gson; -import com.nimbusds.jose.shaded.gson.GsonBuilder; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.UUID; -import ca.bc.gov.nrs.wfprev.controllers.ReportController; -import ca.bc.gov.nrs.wfprev.data.models.ReportRequestModel; -import ca.bc.gov.nrs.wfprev.services.ReportService; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(ReportController.class) @Import({TestSpringSecurity.class, TestcontainersConfiguration.class}) @@ -56,61 +61,86 @@ void setup() { .create(); } - @Test - @WithMockUser - void testGenerateXlsxReport() throws Exception { - UUID guid = UUID.randomUUID(); - - doAnswer(invocation -> null).when(reportService).exportXlsx(any(), any(), anyString()); - - ReportRequestModel request = new ReportRequestModel(); - request.setReportType("XLSX"); - request.setProjectGuids(List.of(guid)); - - String json = gson.toJson(request); - - mockMvc.perform(post("/reports") - .content(json) - .contentType(MediaType.APPLICATION_JSON) - .header(HttpHeaders.AUTHORIZATION, "Bearer test-token")) - .andExpect(status().isOk()); - } - - @Test - @WithMockUser - void testGenerateCsvReport() throws Exception { - UUID guid = UUID.randomUUID(); - - doAnswer(invocation -> null).when(reportService).writeCsvZipFromEntities(any(), any()); - - ReportRequestModel request = new ReportRequestModel(); - request.setReportType("CSV"); - request.setProjectGuids(List.of(guid)); - - String json = gson.toJson(request); - - mockMvc.perform(post("/reports") - .content(json) - .contentType(MediaType.APPLICATION_JSON) - .header(HttpHeaders.AUTHORIZATION, "Bearer test-token")) - .andExpect(status().isOk()); - } - - @Test - @WithMockUser - void testGenerateReport_InvalidType() throws Exception { - ReportRequestModel request = new ReportRequestModel(); - request.setReportType("TXT"); - request.setProjectGuids(Collections.singletonList(UUID.randomUUID())); - - String json = gson.toJson(request); - - ResultActions result = mockMvc.perform(post("/reports") - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isBadRequest()); - - assertEquals(400, result.andReturn().getResponse().getStatus()); - verifyNoInteractions(reportService); - } + @Test + @WithMockUser + void testGenerateXlsxReport() throws Exception { + UUID guid = UUID.randomUUID(); + + doAnswer(inv -> { + OutputStream os = inv.getArgument(1); + os.write("test-xlsx".getBytes(StandardCharsets.UTF_8)); // simulate XLSX bytes + return null; + }).when(reportService).exportXlsx(any(ReportRequestModel.class), any(OutputStream.class)); + + ReportRequestModel.Project p = new ReportRequestModel.Project(); + p.setProjectGuid(guid); + p.setProjectFiscalGuids(List.of()); + + ReportRequestModel request = new ReportRequestModel(); + request.setReportType("XLSX"); + request.setProjects(List.of(p)); + + String json = gson.toJson(request); + + mockMvc.perform(post("/reports") + .content(json) + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer test-token")) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=project-report.xlsx")) + .andExpect(content().contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")); + + verify(reportService, times(1)) + .exportXlsx(any(ReportRequestModel.class), any(OutputStream.class)); + } + + @Test + @WithMockUser + void testGenerateCsvReport() throws Exception { + UUID guid = UUID.randomUUID(); + + doAnswer(inv -> { + OutputStream os = inv.getArgument(1); + os.write("test-zip".getBytes(StandardCharsets.UTF_8)); + return null; + }).when(reportService).writeCsvZipFromEntities(any(ReportRequestModel.class), any(OutputStream.class)); + + ReportRequestModel.Project p = new ReportRequestModel.Project(); + p.setProjectGuid(guid); + p.setProjectFiscalGuids(List.of()); + + ReportRequestModel request = new ReportRequestModel(); + request.setReportType("CSV"); + request.setProjects(List.of(p)); + + String json = gson.toJson(request); + + mockMvc.perform(post("/reports") + .content(json) + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer test-token")) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=project-report.zip")) + .andExpect(content().contentType("application/zip")); + + verify(reportService, times(1)) + .writeCsvZipFromEntities(any(ReportRequestModel.class), any(OutputStream.class)); + } + + @Test + @WithMockUser + void testGenerateReport_InvalidType() throws Exception { + ReportRequestModel request = new ReportRequestModel(); + request.setReportType("TXT"); + + String json = gson.toJson(request); + + ResultActions result = mockMvc.perform(post("/reports") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()); + + assertEquals(400, result.andReturn().getResponse().getStatus()); + verifyNoInteractions(reportService); + } } diff --git a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/data/assemblers/CulturalPrescribedFireReportRepositoryTest.java b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/data/assemblers/CulturalPrescribedFireReportRepositoryTest.java index 63643584d..6f443db88 100644 --- a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/data/assemblers/CulturalPrescribedFireReportRepositoryTest.java +++ b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/data/assemblers/CulturalPrescribedFireReportRepositoryTest.java @@ -5,11 +5,22 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.UUID; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; class CulturalPrescribedFireReportRepositoryTest { @@ -18,46 +29,83 @@ class CulturalPrescribedFireReportRepositoryTest { private UUID projectGuid; private UUID fiscalGuid; + private CulturalPrescribedFireReportEntity mockEntity; + @BeforeEach void setUp() { repository = mock(CulturalPrescribedFireReportRepository.class); projectGuid = UUID.randomUUID(); - fiscalGuid = UUID.randomUUID(); + fiscalGuid = UUID.randomUUID(); - CulturalPrescribedFireReportEntity mockEntity = new CulturalPrescribedFireReportEntity(); + mockEntity = new CulturalPrescribedFireReportEntity(); + mockEntity.setUniqueRowGuid(UUID.randomUUID()); mockEntity.setProjectGuid(projectGuid); mockEntity.setProjectPlanFiscalGuid(fiscalGuid); mockEntity.setProjectName("Test Cultural Report"); - when(repository.findByProjectGuidIn(List.of(projectGuid))) + when(repository.findByProjectGuid(projectGuid)) .thenReturn(List.of(mockEntity)); - when(repository.findByProjectPlanFiscalGuidIn(List.of(fiscalGuid))) + when(repository.findByProjectGuidAndProjectPlanFiscalGuidIn(eq(projectGuid), any(Collection.class))) + .thenAnswer(inv -> { + Collection guids = inv.getArgument(1); + return guids != null && guids.contains(fiscalGuid) + ? List.of(mockEntity) + : Collections.emptyList(); + }); + + when(repository.findByProjectGuid(any(UUID.class))) + .thenReturn(Collections.emptyList()); + when(repository.findByProjectGuid(projectGuid)) .thenReturn(List.of(mockEntity)); } @Test - void testFindByProjectGuidIn_ReturnsExpectedResult() { - List results = repository.findByProjectGuidIn(List.of(projectGuid)); + void findByProjectGuid_returnsExpectedRows() { + List results = + repository.findByProjectGuid(projectGuid); assertNotNull(results, "Result list should not be null"); assertFalse(results.isEmpty(), "Result list should not be empty"); - assertEquals(projectGuid, results.get(0).getProjectGuid(), "Project GUID should match"); - assertEquals("Test Cultural Report", results.get(0).getProjectName(), "Project name should match"); - verify(repository, times(1)).findByProjectGuidIn(List.of(projectGuid)); + CulturalPrescribedFireReportEntity e = results.get(0); + assertNotNull(e.getUniqueRowGuid(), "unique_row_guid should be present"); + assertEquals(projectGuid, e.getProjectGuid(), "Project GUID should match"); + assertEquals("Test Cultural Report", e.getProjectName(), "Project name should match"); + + verify(repository, times(1)).findByProjectGuid(projectGuid); + verifyNoMoreInteractions(repository); } @Test - void testFindByProjectPlanFiscalGuidIn_ReturnsExpectedResult() { - List results = repository.findByProjectPlanFiscalGuidIn(List.of(fiscalGuid)); + void findByProjectGuidAndProjectPlanFiscalGuidIn_returnsExpectedRows() { + List results = + repository.findByProjectGuidAndProjectPlanFiscalGuidIn( + projectGuid, Collections.singleton(fiscalGuid)); assertNotNull(results, "Result list should not be null"); assertFalse(results.isEmpty(), "Result list should not be empty"); - assertEquals(fiscalGuid, results.get(0).getProjectPlanFiscalGuid(), "Fiscal GUID should match"); - assertEquals("Test Cultural Report", results.get(0).getProjectName(), "Project name should match"); - verify(repository, times(1)).findByProjectPlanFiscalGuidIn(List.of(fiscalGuid)); + CulturalPrescribedFireReportEntity e = results.get(0); + assertEquals(fiscalGuid, e.getProjectPlanFiscalGuid(), "Fiscal GUID should match"); + assertEquals("Test Cultural Report", e.getProjectName(), "Project name should match"); + + verify(repository, times(1)) + .findByProjectGuidAndProjectPlanFiscalGuidIn(eq(projectGuid), any(Collection.class)); + verifyNoMoreInteractions(repository); + } + + @Test + void findByProjectGuidAndProjectPlanFiscalGuidIn_emptyCollection_returnsEmpty() { + List results = + repository.findByProjectGuidAndProjectPlanFiscalGuidIn(projectGuid, Collections.emptyList()); + + assertNotNull(results, "Result list should not be null"); + assertTrue(results.isEmpty(), "Result list should be empty"); + + verify(repository, times(1)) + .findByProjectGuidAndProjectPlanFiscalGuidIn(eq(projectGuid), any(Collection.class)); + verifyNoMoreInteractions(repository); } } diff --git a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/data/assemblers/FuelManagementReportRepositoryTest.java b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/data/assemblers/FuelManagementReportRepositoryTest.java index f390b5b35..78f707767 100644 --- a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/data/assemblers/FuelManagementReportRepositoryTest.java +++ b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/data/assemblers/FuelManagementReportRepositoryTest.java @@ -14,6 +14,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; class FuelManagementReportRepositoryTest { @@ -27,42 +28,48 @@ class FuelManagementReportRepositoryTest { void setUp() { repository = mock(FuelManagementReportRepository.class); + UUID uniqueRowGuid = UUID.randomUUID(); projectGuid = UUID.randomUUID(); fiscalGuid = UUID.randomUUID(); FuelManagementReportEntity mockEntity = new FuelManagementReportEntity(); + mockEntity.setUniqueRowGuid(uniqueRowGuid); mockEntity.setProjectGuid(projectGuid); mockEntity.setProjectPlanFiscalGuid(fiscalGuid); mockEntity.setProjectName("Test Fuel Report"); - when(repository.findByProjectGuidIn(List.of(projectGuid))) + when(repository.findByProjectGuid(projectGuid)) .thenReturn(List.of(mockEntity)); - when(repository.findByProjectPlanFiscalGuidIn(List.of(fiscalGuid))) + when(repository.findByProjectGuidAndProjectPlanFiscalGuidIn(projectGuid, List.of(fiscalGuid))) .thenReturn(List.of(mockEntity)); } @Test - void testFindByProjectGuidIn_ReturnsExpectedResult() { - List results = repository.findByProjectGuidIn(List.of(projectGuid)); + void findByProjectGuid_returnsRowsIncludingNullFiscalOnesInRealDB() { + List results = repository.findByProjectGuid(projectGuid); assertNotNull(results); assertFalse(results.isEmpty()); assertEquals(projectGuid, results.get(0).getProjectGuid()); assertEquals("Test Fuel Report", results.get(0).getProjectName()); - verify(repository, times(1)).findByProjectGuidIn(List.of(projectGuid)); + verify(repository, times(1)).findByProjectGuid(projectGuid); + verifyNoMoreInteractions(repository); } @Test - void testFindByProjectPlanFiscalGuidIn_ReturnsExpectedResult() { - List results = repository.findByProjectPlanFiscalGuidIn(List.of(fiscalGuid)); + void findByProjectGuidAndProjectPlanFiscalGuidIn_filtersByFiscal() { + List results = + repository.findByProjectGuidAndProjectPlanFiscalGuidIn(projectGuid, List.of(fiscalGuid)); assertNotNull(results); assertFalse(results.isEmpty()); assertEquals(fiscalGuid, results.get(0).getProjectPlanFiscalGuid()); assertEquals("Test Fuel Report", results.get(0).getProjectName()); - verify(repository, times(1)).findByProjectPlanFiscalGuidIn(List.of(fiscalGuid)); + verify(repository, times(1)) + .findByProjectGuidAndProjectPlanFiscalGuidIn(projectGuid, List.of(fiscalGuid)); + verifyNoMoreInteractions(repository); } -} +} \ No newline at end of file diff --git a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/services/ReportServiceTest.java b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/services/ReportServiceTest.java new file mode 100644 index 000000000..790359167 --- /dev/null +++ b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/services/ReportServiceTest.java @@ -0,0 +1,393 @@ +package ca.bc.gov.nrs.wfprev.services; + +import ca.bc.gov.nrs.wfprev.common.exceptions.ServiceException; +import ca.bc.gov.nrs.wfprev.data.entities.CulturalPrescribedFireReportEntity; +import ca.bc.gov.nrs.wfprev.data.entities.FuelManagementReportEntity; +import ca.bc.gov.nrs.wfprev.data.models.ReportRequestModel; +import ca.bc.gov.nrs.wfprev.data.repositories.CulturalPrescribedFireReportRepository; +import ca.bc.gov.nrs.wfprev.data.repositories.FuelManagementReportRepository; +import ca.bc.gov.nrs.wfprev.data.repositories.ProgramAreaRepository; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.lang.reflect.Field; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ReportServiceTest { + + private FuelManagementReportRepository fuelRepo; + private CulturalPrescribedFireReportRepository crxRepo; + private ProgramAreaRepository programAreaRepo; + + private ReportService service; + + @BeforeEach + void setup() throws Exception { + fuelRepo = mock(FuelManagementReportRepository.class); + crxRepo = mock(CulturalPrescribedFireReportRepository.class); + programAreaRepo = mock(ProgramAreaRepository.class); + + service = new ReportService(fuelRepo, crxRepo, programAreaRepo); + + setField(service, "baseUrl", "https://example.gov.bc.ca"); + setField(service, "reportGeneratorLambdaUrl", "http://invalid/override-me-in-test"); + } + + @Test + void resolveReportData_nullRequest_throwsIllegalArgument() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> service.writeCsvZipFromEntities(null, new ByteArrayOutputStream()) + ); + assertTrue(ex.getMessage().contains("At least one project is required")); + } + + @Test + void resolveReportData_emptyProjects_throwsIllegalArgument() { + ReportRequestModel req = new ReportRequestModel(); + IllegalArgumentException ex1 = assertThrows( + IllegalArgumentException.class, + () -> service.writeCsvZipFromEntities(req, new ByteArrayOutputStream()) + ); + assertTrue(ex1.getMessage().contains("At least one project is required")); + + req.setProjects(Collections.emptyList()); + IllegalArgumentException ex2 = assertThrows( + IllegalArgumentException.class, + () -> service.writeCsvZipFromEntities(req, new ByteArrayOutputStream()) + ); + assertTrue(ex2.getMessage().contains("At least one project is required")); + } + + @Test + void resolveReportData_noFiscalGuids_usesFindByProjectGuid() throws Exception { + UUID proj = UUID.randomUUID(); + + when(fuelRepo.findByProjectGuid(proj)) + .thenReturn(List.of(fuel(proj,null, "Fuel N"))); + when(crxRepo.findByProjectGuid(proj)) + .thenReturn(List.of(crx(proj,null, "CRX N"))); + + ReportRequestModel req = requestWithProjects(List.of(project(proj, /*fiscals*/ null))); + + service.writeCsvZipFromEntities(req, new ByteArrayOutputStream()); + + verify(fuelRepo, times(1)).findByProjectGuid(proj); + verify(crxRepo, times(1)).findByProjectGuid(proj); + verify(fuelRepo, never()).findByProjectGuidAndProjectPlanFiscalGuidIn(any(), any()); + verify(crxRepo, never()).findByProjectGuidAndProjectPlanFiscalGuidIn(any(), any()); + } + + @Test + void resolveReportData_withFiscalGuids_usesFindByProjectGuidAndFiscal() throws Exception { + UUID proj = UUID.randomUUID(); + UUID fiscal = UUID.randomUUID(); + List fiscals = List.of(fiscal); + + when(fuelRepo.findByProjectGuidAndProjectPlanFiscalGuidIn(eq(proj), eq(fiscals))) + .thenReturn(List.of(fuel(proj, fiscal, "Fuel F"))); + when(crxRepo.findByProjectGuidAndProjectPlanFiscalGuidIn(eq(proj), eq(fiscals))) + .thenReturn(List.of(crx(proj, fiscal, "CRX F"))); + + ReportRequestModel req = requestWithProjects(List.of(project(proj, fiscals))); + + service.writeCsvZipFromEntities(req, new ByteArrayOutputStream()); + + verify(fuelRepo, times(1)).findByProjectGuidAndProjectPlanFiscalGuidIn(eq(proj), eq(fiscals)); + verify(crxRepo, times(1)).findByProjectGuidAndProjectPlanFiscalGuidIn(eq(proj), eq(fiscals)); + verify(fuelRepo, never()).findByProjectGuid(proj); + verify(crxRepo, never()).findByProjectGuid(proj); + } + + + @Test + void writeCsvZip_onlyFuel_present_writesOneCsv() throws Exception { + UUID projectGuid = UUID.randomUUID(); + UUID fiscalGuid = UUID.randomUUID(); + + when(fuelRepo.findByProjectGuid(projectGuid)).thenReturn(List.of(fuel(projectGuid, fiscalGuid, "Fuel A"))); + when(crxRepo.findByProjectGuid(projectGuid)).thenReturn(Collections.emptyList()); + when(programAreaRepo.findById(any())).thenReturn(Optional.empty()); + + ReportRequestModel req = requestWithProjects(List.of(project(projectGuid, null))); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + service.writeCsvZipFromEntities(req, out); + + Set entries = zipEntries(out.toByteArray()); + assertTrue(entries.contains("fuel-management-projects.csv")); + assertFalse(entries.contains("cultural-prescribed-fire-projects.csv")); + } + + @Test + void writeCsvZip_onlyCrx_present_writesOneCsv() throws Exception { + UUID projectGuid = UUID.randomUUID(); + UUID fiscalGuid = UUID.randomUUID(); + + when(fuelRepo.findByProjectGuid(projectGuid)).thenReturn(Collections.emptyList()); + when(crxRepo.findByProjectGuid(projectGuid)).thenReturn(List.of(crx(projectGuid, fiscalGuid, "CRX A"))); + when(programAreaRepo.findById(any())).thenReturn(Optional.empty()); + + ReportRequestModel req = requestWithProjects(List.of(project(projectGuid, null))); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + service.writeCsvZipFromEntities(req, out); + + Set entries = zipEntries(out.toByteArray()); + assertFalse(entries.contains("fuel-management-projects.csv")); + assertTrue(entries.contains("cultural-prescribed-fire-projects.csv")); + } + + @Test + void writeCsvZip_noData_throwsIllegalArgument() { + UUID projectGuid = UUID.randomUUID(); + when(fuelRepo.findByProjectGuid(projectGuid)).thenReturn(Collections.emptyList()); + when(crxRepo.findByProjectGuid(projectGuid)).thenReturn(Collections.emptyList()); + + ReportRequestModel req = requestWithProjects(List.of(project(projectGuid, null))); + + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> service.writeCsvZipFromEntities(req, new ByteArrayOutputStream()) + ); + assertTrue(ex.getMessage().toLowerCase().contains("no fiscal data")); + } + + + @Test + void exportXlsx_success_writesReturnedBytes() throws Exception { + byte[] xlsxBytes = "test-xlsx-contents".getBytes(StandardCharsets.UTF_8); + String payload = lambdaResponseWithSingleFile( + "project-report.xlsx", + Base64.getEncoder().encodeToString(xlsxBytes) + ); + + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/lambda", (HttpExchange ex) -> { + byte[] response = payload.getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().add("Content-Type", "application/json"); + ex.sendResponseHeaders(200, response.length); + try (OutputStream os = ex.getResponseBody()) { + os.write(response); + } + }); + + try (var ignored = start(server)) { + String url = "http://localhost:" + server.getAddress().getPort() + "/lambda"; + setField(service, "reportGeneratorLambdaUrl", url); + + UUID projectGuid = UUID.randomUUID(); + UUID fiscalGuid = UUID.randomUUID(); + when(fuelRepo.findByProjectGuid(projectGuid)).thenReturn(List.of(fuel(projectGuid, fiscalGuid, "Fuel X"))); + when(crxRepo.findByProjectGuid(projectGuid)).thenReturn(List.of(crx(projectGuid, fiscalGuid, "CRX X"))); + when(programAreaRepo.findById(any())).thenReturn(Optional.empty()); + + ReportRequestModel req = requestWithProjects(List.of(project(projectGuid, null))); + + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + service.exportXlsx(req, out); + assertArrayEquals(xlsxBytes, out.toByteArray(), + "Should write exactly the XLSX bytes returned by Lambda"); + } + } + } + + @Test + void exportXlsx_lambdaNon200_throwsServiceException() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/lambda", (HttpExchange ex) -> { + byte[] response = "boom".getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().add("Content-Type", "text/plain"); + ex.sendResponseHeaders(500, response.length); + try (OutputStream os = ex.getResponseBody()) { + os.write(response); + } + }); + server.start(); + try { + String url = "http://localhost:" + server.getAddress().getPort() + "/lambda"; + setField(service, "reportGeneratorLambdaUrl", url); + + UUID proj = UUID.randomUUID(); + when(fuelRepo.findByProjectGuid(proj)).thenReturn(List.of(fuel(proj, null, "Fuel Y"))); + when(crxRepo.findByProjectGuid(proj)).thenReturn(Collections.emptyList()); + + ReportRequestModel req = requestWithProjects(List.of(project(proj, null))); + + ServiceException ex = assertThrows( + ServiceException.class, + () -> service.exportXlsx(req, new ByteArrayOutputStream()) + ); + assertTrue(ex.getMessage().contains("Lambda returned error")); + } finally { + server.stop(0); + } + } + + @Test + void exportXlsx_lambdaReturnsNoFiles_throwsServiceException() throws Exception { + String payload = "{\"files\":[]}"; + + com.sun.net.httpserver.HttpServer server = com.sun.net.httpserver.HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/lambda", ex -> { + byte[] resp = payload.getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().add("Content-Type", "application/json"); + ex.sendResponseHeaders(200, resp.length); + try (OutputStream os = ex.getResponseBody()) { os.write(resp); } + }); + + try (var ignored = start(server)) { + String url = "http://localhost:" + server.getAddress().getPort() + "/lambda"; + setField(service, "reportGeneratorLambdaUrl", url); + + UUID proj = UUID.randomUUID(); + when(fuelRepo.findByProjectGuid(proj)).thenReturn(List.of(fuel(proj, null, "Fuel Q"))); + when(crxRepo.findByProjectGuid(proj)).thenReturn(Collections.emptyList()); + + ReportRequestModel req = requestWithProjects(List.of(project(proj, null))); + + ServiceException ex = assertThrows( + ServiceException.class, + () -> service.exportXlsx(req, new ByteArrayOutputStream()) + ); + assertTrue(ex.getMessage().contains("No files returned")); + } + } + + @Test + void exportXlsx_noData_throwsIllegalArgument() throws Exception { + UUID proj = UUID.randomUUID(); + when(fuelRepo.findByProjectGuid(proj)).thenReturn(Collections.emptyList()); + when(crxRepo.findByProjectGuid(proj)).thenReturn(Collections.emptyList()); + + ReportRequestModel req = requestWithProjects(List.of(project(proj, null))); + + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> service.exportXlsx(req, new ByteArrayOutputStream()) + ); + assertTrue(ex.getMessage().toLowerCase().contains("no fiscal data")); + } + + @Test + void exportXlsx_missingLambdaUrl_throwsServiceException() throws Exception { + UUID proj = UUID.randomUUID(); + UUID fiscal = UUID.randomUUID(); + + when(fuelRepo.findByProjectGuid(proj)).thenReturn(List.of(fuel(proj, fiscal, "Fuel Z"))); + when(crxRepo.findByProjectGuid(proj)).thenReturn(List.of(crx(proj, fiscal, "CRX Z"))); + + // Blank URL triggers the ServiceException + setField(service, "reportGeneratorLambdaUrl", ""); + + ReportRequestModel req = requestWithProjects(List.of(project(proj, null))); + + ServiceException ex = assertThrows( + ServiceException.class, + () -> service.exportXlsx(req, new ByteArrayOutputStream()) + ); + assertTrue(ex.getMessage().contains("REPORT_GENERATOR_LAMBDA_URL")); + } + + + private static void setField(Object target, String fieldName, Object value) throws Exception { + Field f = target.getClass().getDeclaredField(fieldName); + f.setAccessible(true); + f.set(target, value); + } + + private static Set zipEntries(byte[] zipBytes) throws IOException { + Set names = new HashSet<>(); + try (ZipInputStream zin = new ZipInputStream(new ByteArrayInputStream(zipBytes))) { + ZipEntry e; + while ((e = zin.getNextEntry()) != null) { + names.add(e.getName()); + } + } + return names; + } + + private static ReportRequestModel requestWithProjects(List projects) { + ReportRequestModel req = new ReportRequestModel(); + req.setProjects(projects); + return req; + } + + private static ReportRequestModel.Project project(UUID projectGuid, List fiscalsOrNull) { + ReportRequestModel.Project p = new ReportRequestModel.Project(); + p.setProjectGuid(projectGuid); + if (fiscalsOrNull != null) { + p.setProjectFiscalGuids(fiscalsOrNull); + } + return p; + } + + private static FuelManagementReportEntity fuel(UUID projectGuid, UUID fiscalGuid, String name) { + FuelManagementReportEntity e = new FuelManagementReportEntity(); + e.setUniqueRowGuid(UUID.randomUUID()); + e.setProjectGuid(projectGuid); + e.setProjectPlanFiscalGuid(fiscalGuid); + e.setProjectName(name); + e.setProjectFiscalName("Fiscal " + name); + e.setFiscalYear("2025"); + return e; + } + + private static CulturalPrescribedFireReportEntity crx(UUID projectGuid, UUID fiscalGuid, String name) { + CulturalPrescribedFireReportEntity e = new CulturalPrescribedFireReportEntity(); + e.setUniqueRowGuid(UUID.randomUUID()); + e.setProjectGuid(projectGuid); + e.setProjectPlanFiscalGuid(fiscalGuid); + e.setProjectName(name); + e.setProjectFiscalName("Fiscal " + name); + e.setFiscalYear("2025"); + return e; + } + + private static AutoCloseable start(HttpServer server) { + server.start(); + return () -> server.stop(0); + } + + private static String lambdaResponseWithSingleFile(String filename, String base64) { + return "{\n" + + " \"files\": [\n" + + " {\n" + + " \"filename\": \"" + filename + "\",\n" + + " \"content\": \"" + base64 + "\"\n" + + " }\n" + + " ]\n" + + "}"; + } + +}