diff --git a/api/src/modules/impact/comparison/actual-vs-scenario.service.ts b/api/src/modules/impact/comparison/actual-vs-scenario.service.ts index a896439f7..e4d47ab26 100644 --- a/api/src/modules/impact/comparison/actual-vs-scenario.service.ts +++ b/api/src/modules/impact/comparison/actual-vs-scenario.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { GetActualVsScenarioImpactTableDto, + GROUP_BY_VALUES, ORDER_BY, } from 'modules/impact/dto/impact-table.dto'; import { IndicatorsService } from 'modules/indicators/indicators.service'; @@ -408,7 +409,7 @@ export class ActualVsScenarioImpactService { private createActualVsScenarioImpactTableDataByIndicator( indicator: Indicator, - groupBy: string, + groupBy: GROUP_BY_VALUES, ): ActualVsScenarioImpactTableDataByIndicator { return { indicatorShortName: indicator.shortName as string, diff --git a/api/src/modules/impact/comparison/scenario-vs-scenario.service.ts b/api/src/modules/impact/comparison/scenario-vs-scenario.service.ts index d200ce44d..dd4fb13b8 100644 --- a/api/src/modules/impact/comparison/scenario-vs-scenario.service.ts +++ b/api/src/modules/impact/comparison/scenario-vs-scenario.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { GetActualVsScenarioImpactTableDto, GetScenarioVsScenarioImpactTableDto, + GROUP_BY_VALUES, ORDER_BY, } from 'modules/impact/dto/impact-table.dto'; import { IndicatorsService } from 'modules/indicators/indicators.service'; @@ -503,7 +504,7 @@ export class ScenarioVsScenarioImpactService { private createScenarioVsScenarioImpactTableDataByIndicator( indicator: Indicator, - groupBy: string, + groupBy: GROUP_BY_VALUES, ): ScenarioVsScenarioImpactTableDataByIndicator { return { indicatorShortName: indicator.shortName as string, diff --git a/api/src/modules/impact/dto/impact-table.dto.ts b/api/src/modules/impact/dto/impact-table.dto.ts index 6ca3705d7..c0beb7cb6 100644 --- a/api/src/modules/impact/dto/impact-table.dto.ts +++ b/api/src/modules/impact/dto/impact-table.dto.ts @@ -65,7 +65,7 @@ export class BaseImpactTableDto { @IsEnum(GROUP_BY_VALUES, { message: 'Available options: ' + Object.values(GROUP_BY_VALUES).toString(), }) - groupBy!: string; + groupBy!: GROUP_BY_VALUES; @ApiPropertyOptional({ name: 'materialIds[]' }) @IsOptional() diff --git a/api/src/modules/impact/dto/response-actual-scenario.dto.ts b/api/src/modules/impact/dto/response-actual-scenario.dto.ts index 3893469fe..be076a46c 100644 --- a/api/src/modules/impact/dto/response-actual-scenario.dto.ts +++ b/api/src/modules/impact/dto/response-actual-scenario.dto.ts @@ -5,6 +5,7 @@ import { ImpactTableBaseRowsValues, ImpactTablePurchasedTonnes, } from 'modules/impact/dto/response-impact-table.dto'; +import { GROUP_BY_VALUES } from 'modules/impact/dto/impact-table.dto'; export class ActualVsScenarioImpactTable { @ApiProperty({ @@ -32,7 +33,7 @@ export class ActualVsScenarioImpactTableDataByIndicator { @ApiProperty() indicatorId: string; @ApiProperty() - groupBy: string; + groupBy: GROUP_BY_VALUES; @ApiProperty({ type: () => ActualVsScenarioImpactTableRows, isArray: true }) rows: ActualVsScenarioImpactTableRows[]; @ApiProperty({ @@ -52,6 +53,7 @@ export class ActualVsScenarioImpactTablePurchasedTonnes { @ApiProperty() isProjected: boolean; } + export class ActualVsScenarioImpactTableRows { @ApiProperty() name: string; diff --git a/api/src/modules/impact/dto/response-impact-table.dto.ts b/api/src/modules/impact/dto/response-impact-table.dto.ts index 45af65a0e..584f57e98 100644 --- a/api/src/modules/impact/dto/response-impact-table.dto.ts +++ b/api/src/modules/impact/dto/response-impact-table.dto.ts @@ -8,6 +8,7 @@ import { ScenarioVsScenarioImpactTableRows, ScenarioVsScenarioImpactTableRowsValues, } from 'modules/impact/dto/response-scenario-scenario.dto'; +import { GROUP_BY_VALUES } from 'modules/impact/dto/impact-table.dto'; export class ImpactTable { @ApiProperty({ type: () => ImpactTableDataByIndicator, isArray: true }) @@ -28,6 +29,7 @@ export class ImpactTableDataAggregationInfo { numberOfAggregatedEntities: number; sort: string; } + export class ImpactTableDataAggregatedValue { year: number; value: number; @@ -39,7 +41,7 @@ export class ImpactTableDataByIndicator { @ApiProperty() indicatorId: string; @ApiProperty() - groupBy: string; + groupBy: GROUP_BY_VALUES; @ApiProperty({ type: () => ImpactTableRows, isArray: true }) rows: ImpactTableRows[]; @ApiProperty({ type: () => YearSumData, isArray: true }) @@ -61,6 +63,7 @@ export class ImpactTablePurchasedTonnes { @ApiProperty() isProjected: boolean; } + export class ImpactTableRows { @ApiProperty() name: string; @@ -74,22 +77,26 @@ export class ImpactTableRows { }) children: ImpactTableRows[]; } + export class BaseIndicatorSumByYearData { @ApiProperty() year: number; @ApiProperty() isProjected: boolean; } + export class YearSumData { @ApiProperty() value: number; } + export class ImpactTableBaseRowsValues { @ApiProperty() year: number; @ApiProperty() isProjected: boolean; } + export class ImpactTableRowsValues extends ImpactTableBaseRowsValues { @ApiProperty() value: number; diff --git a/api/src/modules/impact/dto/response-scenario-scenario.dto.ts b/api/src/modules/impact/dto/response-scenario-scenario.dto.ts index 4caf7bf68..e08da9da9 100644 --- a/api/src/modules/impact/dto/response-scenario-scenario.dto.ts +++ b/api/src/modules/impact/dto/response-scenario-scenario.dto.ts @@ -5,6 +5,7 @@ import { ImpactTableBaseRowsValues, ImpactTablePurchasedTonnes, } from 'modules/impact/dto/response-impact-table.dto'; +import { GROUP_BY_VALUES } from 'modules/impact/dto/impact-table.dto'; export class ScenarioVsScenarioImpactTable { @ApiProperty({ @@ -32,7 +33,7 @@ export class ScenarioVsScenarioImpactTableDataByIndicator { @ApiProperty() indicatorId: string; @ApiProperty() - groupBy: string; + groupBy: GROUP_BY_VALUES; @ApiProperty({ type: () => ScenarioVsScenarioImpactTableRows, isArray: true }) rows: ScenarioVsScenarioImpactTableRows[]; @ApiProperty({ @@ -52,6 +53,7 @@ export class ScenarioVsScenarioImpactTablePurchasedTonnes { @ApiProperty() isProjected: boolean; } + export class ScenarioVsScenarioImpactTableRows { @ApiProperty() name: string; diff --git a/api/src/modules/impact/impact-report.controller.ts b/api/src/modules/impact/impact-report.controller.ts index abd3d450b..7b56075d9 100644 --- a/api/src/modules/impact/impact-report.controller.ts +++ b/api/src/modules/impact/impact-report.controller.ts @@ -2,23 +2,21 @@ import { Controller, Get, Query, + Res, UseInterceptors, ValidationPipe, } from '@nestjs/common'; import { ImpactService } from 'modules/impact/impact.service'; import { ActualVsScenarioImpactService } from 'modules/impact/comparison/actual-vs-scenario.service'; import { ScenarioVsScenarioImpactService } from 'modules/impact/comparison/scenario-vs-scenario.service'; -import { ApiOkResponse, ApiOperation } from '@nestjs/swagger'; -import { - ImpactTable, - PaginatedImpactTable, -} from './dto/response-impact-table.dto'; -import { JSONAPIPaginationQueryParams } from '../../decorators/json-api-parameters.decorator'; -import { SetScenarioIdsInterceptor } from './set-scenario-ids.interceptor'; -import { GetImpactTableDto } from './dto/impact-table.dto'; -import { ImpactReportService } from './impact.report'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { SetScenarioIdsInterceptor } from 'modules/impact/set-scenario-ids.interceptor'; +import { ImpactReportService } from 'modules/impact/impact.report'; +import { Response } from 'express'; +import { GetImpactTableDto } from 'modules/impact/dto/impact-table.dto'; @Controller('/api/v1/impact') +@ApiTags('Impact') export class ImpactReportController { constructor( private readonly impactService: ImpactService, @@ -30,15 +28,23 @@ export class ImpactReportController { @ApiOperation({ description: 'Get a Impact Table CSV Report', }) - @JSONAPIPaginationQueryParams() @UseInterceptors(SetScenarioIdsInterceptor) @Get('table/report') async getImpactTable( @Query(ValidationPipe) impactTableDto: GetImpactTableDto, - ): Promise { - const table: any = await this.impactService.getImpactTable(impactTableDto, { + @Res() res: Response, + ): Promise { + const { data } = await this.impactService.getImpactTable(impactTableDto, { disablePagination: true, }); - return this.impactReports.generateImpactReport(table); + const report: string = await this.impactReports.generateImpactReport( + data.impactTable, + ); + res.setHeader('Content-Type', 'text/csv'); + res.setHeader( + 'Content-Disposition', + 'attachment; filename=impact_report.csv', + ); + res.send(report); } } diff --git a/api/src/modules/impact/impact.report.ts b/api/src/modules/impact/impact.report.ts index be01fe6fd..0a884dca1 100644 --- a/api/src/modules/impact/impact.report.ts +++ b/api/src/modules/impact/impact.report.ts @@ -1,17 +1,102 @@ import { Inject, Injectable } from '@nestjs/common'; -import { IReportService } from 'modules/reports/report-service.interface'; +import { ICSVReportService } from 'modules/reports/report-service.interface'; import { ReportServiceToken } from 'modules/reports/reports.module'; +import { + ImpactTableDataByIndicator, + ImpactTableRows, +} from 'modules/impact/dto/response-impact-table.dto'; +import { ImpactTableCSVReport } from 'modules/impact/table-reports/types'; +import { ParserOptions } from '@json2csv/plainjs'; +import { GROUP_BY_VALUES } from 'modules/impact/dto/impact-table.dto'; @Injectable() export class ImpactReportService { constructor( - @Inject(ReportServiceToken) private reportService: IReportService, + @Inject(ReportServiceToken) private reportService: ICSVReportService, ) {} - async generateImpactReport(data: any): Promise { - const parserOptions: { fields: ['line', 'error'] } = { - fields: ['line', 'error'], + async generateImpactReport( + data: ImpactTableDataByIndicator[], + ): Promise { + const parsedData: ImpactTableCSVReport[] = this.parseToCSVShape(data); + const columnOrder: Pick = { + fields: this.getColumnOrder(parsedData[0]), }; - return this.reportService.generateReport(data, {}); + + return this.reportService.generateCSVReport(parsedData, columnOrder); + } + + private parseToCSVShape( + data: ImpactTableDataByIndicator[], + ): ImpactTableCSVReport[] { + const results: ImpactTableCSVReport[] = []; + + data.forEach((indicator: ImpactTableDataByIndicator) => { + const unit: string = indicator.metadata.unit; + indicator.rows.forEach((row: ImpactTableRows) => { + this.processNode( + row, + indicator.indicatorShortName, + indicator.groupBy, + unit, + results, + ); + }); + }); + + return results; + } + + private processNode( + node: ImpactTableRows, + indicatorName: + | Pick + | string, + groupBy: GROUP_BY_VALUES, + unit: string, + accumulator: ImpactTableCSVReport[], + ): void { + const groupName: string = `Group by ${groupBy}`; + const targets: ImpactTableRows[] = + node.children && node.children.length > 0 ? node.children : [node]; + + targets.forEach((target: any) => { + const resultObject: any = { + [`Indicator`]: `${indicatorName} (${unit})`, + [groupName]: target.name, + }; + + if (target.values && target.values.length > 0) { + target.values.forEach((value: any) => { + let yearKey: string = value.year.toString(); + if (value.isProjected) { + yearKey = yearKey.concat(' (projected)'); + } + resultObject[yearKey] = value.value; + }); + + accumulator.push(resultObject); + } + + if (target.children && target.children.length > 0) { + this.processNode(target, indicatorName, groupBy, unit, accumulator); + } + }); + } + + private getColumnOrder(dataSample: ImpactTableCSVReport): string[] { + const indicatorKey: string[] = []; + const groupByKey: string[] = []; + const yearKeys: string[] = []; + Object.keys(dataSample).forEach((key: string) => { + if (key.includes('Indicator')) { + groupByKey.push(key); + } else if (key.includes('Group by')) { + groupByKey.push(key); + } else { + yearKeys.push(key); + } + }); + return [...indicatorKey, ...groupByKey, ...yearKeys]; } } diff --git a/api/src/modules/impact/impact.service.ts b/api/src/modules/impact/impact.service.ts index 8c37d7a36..eedc9ded6 100644 --- a/api/src/modules/impact/impact.service.ts +++ b/api/src/modules/impact/impact.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { GetImpactTableDto, GetRankedImpactTableDto, + GROUP_BY_VALUES, ORDER_BY, } from 'modules/impact/dto/impact-table.dto'; import { IndicatorsService } from 'modules/indicators/indicators.service'; @@ -485,7 +486,7 @@ export class ImpactService { private createImpactTableDataByIndicator( indicator: Indicator, - groupBy: string, + groupBy: GROUP_BY_VALUES, ): ImpactTableDataByIndicator { return { indicatorShortName: indicator.shortName as string, diff --git a/api/src/modules/impact/table-reports/types.ts b/api/src/modules/impact/table-reports/types.ts new file mode 100644 index 000000000..ba6375ec2 --- /dev/null +++ b/api/src/modules/impact/table-reports/types.ts @@ -0,0 +1,14 @@ +type YearData = { + [key: string]: string | number; +}; + +type indicatorField = { + Indicator: string; +}; +type DynamicGroupByField = { + [key: `Group by ${string}`]: string; +}; + +export type ImpactTableCSVReport = DynamicGroupByField & + YearData & + indicatorField; diff --git a/api/src/modules/reports/csv-report.service.ts b/api/src/modules/reports/csv-report.service.ts index 5c51a0780..8d3ae2067 100644 --- a/api/src/modules/reports/csv-report.service.ts +++ b/api/src/modules/reports/csv-report.service.ts @@ -1,5 +1,9 @@ -import { Injectable } from '@nestjs/common'; -import { IReportService } from 'modules/reports/report-service.interface'; +import { + Injectable, + Logger, + ServiceUnavailableException, +} from '@nestjs/common'; +import { ICSVReportService } from 'modules/reports/report-service.interface'; import { ParserOptions } from '@json2csv/plainjs'; // import { AsyncParser } from '@json2csv/node'; @@ -9,12 +13,21 @@ import { ParserOptions } from '@json2csv/plainjs'; const { AsyncParser } = require('@json2csv/node'); @Injectable() -export class CSVReportService implements IReportService { +export class CSVReportService implements ICSVReportService { + logger: Logger = new Logger(CSVReportService.name); + private getParser(parserOptions: ParserOptions): typeof AsyncParser { return new AsyncParser(parserOptions); } - generateReport(data: any, options: ParserOptions): Promise { - return this.getParser(options).parse(data).promise(); + async generateCSVReport(data: any, options: ParserOptions): Promise { + try { + return this.getParser(options).parse(data).promise(); + } catch (e) { + this.logger.error(`Error generating CSV from data: `, e); + throw new ServiceUnavailableException( + `Could not generate CSV Report, contact Administrator`, + ); + } } } diff --git a/api/src/modules/reports/report-service.interface.ts b/api/src/modules/reports/report-service.interface.ts index 26fdb3119..ce20e6948 100644 --- a/api/src/modules/reports/report-service.interface.ts +++ b/api/src/modules/reports/report-service.interface.ts @@ -1,3 +1,5 @@ -export interface IReportService { - generateReport(data: any, options: object): Promise; +import { ParserOptions } from '@json2csv/plainjs'; + +export interface ICSVReportService { + generateCSVReport(data: any, options: ParserOptions): Promise; } diff --git a/api/src/modules/tasks/task-report.service.ts b/api/src/modules/tasks/task-report.service.ts index 7a81409ec..fc80ab3d8 100644 --- a/api/src/modules/tasks/task-report.service.ts +++ b/api/src/modules/tasks/task-report.service.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { IReportService } from 'modules/reports/report-service.interface'; +import { ICSVReportService } from 'modules/reports/report-service.interface'; import { ReportServiceToken } from 'modules/reports/reports.module'; export interface ErrorRecord { @@ -10,13 +10,13 @@ export interface ErrorRecord { @Injectable() export class TaskReportService { constructor( - @Inject(ReportServiceToken) private reportService: IReportService, + @Inject(ReportServiceToken) private reportService: ICSVReportService, ) {} async createImportErrorReport(errors: ErrorRecord[]): Promise { const parserOptions: { fields: ['line', 'error'] } = { fields: ['line', 'error'], }; - return this.reportService.generateReport(errors, parserOptions); + return this.reportService.generateCSVReport(errors, parserOptions); } } diff --git a/api/test/e2e/impact/impact-reports/impact-reports.spec.ts b/api/test/e2e/impact/impact-reports/impact-reports.spec.ts new file mode 100644 index 000000000..2ac4634de --- /dev/null +++ b/api/test/e2e/impact/impact-reports/impact-reports.spec.ts @@ -0,0 +1,45 @@ +import { impactReportFixtures } from './impactReportFixtures'; +import ApplicationManager, { + TestApplication, +} from '../../../utils/application-manager'; +import { DataSource } from 'typeorm'; +import { setupTestUser } from '../../../utils/userAuth'; +import { + clearEntityTables, + clearTestDataFromDatabase, +} from '../../../utils/database-test-helper'; +import { Indicator } from '../../../../src/modules/indicators/indicator.entity'; + +describe('Impact Reports', () => { + const fixtures = impactReportFixtures(); + let testApplication: TestApplication; + let jwtToken: string; + let dataSource: DataSource; + + beforeAll(async () => { + testApplication = await ApplicationManager.init(); + + dataSource = testApplication.get(DataSource); + + ({ jwtToken } = await setupTestUser(testApplication)); + }); + + afterEach(async () => { + await clearEntityTables(dataSource, []); + }); + + afterAll(async () => { + await clearTestDataFromDatabase(dataSource); + await testApplication.close(); + }); + it('should create an impact report', async () => { + const { indicators } = await fixtures.GivenSourcingLocationWithImpact(); + const response = await fixtures.WhenIRequestAnImpactReport({ + app: testApplication, + jwtToken, + indicatorIds: indicators.map((indicator: Indicator) => indicator.id), + }); + + await fixtures.ThenIShouldGetAnImpactReportAboutProvidedFilters(response); + }); +}); diff --git a/api/test/e2e/impact/impact-reports/impactReportFixtures.ts b/api/test/e2e/impact/impact-reports/impactReportFixtures.ts new file mode 100644 index 000000000..e25f8db7c --- /dev/null +++ b/api/test/e2e/impact/impact-reports/impactReportFixtures.ts @@ -0,0 +1,108 @@ +import { + createAdminRegion, + createBusinessUnit, + createIndicator, + createIndicatorRecord, + createMaterial, + createSourcingLocation, + createSourcingRecord, + createSupplier, + createUnit, +} from '../../../entity-mocks'; +import { + Indicator, + INDICATOR_NAME_CODES, +} from '../../../../src/modules/indicators/indicator.entity'; +import { SourcingRecord } from 'modules/sourcing-records/sourcing-record.entity'; +import { TestApplication } from '../../../utils/application-manager'; +import * as request from 'supertest'; +import { GROUP_BY_VALUES } from '../../../../src/modules/impact/dto/impact-table.dto'; +import { range } from 'lodash'; + +export const impactReportFixtures = () => ({ + GivenSourcingLocationWithImpact: async () => { + const material = await createMaterial(); + const supplier = await createSupplier(); + const businessUnit = await createBusinessUnit(); + const adminRegion = await createAdminRegion(); + const sourcingLocation = await createSourcingLocation({ + materialId: material.id, + producerId: supplier.id, + businessUnitId: businessUnit.id, + adminRegionId: adminRegion.id, + }); + const unit = await createUnit(); + const indicators: Indicator[] = []; + for (const indicator of Object.values(INDICATOR_NAME_CODES)) { + indicators.push( + await createIndicator({ + nameCode: indicator, + name: indicator, + unit, + shortName: indicator, + }), + ); + } + const sourcingRecords: SourcingRecord[] = []; + for (const year of [2018, 2019, 2020, 2021, 2022, 2023]) { + sourcingRecords.push( + await createSourcingRecord({ + sourcingLocationId: sourcingLocation.id, + year, + tonnage: 100 * year, + }), + ); + } + for (const sourcingRecord of sourcingRecords) { + for (const indicator of indicators) { + await createIndicatorRecord({ + sourcingRecordId: sourcingRecord.id, + indicatorId: indicator.id, + value: sourcingRecord.tonnage * 2, + }); + } + } + return { + sourcingLocation, + indicators, + sourcingRecords, + }; + }, + WhenIRequestAnImpactReport: (options: { + app: TestApplication; + jwtToken: string; + indicatorIds: string[]; + }): Promise => { + return request(options.app.getHttpServer()) + .get('/api/v1/impact/table/report') + .set('Authorization', `Bearer ${options.jwtToken}`) + .query({ + 'indicatorIds[]': [...options.indicatorIds], + startYear: 2010, + endYear: 2027, + groupBy: 'material', + }); + }, + ThenIShouldGetAnImpactReportAboutProvidedFilters: ( + response: request.Response, + filters?: { groupBy?: GROUP_BY_VALUES; indicators?: Indicator[] }, + ) => { + expect(response.status).toBe(200); + expect(response.headers['content-type']).toContain('text/csv'); + expect(response.headers['content-disposition']).toContain( + 'filename=impact_report.csv', + ); + expect(response.text).toContain('Indicator'); + expect(response.text).toContain( + `Group by ${filters?.groupBy ?? GROUP_BY_VALUES.MATERIAL}`, + ); + for (const year of range(2010, 2027)) { + expect(response.text).toContain(year.toString()); + } + for (const indicatorShortName of filters?.indicators?.map( + (indicator: Indicator) => indicator.shortName, + ) ?? Object.values(INDICATOR_NAME_CODES)) { + expect(response.text).toContain(indicatorShortName); + } + }, +});