diff --git a/api/src/modules/eudr-alerts/alerts-query-builder/big-query-alerts-query.builder.ts b/api/src/modules/eudr-alerts/alerts-query-builder/big-query-alerts-query.builder.ts index 398459886..6372c047e 100644 --- a/api/src/modules/eudr-alerts/alerts-query-builder/big-query-alerts-query.builder.ts +++ b/api/src/modules/eudr-alerts/alerts-query-builder/big-query-alerts-query.builder.ts @@ -95,8 +95,6 @@ export class BigQueryAlertsQueryBuilder { this.queryBuilder.limit(this.dto?.limit); const [query, params] = this.queryBuilder.getQueryAndParameters(); - console.log('query', query); - console.log('params', params); return this.parseToBigQuery(query, params); } diff --git a/api/src/modules/eudr-alerts/alerts.repository.ts b/api/src/modules/eudr-alerts/alerts.repository.ts index ed5a13d28..340d78dc6 100644 --- a/api/src/modules/eudr-alerts/alerts.repository.ts +++ b/api/src/modules/eudr-alerts/alerts.repository.ts @@ -14,7 +14,6 @@ import { AlertsOutput } from 'modules/eudr-alerts/dto/alerts-output.dto'; import { EUDRAlertDatabaseResult, EUDRAlertDates, - GetAlertSummary, IEUDRAlertsRepository, } from 'modules/eudr-alerts/eudr.repositoty.interface'; import { GetEUDRAlertsDto } from 'modules/eudr-alerts/dto/get-alerts.dto'; @@ -62,6 +61,8 @@ export class AlertsRepository implements IEUDRAlertsRepository { queryBuilder.addSelect('alertconfidence', 'alertConfidence'); queryBuilder.addSelect('year', 'alertYear'); queryBuilder.addSelect('alertcount', 'alertCount'); + queryBuilder.addSelect('georegionid', 'geoRegionId'); + queryBuilder.orderBy('alertdate', 'ASC'); return this.query(queryBuilder, dto); } diff --git a/api/src/modules/eudr-alerts/dashboard/dashboard-detail.types.ts b/api/src/modules/eudr-alerts/dashboard/dashboard-detail.types.ts new file mode 100644 index 000000000..7f17ede5f --- /dev/null +++ b/api/src/modules/eudr-alerts/dashboard/dashboard-detail.types.ts @@ -0,0 +1,72 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class EUDRDashBoardDetail { + @ApiProperty() + name: string; + @ApiProperty() + address: string; + @ApiProperty() + companyId: string; + @ApiProperty({ + type: () => DashBoardDetailSourcingInformation, + isArray: true, + }) + sourcingInformation: DashBoardDetailSourcingInformation[]; + @ApiProperty({ type: () => DashBoardDetailAlerts, isArray: true }) + alerts: DashBoardDetailAlerts[]; +} + +class DashBoardDetailSourcingInformation { + @ApiProperty() + materialName: string; + @ApiProperty() + hsCode: string; + @ApiProperty() + totalArea: number; + @ApiProperty() + totalVolume: number; + @ApiProperty({ type: () => ByVolume, isArray: true }) + byVolume: ByVolume[]; + @ApiProperty({ type: () => ByArea, isArray: true }) + byArea: ByArea[]; +} + +class ByVolume { + @ApiProperty() + year: number; + @ApiProperty() + percentage: number; + @ApiProperty() + volume: number; +} + +class ByArea { + @ApiProperty() + plotName: string; + @ApiProperty() + percentage: number; + @ApiProperty() + area: number; + @ApiProperty() + geoRegionId: string; +} + +class DashBoardDetailAlerts { + @ApiProperty() + startAlertDate: Date; + @ApiProperty() + endAlertDate: number; + @ApiProperty() + totalAlerts: number; + @ApiProperty({ type: () => AlertValues, isArray: true }) + values: AlertValues[]; +} + +class AlertValues { + @ApiProperty() + geoRegionId: string; + @ApiProperty() + alertCount: number; + @ApiProperty() + plotName: string; +} diff --git a/api/src/modules/eudr-alerts/dashboard/types.ts b/api/src/modules/eudr-alerts/dashboard/dashboard.types.ts similarity index 100% rename from api/src/modules/eudr-alerts/dashboard/types.ts rename to api/src/modules/eudr-alerts/dashboard/dashboard.types.ts diff --git a/api/src/modules/eudr-alerts/dashboard/eudr-dashboard.service.ts b/api/src/modules/eudr-alerts/dashboard/eudr-dashboard.service.ts index 4080f653a..d0d077a8a 100644 --- a/api/src/modules/eudr-alerts/dashboard/eudr-dashboard.service.ts +++ b/api/src/modules/eudr-alerts/dashboard/eudr-dashboard.service.ts @@ -1,7 +1,7 @@ // supress typescript error // eslint-disable-next-line @typescript-eslint/ban-types import { Inject, Injectable, NotFoundException } from '@nestjs/common'; -import { DataSource, SelectQueryBuilder } from 'typeorm'; +import { DataSource, EntityManager, SelectQueryBuilder } from 'typeorm'; import { EUDRAlertDatabaseResult, IEUDRAlertsRepository, @@ -18,7 +18,13 @@ import { EUDRBreakDown, EUDRDashboard, EUDRDashBoardFields, -} from 'modules/eudr-alerts/dashboard/types'; +} from 'modules/eudr-alerts/dashboard/dashboard.types'; +import { GetEUDRAlertDatesDto } from '../dto/get-alerts.dto'; +import { AdminRegionsService } from '../../admin-regions/admin-regions.service'; +import { AlertsOutput } from '../dto/alerts-output.dto'; + +import { GeoRegion } from 'modules/geo-regions/geo-region.entity'; +import { EUDRDashBoardDetail } from './dashboard-detail.types'; @Injectable() export class EudrDashboardService { @@ -392,4 +398,155 @@ export class EudrDashboardService { return queryBuilder.getRawMany(); } + + async buildDashboardDetail( + supplierId: string, + dto?: GetEUDRAlertDatesDto, + ): Promise { + const result: any = {}; + const sourcingInformation: any = {}; + let supplier: Supplier; + const geoRegionMap: Map = new Map(); + + return this.datasource.transaction(async (manager: EntityManager) => { + supplier = await manager + .getRepository(Supplier) + .findOneOrFail({ where: { id: supplierId } }); + result.name = supplier.name; + result.address = supplier.address; + result.companyId = supplier.companyId; + result.sourcingInformation = sourcingInformation; + const sourcingData: { + materialId: string; + materialName: string; + hsCode: string; + countryName: string; + plotName: string; + geoRegionId: string; + plotArea: number; + volume: number; + year: number; + sourcingLocationId: string; + }[] = await manager + .createQueryBuilder(SourcingLocation, 'sl') + .select('m.name', 'materialName') + .addSelect('sl.locationCountryInput', 'countryName') + .addSelect('m.hsCodeId', 'hsCode') + .addSelect('m.id', 'materialId') + .leftJoin(Material, 'm', 'm.id = sl.materialId') + .where('sl.producerId = :producerId', { producerId: supplierId }) + .distinct(true) + .getRawMany(); + + // TODO: we are assuming that each suppliers supplies only one material and for the same country + + const country: AdminRegion = await manager + .getRepository(AdminRegion) + .findOneOrFail({ + where: { name: sourcingData[0].countryName, level: 0 }, + }); + + sourcingInformation.materialName = sourcingData[0].materialName; + sourcingInformation.hsCode = sourcingData[0].hsCode; + sourcingInformation.country = { + name: country.name, + isoA3: country.isoA3, + }; + + for (const material of sourcingData) { + const geoRegions: any[] = await manager + .createQueryBuilder(SourcingLocation, 'sl') + .select('gr.id', 'geoRegionId') + .addSelect('gr.name', 'plotName') + .addSelect('gr.totalArea', 'totalArea') + .distinct(true) + .leftJoin(GeoRegion, 'gr', 'gr.id = sl.geoRegionId') + .where('sl.materialId = :materialId', { + materialId: material.materialId, + }) + .andWhere('sl.producerId = :supplierId', { supplierId }) + .getRawMany(); + const totalArea: number = geoRegions.reduce( + (acc: number, cur: any) => acc + parseInt(cur.totalArea), + 0, + ); + let sourcingRecords: SourcingRecord[] = []; + for (const geoRegion of geoRegions) { + if (!geoRegionMap.get(geoRegion.geoRegionId)) { + geoRegionMap.set(geoRegion.geoRegionId, { + plotName: geoRegion.plotName, + }); + } + sourcingRecords = await manager + .createQueryBuilder(SourcingRecord, 'sr') + .leftJoin(SourcingLocation, 'sl', 'sr.sourcingLocationId = sl.id') + .leftJoin(GeoRegion, 'gr', 'gr.id = sl.geoRegionId') + .where('sl.geoRegionId = :geoRegionId', { + geoRegionId: geoRegion.geoRegionId, + }) + .andWhere('sl.producerId = :supplierId', { supplierId: supplierId }) + .andWhere('sl.materialId = :materialId', { + materialId: material.materialId, + }) + .select([ + 'sr.year AS year', + 'sr.tonnage AS volume', + 'gr.name as plotName', + 'gr.id as geoRegionId', + ]) + .getRawMany(); + } + + const totalVolume: number = sourcingRecords.reduce( + (acc: number, cur: any) => acc + parseInt(cur.volume), + 0, + ); + + sourcingInformation.totalArea = totalArea; + sourcingInformation.totalVolume = totalVolume; + sourcingInformation.byArea = geoRegions.map((geoRegion: any) => ({ + plotName: geoRegion.plotName, + geoRegionId: geoRegion.geoRegionId, + percentage: (geoRegion.totalArea / totalArea) * 100, + area: geoRegion.totalArea, + })); + sourcingInformation.byVolume = sourcingRecords.map((record: any) => ({ + plotName: record.plotName, + geoRegionId: record.geoRegionId, + year: record.year, + percentage: (parseInt(record.volume) / totalVolume) * 100, + volume: parseInt(record.volume), + })); + } + + const alertsOutput: AlertsOutput[] = await this.eudrRepository.getAlerts({ + supplierIds: [supplierId], + startAlertDate: dto?.startAlertDate, + endAlertDate: dto?.endAlertDate, + }); + + const totalAlerts: number = alertsOutput.reduce( + (acc: number, cur: AlertsOutput) => acc + cur.alertCount, + 0, + ); + const startAlertDate: string = alertsOutput[0].alertDate.value.toString(); + const endAlertDate: string = + alertsOutput[alertsOutput.length - 1].alertDate.value.toString(); + + const alerts = { + startADateDate: startAlertDate, + endAlertDate: endAlertDate, + totalAlerts, + values: alertsOutput.map((alert: AlertsOutput) => ({ + geoRegionId: alert.geoRegionId, + alertCount: alert.alertCount, + plotName: geoRegionMap.get(alert.geoRegionId)!.plotName, + })), + }; + + result.alerts = alerts; + + return result; + }); + } } diff --git a/api/src/modules/eudr-alerts/dto/alerts-output.dto.ts b/api/src/modules/eudr-alerts/dto/alerts-output.dto.ts index a66b22516..d61ab5583 100644 --- a/api/src/modules/eudr-alerts/dto/alerts-output.dto.ts +++ b/api/src/modules/eudr-alerts/dto/alerts-output.dto.ts @@ -1,10 +1,11 @@ export type AlertsOutput = { - alertCount: boolean; + alertCount: number; alertDate: { value: Date | string; }; year: number; alertConfidence: 'low' | 'medium' | 'high' | 'very high'; + geoRegionId: string; }; export type AlertGeometry = { diff --git a/api/src/modules/eudr-alerts/dto/get-alerts.dto.ts b/api/src/modules/eudr-alerts/dto/get-alerts.dto.ts index 5b6699caa..77a99f425 100644 --- a/api/src/modules/eudr-alerts/dto/get-alerts.dto.ts +++ b/api/src/modules/eudr-alerts/dto/get-alerts.dto.ts @@ -9,47 +9,49 @@ import { IsUUID, } from 'class-validator'; -export class GetEUDRAlertsDto { +export class GetEUDRAlertDatesDto { + @ApiPropertyOptional() + @IsOptional() + @IsDate() + @Type(() => Date) + startAlertDate?: Date; + + @ApiPropertyOptional() + @IsOptional() + @IsDate() + @Type(() => Date) + endAlertDate?: Date; +} + +export class GetEUDRAlertsDto extends GetEUDRAlertDatesDto { @ApiPropertyOptional() @IsOptional() @IsArray() @IsUUID('4', { each: true }) - supplierIds: string[]; + supplierIds?: string[]; @ApiPropertyOptional() @IsOptional() @IsArray() @IsUUID('4', { each: true }) - geoRegionIds: string[]; + geoRegionIds?: string[]; @ApiPropertyOptional() @IsOptional() @IsNumber() @Type(() => Number) - startYear: number; + startYear?: number; @ApiPropertyOptional() @IsOptional() @IsNumber() @Type(() => Number) - endYear: number; + endYear?: number; - alertConfidence: 'high' | 'medium' | 'low'; - - @ApiPropertyOptional() - @IsOptional() - @IsDate() - @Type(() => Date) - startAlertDate: Date; - - @ApiPropertyOptional() - @IsOptional() - @IsDate() - @Type(() => Date) - endAlertDate: Date; + alertConfidence?: 'high' | 'medium' | 'low'; @ApiPropertyOptional() @IsOptional() @IsInt() - limit: number = 1000; + limit?: number = 1000; } diff --git a/api/src/modules/eudr-alerts/eudr.controller.ts b/api/src/modules/eudr-alerts/eudr.controller.ts index a500019a3..41161456b 100644 --- a/api/src/modules/eudr-alerts/eudr.controller.ts +++ b/api/src/modules/eudr-alerts/eudr.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, + Param, Query, UseInterceptors, ValidationPipe, @@ -33,7 +34,10 @@ import { import { JSONAPIQueryParams } from 'decorators/json-api-parameters.decorator'; import { GetEUDRGeoRegions } from 'modules/geo-regions/dto/get-geo-region.dto'; import { EudrService } from 'modules/eudr-alerts/eudr.service'; -import { GetEUDRAlertsDto } from 'modules/eudr-alerts/dto/get-alerts.dto'; +import { + GetEUDRAlertDatesDto, + GetEUDRAlertsDto, +} from 'modules/eudr-alerts/dto/get-alerts.dto'; import { EUDRAlertDates } from 'modules/eudr-alerts/eudr.repositoty.interface'; import { GetEUDRFeaturesGeoJSONDto } from 'modules/geo-regions/dto/get-features-geojson.dto'; import { @@ -43,7 +47,8 @@ import { import { EudrDashboardService } from './dashboard/eudr-dashboard.service'; import { IsDate, IsOptional, IsUUID } from 'class-validator'; import { Type } from 'class-transformer'; -import { EUDRDashboard } from './dashboard/types'; +import { EUDRDashboard } from './dashboard/dashboard.types'; +import { EUDRDashBoardDetail } from './dashboard/dashboard-detail.types'; export class GetDashBoardDTO { @ApiPropertyOptional() @@ -251,4 +256,18 @@ export class EudrController { data: dashboard, }; } + + @ApiOperation({ description: 'Get EUDR Dashboard Detail' }) + @ApiOkResponse({ type: EUDRDashBoardDetail }) + @Get('/dashboard/detail/:supplierId') + async getDashboardDetail( + @Param('supplierId') supplierId: string, + @Query(ValidationPipe) dto: GetEUDRAlertDatesDto, + ): Promise<{ data: EUDRDashBoardDetail }> { + const dashboard: EUDRDashBoardDetail = + await this.dashboard.buildDashboardDetail(supplierId, dto); + return { + data: dashboard, + }; + } } diff --git a/api/src/modules/geo-regions/geo-region.entity.ts b/api/src/modules/geo-regions/geo-region.entity.ts index 6c13c6df2..40bd08bda 100644 --- a/api/src/modules/geo-regions/geo-region.entity.ts +++ b/api/src/modules/geo-regions/geo-region.entity.ts @@ -53,7 +53,7 @@ export class GeoRegion extends BaseEntity { // TODO: It might be interesting to add a trigger to calculate the value in case it's not provided. We are considering that EUDR will alwaus provide the value // but not the regular ingestion @Column({ type: 'decimal', nullable: true }) - totalArea?: number; + totalArea: number; @Column({ type: 'boolean', default: true }) isCreatedByUser: boolean;