Vizzuality/landgriffon

View on GitHub
api/src/modules/import-data/eudr/eudr.dto-processor.service.ts

Summary

Maintainability
C
1 day
Test Coverage
import {
  BadRequestException,
  Injectable,
  Logger,
  ValidationError,
} from '@nestjs/common';
import { CreateSourcingLocationDto } from 'modules/sourcing-locations/dto/create.sourcing-location.dto';
import { SourcingRecord } from 'modules/sourcing-records/sourcing-record.entity';
import { SourcingDataExcelValidator } from 'modules/import-data/sourcing-data/validation/validators/sourcing-data.class.validator';
import { validateOrReject } from 'class-validator';
import { plainToClass } from 'class-transformer';
import {
  LOCATION_TYPES,
  SourcingLocation,
} from 'modules/sourcing-locations/sourcing-location.entity';
import { Supplier } from 'modules/suppliers/supplier.entity';
import { GeoRegion } from 'modules/geo-regions/geo-region.entity';
// @ts-ignore
import * as wellknown from 'wellknown';
import { DataSource, QueryRunner, Repository } from 'typeorm';
import { GeoCodingError } from 'modules/geo-coding/errors/geo-coding.error';
import { AdminRegion } from 'modules/admin-regions/admin-region.entity';
import { Geometry } from 'geojson';

/**
 * @debt: Define a more accurate DTO / Interface / Class for API-DB trades
 * and spread through typing
 */
export interface SourcingData extends CreateSourcingLocationDto {
  sourcingRecords: SourcingRecord[] | { year: number; tonnage: number }[];
  geoRegionId?: string;
  adminRegionId?: string;
}

export interface EudrInputShape {
  plot_id: string;
  plot_name: string;
  company_id: string;
  company_name: string;
  company_address: string;
  total_area_ha: number;
  sourcing_country: string;
  sourcing_district: string;
  path_id: string;
  material_id: string;
  geometry: string;

  [key: string]: string | number | undefined;
}

@Injectable()
export class EUDRDTOProcessor {
  protected readonly logger: Logger = new Logger(EUDRDTOProcessor.name);

  constructor(private readonly dataSource: DataSource) {}

  async save(
    importData: EudrInputShape[],
    sourcingLocationGroupId?: string,
  ): Promise<{
    sourcingLocations: SourcingLocation[];
  }> {
    const queryRunner: QueryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();
    try {
      this.logger.debug(`Creating DTOs from sourcing records sheets`);
      const sourcingLocations: SourcingLocation[] = [];
      const supplierRepository: Repository<Supplier> =
        queryRunner.manager.getRepository(Supplier);
      const geoRegionRepository: Repository<GeoRegion> =
        queryRunner.manager.getRepository(GeoRegion);
      for (const row of importData) {
        const supplier: Supplier = new Supplier();
        let savedSupplier: Supplier;
        supplier.name = row.company_name;
        supplier.description = row.company_name;
        supplier.address = row.company_address;
        supplier.companyId = row.company_id;
        const foundSupplier: Supplier | null = await supplierRepository.findOne(
          {
            where: { name: supplier.name },
          },
        );
        if (!foundSupplier) {
          savedSupplier = await supplierRepository.save(supplier);
        } else {
          savedSupplier = foundSupplier;
        }
        const geoRegion: GeoRegion = new GeoRegion();
        geoRegion.totalArea = row.total_area_ha;
        geoRegion.theGeom = row.geometry
          ? (wellknown.parse(row.geometry) as Geometry)
          : (null as unknown as Geometry);
        geoRegion.isCreatedByUser = true;
        geoRegion.name = row.plot_name;
        let savedGeoRegion: GeoRegion;
        if (geoRegion.theGeom && geoRegion.name) {
          savedGeoRegion = await geoRegionRepository.save(geoRegion);
        }
        const sourcingLocation: SourcingLocation = new SourcingLocation();
        sourcingLocation.locationType = 'eudr' as LOCATION_TYPES;
        sourcingLocation.locationCountryInput = row.sourcing_country;
        sourcingLocation.locationAddressInput = row.sourcing_district;
        sourcingLocation.materialId = row.material_id
          .split('.')
          .filter(Boolean)
          .pop() as string;
        sourcingLocation.producer = savedSupplier;
        // @ts-ignore
        sourcingLocation.geoRegion = savedGeoRegion ?? null;
        sourcingLocation.sourcingRecords = [];
        sourcingLocation.adminRegionId = row.sourcing_district
          ? await this.getAdminRegionByAddress(
              queryRunner,
              row.sourcing_district,
              row.geometry,
            )
          : (null as unknown as string);

        for (const key in row) {
          const sourcingRecord: SourcingRecord = new SourcingRecord();
          if (row.hasOwnProperty(key)) {
            const match: RegExpMatchArray | null = key.match(/^(\d{4})_t$/);
            if (match) {
              sourcingRecord.year = parseInt(match[1]);
              sourcingRecord.tonnage = row[key] as number;
              sourcingLocation.sourcingRecords.push(sourcingRecord);
            }
          }
        }
        sourcingLocations.push(sourcingLocation);
      }

      const saved: SourcingLocation[] = await queryRunner.manager
        .getRepository(SourcingLocation)
        .save(sourcingLocations);

      await queryRunner.commitTransaction();
      return {
        sourcingLocations: saved,
      };
    } catch (err) {
      await queryRunner.rollbackTransaction();
      throw err;
    } finally {
      await queryRunner.release();
    }
  }

  private async getAdminRegionByAddress(
    queryRunner: QueryRunner,
    name: string,
    geom: string,
  ): Promise<string> {
    const adminRegion: AdminRegion | null = await queryRunner.manager
      .getRepository(AdminRegion)
      .findOne({ where: { name: name, level: 1 } });
    if (!adminRegion) {
      this.logger.warn(
        `No admin region found for the provided address: ${name}`,
      );
      return this.getAdminRegionByIntersection(queryRunner, geom);
    }
    return adminRegion.id;
  }

  // TODO: temporal method to determine the most accurate admin region. For now we only consider Level 0
  //  as Country and Level 1 as district

  private async getAdminRegionByIntersection(
    queryRunner: QueryRunner,
    geometry: string,
  ): Promise<string> {
    this.logger.log(`Intersecting EUDR geometry...`);

    const adminRegions: any = await queryRunner.manager.query(
      `
    WITH intersections AS (
        SELECT
        ar.id,
        ar.name,
        ar."geoRegionId",
        gr."theGeom",
        ar.level,
        ST_Area(ST_Intersection(gr."theGeom", ST_GeomFromEWKT('SRID=4326;${geometry}'))) AS intersection_area
    FROM admin_region ar
    JOIN geo_region gr ON ar."geoRegionId" = gr.id
    WHERE
        ST_Intersects(gr."theGeom", ST_GeomFromEWKT('SRID=4326;${geometry}'))
    AND ar.level IN (0, 1)
    ),
    max_intersection_by_level AS (
    SELECT
    level,
    MAX(intersection_area) AS max_area
    FROM intersections
    GROUP BY level
    )
    SELECT i.*
    FROM intersections i
    JOIN max_intersection_by_level m ON i.level = m.level AND i.intersection_area = m.max_area;
    `,
    );
    if (!adminRegions.length) {
      throw new GeoCodingError(
        `No admin region found for the provided geometry`,
      );
    }

    const level1AdminRegionid: string = adminRegions.find(
      (ar: any) => ar.level === 1,
    ).id;
    this.logger.log('Admin region found');
    return level1AdminRegionid;
  }

  private async validateCleanData(nonEmptyData: SourcingData[]): Promise<void> {
    const excelErrors: {
      line: number;
      column: string;
      errors: { [type: string]: string } | undefined;
    }[] = [];

    for (const [index, dto] of nonEmptyData.entries()) {
      const objectToValidate: SourcingDataExcelValidator = plainToClass(
        SourcingDataExcelValidator,
        dto,
      );

      try {
        await validateOrReject(objectToValidate);
      } catch (errors: any) {
        errors.forEach((error: ValidationError) => {
          if (error.children?.length) {
            error.children.forEach((nestedError: ValidationError) => {
              excelErrors.push({
                line: index + 5,
                column: nestedError.value.year,
                errors: nestedError.children?.[0].constraints,
              });
            });
          } else {
            excelErrors.push({
              line: index + 5,
              column: error?.property,
              errors: error?.constraints,
            });
          }
        });
      }
    }

    if (excelErrors.length) {
      throw new BadRequestException(excelErrors);
    }
  }
}