diff --git a/api/src/modules/admin-regions/admin-regions.service.ts b/api/src/modules/admin-regions/admin-regions.service.ts index 1222f822a0..d59594c92e 100644 --- a/api/src/modules/admin-regions/admin-regions.service.ts +++ b/api/src/modules/admin-regions/admin-regions.service.ts @@ -24,7 +24,7 @@ import { SuppliersService } from 'modules/suppliers/suppliers.service'; import { BusinessUnitsService } from 'modules/business-units/business-units.service'; import { SourcingLocation } from 'modules/sourcing-locations/sourcing-location.entity'; import { GeoRegion } from 'modules/geo-regions/geo-region.entity'; -import { SelectQueryBuilder } from 'typeorm'; +import { In, SelectQueryBuilder } from 'typeorm'; import { FindOneOptions } from 'typeorm/find-options/FindOneOptions'; @Injectable() @@ -267,4 +267,19 @@ export class AdminRegionsService extends AppBaseService< return adminRegions.map((adminRegion: AdminRegion) => adminRegion.id); } + + async getAdminRegionsLevels( + adminRegionIds: string[], + ): Promise { + const adminRegions: AdminRegion[] | undefined = + await this.adminRegionRepository.find({ + where: { id: In(adminRegionIds) }, + }); + + if (adminRegions) + return Object.values( + adminRegions.map((adminRegion: AdminRegion) => adminRegion.level), + ); + else return undefined; + } } diff --git a/api/src/modules/impact/dto/impact-table.dto.ts b/api/src/modules/impact/dto/impact-table.dto.ts index 8ab1fb10bb..63dc83414d 100644 --- a/api/src/modules/impact/dto/impact-table.dto.ts +++ b/api/src/modules/impact/dto/impact-table.dto.ts @@ -7,7 +7,6 @@ import { IsPositive, IsString, IsUUID, - Max, Min, } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; @@ -165,14 +164,13 @@ export class GetRankedImpactTableDto extends BaseImpactTableDto { @ApiPropertyOptional({ description: - 'Optional property for groupBy region and chart view to choose level of admin regions to show', + 'Optional param to choose the depth level of the tree of nested grouped elements', }) - @IsOptional() @Type(() => Number) + @IsOptional() + @Min(0) @IsNumber() - @Min(1) - @Max(3) - level?: number; + depth?: number; // Property for internal api use (entity filters) @IsOptional() diff --git a/api/src/modules/impact/impact.service.ts b/api/src/modules/impact/impact.service.ts index 4c00403da7..30f3fde549 100644 --- a/api/src/modules/impact/impact.service.ts +++ b/api/src/modules/impact/impact.service.ts @@ -112,6 +112,13 @@ export class ImpactService extends BaseImpactService { async getRankedImpactTable( rankedImpactTableDto: GetRankedImpactTableDto, ): Promise { + let filteredAdminRegionsLevels: number[] | undefined; + if (rankedImpactTableDto.originIds) + filteredAdminRegionsLevels = + await this.adminRegionsService.getAdminRegionsLevels( + rankedImpactTableDto.originIds, + ); + const indicators: Indicator[] = await this.indicatorService.getIndicatorsById( rankedImpactTableDto.indicatorIds, @@ -145,16 +152,26 @@ export class ImpactService extends BaseImpactService { ); // If chart is requested for regions and specific level is received, we get only the requested level from admin regions trees of the response - - if ( + if (rankedImpactTableDto.depth) { + impactTable.impactTable.forEach( + (impactDataByIndicator: ImpactTableDataByIndicator) => { + impactDataByIndicator.rows = this.getLevelOfImpactTableRows( + impactDataByIndicator.rows, + rankedImpactTableDto.depth as number, + ); + }, + ); + } else if ( rankedImpactTableDto.groupBy === 'region' && - rankedImpactTableDto.level + filteredAdminRegionsLevels?.length ) { + const depth: number = this.defineLevelToApply(filteredAdminRegionsLevels); + impactTable.impactTable.forEach( (impactDataByIndicator: ImpactTableDataByIndicator) => { impactDataByIndicator.rows = this.getLevelOfImpactTableRows( impactDataByIndicator.rows, - rankedImpactTableDto.level as number, + depth, ); }, ); @@ -233,6 +250,58 @@ export class ImpactService extends BaseImpactService { return impactTable; } + private defineLevelToApply(receivedLevels: number[]): number { + // All filters are of level 0 - showing one level down (1) + if (receivedLevels.every((level: number) => level === 0)) return 1; + // All filters are of same level - 1 or 2 - showing one level down or same (level 2) + else if ( + receivedLevels.every((level: number) => level === 1) || + receivedLevels.every((level: number) => level === 2) + ) + return 2; + // received filters are of level 0 and 1 - showing level 1 + else if ( + receivedLevels.includes(0) && + receivedLevels.includes(1) && + !receivedLevels.includes(2) + ) + return 1; + // received filters are of mixed levels including the lowest + else if ( + (!receivedLevels.includes(0) && + receivedLevels.includes(1) && + receivedLevels.includes(2)) || + (receivedLevels.includes(0) && + receivedLevels.includes(1) && + receivedLevels.includes(2)) || + (receivedLevels.includes(0) && + !receivedLevels.includes(1) && + receivedLevels.includes(2)) + ) + return 2; + else return 0; + } + + private getLevelOfImpactTableRows( + impactTableRows: ImpactTableRows[], + level: number, + ): ImpactTableRows[] { + if (level === 0) { + return impactTableRows; + } + return impactTableRows.reduce( + (impactTableRows: ImpactTableRows[], row: ImpactTableRows) => { + if (row.children) { + impactTableRows = impactTableRows.concat( + this.getLevelOfImpactTableRows(row.children, level - 1), + ); + } + return impactTableRows; + }, + [], + ); + } + private buildImpactTable( queryDto: GetImpactTableDto, indicators: Indicator[], @@ -480,27 +549,4 @@ export class ImpactService extends BaseImpactService { isProjected: false, }; } - - private getLevelOfImpactTableRows( - impactTableRows: ImpactTableRows[], - level: number, - ): ImpactTableRows[] { - if (level === 1) { - return impactTableRows; - } - if (level < 1) { - return []; - } - return impactTableRows.reduce( - (impactTableRows: ImpactTableRows[], row: ImpactTableRows) => { - if (row.children) { - impactTableRows = impactTableRows.concat( - this.getLevelOfImpactTableRows(row.children, level - 1), - ); - } - return impactTableRows; - }, - [], - ); - } } diff --git a/api/test/e2e/impact/impact-chart/chart-levels.spec.ts b/api/test/e2e/impact/impact-chart/chart-levels.spec.ts index fcb727c5bf..4365969d2b 100644 --- a/api/test/e2e/impact/impact-chart/chart-levels.spec.ts +++ b/api/test/e2e/impact/impact-chart/chart-levels.spec.ts @@ -8,12 +8,18 @@ import { clearTestDataFromDatabase } from '../../../utils/database-test-helper'; import { Indicator } from 'modules/indicators/indicator.entity'; import { DataSource } from 'typeorm'; import { createChartLevelsPreconditions } from '../mocks/chart-levels-preconditions/chart-levels.preconditions'; +import { AdminRegion } from '../../../../src/modules/admin-regions/admin-region.entity'; describe('Impact Chart (Ranking) Test Suite (e2e) with requested levels', () => { let testApplication: TestApplication; let jwtToken: string; let dataSource: DataSource; - let indicator: Indicator; + let preconditions: { + indicator: Indicator; + country: AdminRegion; + provinces: AdminRegion[]; + municipalities: AdminRegion[]; + }; beforeAll(async () => { testApplication = await ApplicationManager.init(); @@ -22,7 +28,7 @@ describe('Impact Chart (Ranking) Test Suite (e2e) with requested levels', () => ({ jwtToken } = await setupTestUser(testApplication)); - indicator = await createChartLevelsPreconditions(); + preconditions = await createChartLevelsPreconditions(); }); afterAll(async () => { @@ -39,7 +45,7 @@ describe('Impact Chart (Ranking) Test Suite (e2e) with requested levels', () => .get('/api/v1/impact/ranking') .set('Authorization', `Bearer ${jwtToken}`) .query({ - 'indicatorIds[]': [indicator.id], + 'indicatorIds[]': [preconditions.indicator.id], endYear: 2020, startYear: 2020, groupBy: 'region', @@ -56,18 +62,18 @@ describe('Impact Chart (Ranking) Test Suite (e2e) with requested levels', () => test( 'When I query a Impact Chart grouped by region ' + - 'And request data for admin regions of level 2' + + 'And request data for admin regions of depth 1' + 'Then I should get response structure in accordance with requested level (province) ', async () => { const response = await request(testApplication.getHttpServer()) .get('/api/v1/impact/ranking') .set('Authorization', `Bearer ${jwtToken}`) .query({ - 'indicatorIds[]': [indicator.id], + 'indicatorIds[]': [preconditions.indicator.id], endYear: 2020, startYear: 2020, groupBy: 'region', - level: 2, + depth: 1, maxRankingEntities: 4, sort: 'DES', }) @@ -81,18 +87,18 @@ describe('Impact Chart (Ranking) Test Suite (e2e) with requested levels', () => test( 'When I query a Impact Chart grouped by region ' + - 'And request data for admin regions of level 3' + + 'And request data for admin regions of depth 2' + 'Then I should get response structure in accordance with requested level (municipality) ', async () => { const response = await request(testApplication.getHttpServer()) .get('/api/v1/impact/ranking') .set('Authorization', `Bearer ${jwtToken}`) .query({ - 'indicatorIds[]': [indicator.id], + 'indicatorIds[]': [preconditions.indicator.id], endYear: 2020, startYear: 2020, groupBy: 'region', - level: 3, + depth: 2, maxRankingEntities: 5, sort: 'DES', }) @@ -106,4 +112,96 @@ describe('Impact Chart (Ranking) Test Suite (e2e) with requested levels', () => ).toBe(2000); }, ); + + test( + 'When I query a Impact Chart grouped by region ' + + 'And send no depth param, but filter by origins of level 0 ' + + 'Then I should get response structure starting with regions of level 1 (provinces)', + async () => { + const response = await request(testApplication.getHttpServer()) + .get('/api/v1/impact/ranking') + .set('Authorization', `Bearer ${jwtToken}`) + .query({ + 'indicatorIds[]': [preconditions.indicator.id], + 'originIds[]': [preconditions.country.id], + endYear: 2020, + startYear: 2020, + groupBy: 'region', + maxRankingEntities: 5, + sort: 'DES', + }) + .expect(HttpStatus.OK); + + expect(response.body.impactTable[0].rows.length).toBe(3); + expect(response.body.impactTable[0].rows[0].name).toBe('Province 3'); + expect(response.body.impactTable[0].rows[0].values[0].value).toBe(3000); + expect( + response.body.impactTable[0].others.aggregatedValues[0].value, + ).toBe(0); + }, + ); + + test( + 'When I query a Impact Chart grouped by region ' + + 'And send no depth param, but filter by origins of level 1 ' + + 'Then I should get response structure starting with regions of level 2 (municipalities)', + async () => { + const response = await request(testApplication.getHttpServer()) + .get('/api/v1/impact/ranking') + .set('Authorization', `Bearer ${jwtToken}`) + .query({ + 'indicatorIds[]': [preconditions.indicator.id], + 'originIds[]': [ + preconditions.provinces[0].id, + preconditions.provinces[1].id, + preconditions.provinces[2].id, + ], + endYear: 2020, + startYear: 2020, + groupBy: 'region', + maxRankingEntities: 5, + sort: 'DES', + }) + .expect(HttpStatus.OK); + + expect(response.body.impactTable[0].rows.length).toBe(5); + expect(response.body.impactTable[0].rows[0].name).toBe('Municipality 1'); + expect(response.body.impactTable[0].rows[0].values[0].value).toBe(1000); + expect( + response.body.impactTable[0].others.aggregatedValues[0].value, + ).toBe(2000); + }, + ); + + test( + 'When I query a Impact Chart grouped by region ' + + 'And send no depth param, but filter by origins of level 0 and 1 ' + + 'Then I should get response structure starting with regions of level 1 (provinces)', + async () => { + const response = await request(testApplication.getHttpServer()) + .get('/api/v1/impact/ranking') + .set('Authorization', `Bearer ${jwtToken}`) + .query({ + 'indicatorIds[]': [preconditions.indicator.id], + 'originIds[]': [ + preconditions.provinces[0].id, + preconditions.provinces[2].id, + preconditions.country.id, + ], + endYear: 2020, + startYear: 2020, + groupBy: 'region', + maxRankingEntities: 5, + sort: 'DES', + }) + .expect(HttpStatus.OK); + + expect(response.body.impactTable[0].rows.length).toBe(3); + expect(response.body.impactTable[0].rows[0].name).toBe('Province 3'); + expect(response.body.impactTable[0].rows[0].values[0].value).toBe(3000); + expect( + response.body.impactTable[0].others.aggregatedValues[0].value, + ).toBe(0); + }, + ); }); diff --git a/api/test/e2e/impact/mocks/chart-levels-preconditions/chart-levels.preconditions.ts b/api/test/e2e/impact/mocks/chart-levels-preconditions/chart-levels.preconditions.ts index 2779cb848e..0ca2b94111 100644 --- a/api/test/e2e/impact/mocks/chart-levels-preconditions/chart-levels.preconditions.ts +++ b/api/test/e2e/impact/mocks/chart-levels-preconditions/chart-levels.preconditions.ts @@ -19,59 +19,75 @@ import { } from '../../../../entity-mocks'; import { INDICATOR_TYPES } from 'modules/indicators/indicator.entity'; -export async function createChartLevelsPreconditions(): Promise { +export async function createChartLevelsPreconditions(): Promise<{ + indicator: Indicator; + country: AdminRegion; + provinces: AdminRegion[]; + municipalities: AdminRegion[]; +}> { const country: AdminRegion = await createAdminRegion({ name: 'Country', + level: 0, }); const provinceOne: AdminRegion = await createAdminRegion({ name: 'Province 1', parent: country, + level: 1, }); const provinceTwo: AdminRegion = await createAdminRegion({ name: 'Province 2', parent: country, + level: 1, }); const provinceThree: AdminRegion = await createAdminRegion({ name: 'Province 3', parent: country, + level: 1, }); const municipalityOne: AdminRegion = await createAdminRegion({ name: 'Municipality 1', parent: provinceOne, + level: 2, }); const municipalityTwo: AdminRegion = await createAdminRegion({ name: 'Municipality 2', parent: provinceOne, + level: 2, }); const municipalityThree: AdminRegion = await createAdminRegion({ name: 'Municipality 3', parent: provinceTwo, + level: 2, }); const municipalityFour: AdminRegion = await createAdminRegion({ name: 'Municipality 4', parent: provinceTwo, + level: 2, }); const municipalityFive: AdminRegion = await createAdminRegion({ name: 'Municipality 5', parent: provinceThree, + level: 2, }); const municipalitySix: AdminRegion = await createAdminRegion({ name: 'Municipality 6', parent: provinceThree, + level: 2, }); const municipalitySeven: AdminRegion = await createAdminRegion({ name: 'Municipality 7', parent: provinceThree, + level: 2, }); const unit: Unit = await createUnit({ name: 'defFakeUnit', @@ -106,13 +122,12 @@ export async function createChartLevelsPreconditions(): Promise { ]; const sourcingLocations: SourcingLocation[] = []; - for (const municipality of municipalities) { - const i: number = municipalities.indexOf(municipality); + for (let i = 0; i < municipalities.length; i++) { sourcingLocations[i] = await createSourcingLocation({ material, businessUnit, t1Supplier, - adminRegion: municipality, + adminRegion: municipalities[i], }); } @@ -128,5 +143,10 @@ export async function createChartLevelsPreconditions(): Promise { }); } - return indicator; + return { + indicator, + country, + provinces: [provinceOne, provinceTwo, provinceThree], + municipalities: [municipalityOne, municipalityTwo, municipalityThree], + }; }