Vizzuality/landgriffon

View on GitHub
api/src/modules/impact/base-impact.service.ts

Summary

Maintainability
D
2 days
Test Coverage
import { Injectable, Logger } from '@nestjs/common';
import {
  AnyImpactTableDto,
  GetActualVsScenarioImpactTableDto,
  GetImpactTableDto,
  GetRankedImpactTableDto,
  GROUP_BY_VALUES,
} from 'modules/impact/dto/impact-table.dto';
import {
  ActualVsScenarioImpactTableData,
  AnyImpactTableData,
  ImpactTableData,
} from 'modules/sourcing-records/sourcing-record.repository';
import {
  AnyImpactTableRowsValues,
  ImpactTablePurchasedTonnes,
} from 'modules/impact/dto/response-impact-table.dto';
import { BusinessUnitsService } from 'modules/business-units/business-units.service';
import { AdminRegionsService } from 'modules/admin-regions/admin-regions.service';
import { SuppliersService } from 'modules/suppliers/suppliers.service';
import { MaterialsService } from 'modules/materials/materials.service';
import { ImpactTableEntityType } from 'types/impact-table-entity.type';
import { DEFAULT_PAGINATION, FetchSpecification } from 'nestjs-base-service';
import { PaginatedEntitiesDto } from 'modules/impact/dto/paginated-entities.dto';
import { LOCATION_TYPES } from 'modules/sourcing-locations/sourcing-location.entity';
import { PaginationMeta } from 'utils/app-base.service';
import { SourcingLocationsService } from 'modules/sourcing-locations/sourcing-locations.service';
import { CommonFiltersDto } from 'utils/base.query-builder';
import { SUPPLIER_TYPES } from 'modules/suppliers/supplier.entity';
import { ImpactRepository } from 'modules/impact/impact.repository';

@Injectable()
export class BaseImpactService {
  //TODO: Hack to set a expected growing rate. This needs to be stored in the DB in the future
  growthRate: number = 1.5;
  logger: Logger = new Logger(BaseImpactService.name);

  constructor(
    protected readonly impactRepository: ImpactRepository,
    protected readonly businessUnitsService: BusinessUnitsService,
    protected readonly adminRegionsService: AdminRegionsService,
    protected readonly suppliersService: SuppliersService,
    protected readonly materialsService: MaterialsService,
    protected readonly sourcingLocationsService: SourcingLocationsService,
  ) {}

  /**
   * Modifies the ImpactTabledto such that, for each entityIds that is populated,
   * the ids of their descendants are added, in-place. Suppliers are not included in this
   * because we serve them in a flat structure, so there is no need to search for descendants
   * @param impactTableDto
   */
  async loadDescendantEntityIds(
    impactTableDto: GetImpactTableDto,
  ): Promise<GetImpactTableDto> {
    if (impactTableDto.originIds)
      impactTableDto.originIds =
        await this.adminRegionsService.getAdminRegionDescendants(
          impactTableDto.originIds,
        );
    if (impactTableDto.materialIds)
      impactTableDto.materialIds =
        await this.materialsService.getMaterialsDescendants(
          impactTableDto.materialIds,
        );
    if (impactTableDto.businessUnitIds)
      impactTableDto.businessUnitIds =
        await this.businessUnitsService.getBusinessUnitsDescendants(
          impactTableDto.businessUnitIds,
        );

    return impactTableDto;
  }

  /**
   * @description Returns an array of ImpactTable Entities, determined by the groupBy field and properties
   * of the GetImpactTableDto
   * @param impactTableDto
   */
  async getEntityTree(
    impactTableDto: AnyImpactTableDto,
  ): Promise<ImpactTableEntityType[]> {
    const treeOptions: CommonFiltersDto = {
      ...(impactTableDto.materialIds && {
        materialIds: impactTableDto.materialIds,
      }),
      ...(impactTableDto.originIds && {
        originIds: impactTableDto.originIds,
      }),
      ...(impactTableDto.t1SupplierIds && {
        t1SupplierIds: impactTableDto.t1SupplierIds,
      }),
      ...(impactTableDto.producerIds && {
        producerIds: impactTableDto.producerIds,
      }),
      ...(impactTableDto.businessUnitIds && {
        businessUnitIds: impactTableDto.businessUnitIds,
      }),
      ...(impactTableDto.scenarioIds && {
        scenarioIds: impactTableDto.scenarioIds,
      }),
      ...(impactTableDto.locationTypes && {
        locationTypes: impactTableDto.locationTypes,
      }),
    };

    switch (impactTableDto.groupBy) {
      case GROUP_BY_VALUES.MATERIAL: {
        return this.materialsService.getMaterialsTreeFromSourcingLocations(
          treeOptions,
        );
      }
      case GROUP_BY_VALUES.REGION: {
        return this.adminRegionsService.getAdminRegionWithSourcingLocations(
          treeOptions,
        );
      }
      case GROUP_BY_VALUES.T1_SUPPLIER: {
        return this.suppliersService.getSuppliersTreeWithSourcingLocations(
          {
            ...treeOptions,
            type: SUPPLIER_TYPES.T1SUPPLIER,
          },
          false,
        );
      }
      case GROUP_BY_VALUES.PRODUCER: {
        return this.suppliersService.getSuppliersTreeWithSourcingLocations(
          {
            ...treeOptions,
            type: SUPPLIER_TYPES.PRODUCER,
          },
          false,
        );
      }
      case GROUP_BY_VALUES.BUSINESS_UNIT:
        return this.businessUnitsService.getBusinessUnitWithSourcingLocations(
          treeOptions,
        );

      case GROUP_BY_VALUES.LOCATION_TYPE:
        return (
          await this.sourcingLocationsService.getAvailableLocationTypes(
            treeOptions,
          )
        ).map((entity: LOCATION_TYPES) => {
          return { name: entity, children: [] };
        });

      default:
        return [];
    }
  }

  /**
   * Modifies the impactTableDto in-place, to put the ids of the entity tree into the
   * corresponding entity Ids field according to the groupBy
   * @param impactTableDto
   * @param entities
   */
  getFlatListOfEntityIdsForLaterFiltering(
    impactTableDto: AnyImpactTableDto,
    entities: ImpactTableEntityType[],
  ): void {
    switch (impactTableDto.groupBy) {
      case GROUP_BY_VALUES.MATERIAL:
        impactTableDto.materialIds = BaseImpactService.getIdsFromTree(
          entities,
          impactTableDto.materialIds,
        );
        break;
      case GROUP_BY_VALUES.REGION:
        impactTableDto.originIds = BaseImpactService.getIdsFromTree(
          entities,
          impactTableDto.originIds,
        );
        break;

      case GROUP_BY_VALUES.BUSINESS_UNIT:
        impactTableDto.businessUnitIds = BaseImpactService.getIdsFromTree(
          entities,
          impactTableDto.businessUnitIds,
        );
        break;

      default:
    }
  }

  getDataForImpactTable(
    impactTableDto: AnyImpactTableDto,
    entities: ImpactTableEntityType[],
  ): Promise<ImpactTableData[]> {
    if (entities.length) {
      if (
        impactTableDto instanceof GetImpactTableDto ||
        impactTableDto instanceof GetRankedImpactTableDto
      ) {
        return this.impactRepository.getDataForImpactTable(impactTableDto);
      } else if (impactTableDto instanceof GetActualVsScenarioImpactTableDto) {
        return this.impactRepository.getDataForActualVsScenarioImpactTable(
          impactTableDto,
        );
      } else {
        return this.impactRepository.getDataForActualVsScenarioImpactTable(
          impactTableDto,
        );
      }
    }
    return Promise.resolve([]);
  }

  getTotalPurchasedVolumeByYear(
    rangeOfYears: number[],
    dataForImpactTable: ImpactTableData[],
    lastYearWithData: number,
    scenarioId?: string,
  ): ImpactTablePurchasedTonnes[] {
    // First scan the whole table to accumulate the values for each year
    const purchasedTonnesYearMap: Map<number, number> = new Map();

    for (const impactTableData of dataForImpactTable) {
      const accumulatedPurchasedTonnes: number | undefined =
        purchasedTonnesYearMap.get(impactTableData.year) || 0;

      const currentPurchasedTonnes: number = Number.isFinite(
        +impactTableData.tonnes,
      )
        ? +impactTableData.tonnes // TODO!!!!!!
        : 0;

      purchasedTonnesYearMap.set(
        impactTableData.year,
        currentPurchasedTonnes + accumulatedPurchasedTonnes,
      );
    }

    //Calculate projected data for missing years and construct result array
    const purchasedTonnes: ImpactTablePurchasedTonnes[] = [];
    rangeOfYears.forEach((year: number) => {
      let currentPurchasedTonnesByYear: number | undefined =
        purchasedTonnesYearMap.get(year);
      let isProjected: boolean = false;

      // If value exist for that year, append it. There will always be a first valid value to start with
      // If it does not exist, get the previous value and project it
      if (!currentPurchasedTonnesByYear) {
        // TODO: this is hotfix for situations where we receive a start year for which we don't have data
        const previousYearTonnage: number =
          purchasedTonnes.length > 0
            ? purchasedTonnes[purchasedTonnes.length - 1].value
            : 0;
        const tonnesToProject: number =
          dataForImpactTable.length > 0 ? previousYearTonnage : 0;

        currentPurchasedTonnesByYear =
          tonnesToProject + (tonnesToProject * this.growthRate) / 100;
        isProjected = year > lastYearWithData;
      }

      purchasedTonnes.push({
        year,
        value: currentPurchasedTonnesByYear,
        isProjected,
      });
    });

    if (scenarioId)
      purchasedTonnes.map((el: ImpactTablePurchasedTonnes) => (el.value /= 2));

    return purchasedTonnes;
  }

  protected static getIdsFromTree(
    entities: ImpactTableEntityType[],
    entityIds?: string[],
  ): string[] {
    const idsFromTree: string[] = entities.reduce(
      (ids: string[], entity: ImpactTableEntityType) => {
        const childIds: string[] =
          entity.children.length > 0
            ? this.getIdsFromTree(entity.children)
            : [];
        return [...ids, ...childIds, entity.id];
      },
      [],
    );
    return entityIds
      ? entityIds.filter((value: string) => idsFromTree.includes(value))
      : idsFromTree;
  }

  protected static paginateRootEntities(
    entities: ImpactTableEntityType[],
    fetchSpecification: FetchSpecification,
  ): PaginatedEntitiesDto {
    if (fetchSpecification.disablePagination) {
      return {
        entities,
        metadata: undefined,
      };
    }
    const totalItems: number = entities.length;
    const pageSize: number =
      fetchSpecification?.pageSize ?? DEFAULT_PAGINATION.pageSize ?? 25;
    const page: number =
      fetchSpecification?.pageNumber ?? DEFAULT_PAGINATION.pageNumber ?? 1;
    return {
      entities: entities.slice((page - 1) * pageSize, page * pageSize),
      metadata: new PaginationMeta({
        totalPages: Math.ceil(totalItems / pageSize),
        totalItems,
        size: pageSize,
        page,
      }),
    };
  }

  static paginateTable(data: any, fetchSpecification: FetchSpecification): any {
    if (fetchSpecification.disablePagination) {
      return {
        data,
        metadata: undefined,
      };
    }

    const pageSize: number =
      fetchSpecification?.pageSize ?? DEFAULT_PAGINATION.pageSize ?? 25;
    const page: number =
      fetchSpecification?.pageNumber ?? DEFAULT_PAGINATION.pageNumber ?? 1;

    // Make a shallow copy of the data to avoid mutating the original
    const paginatedData: any = { ...data };

    const totalItems: number = paginatedData.impactTable[0]?.rows?.length;
    const totalPages: number = Math.ceil(totalItems / pageSize);

    // If there are no rows or the number of rows is less than the page size, return the data as is
    if (totalItems === 0 || totalItems < pageSize) {
      return {
        data: paginatedData,
        metadata: new PaginationMeta({
          totalPages,
          totalItems,
          size: pageSize,
          page,
        }),
      };
    }
    // For each indicator, paginate its rows array
    for (let i: number = 0; i < paginatedData.impactTable.length; i++) {
      paginatedData.impactTable[i].rows = paginatedData.impactTable[
        i
      ].rows.slice((page - 1) * pageSize, page * pageSize);
    }

    return {
      data: paginatedData,
      metadata: new PaginationMeta({
        totalPages,
        totalItems,
        size: pageSize,
        page,
      }),
    };
  }

  /**
   * Converts an array of flat Impact Table Data (either for normal, Actual Vs Scenario or Scenario vs Scenario)
   * to a pseudo-tree structure of corresponding RowsValues Instance  based on maps for easier/faster access, like this
   *    Map<
   *      indicatorId:string,
   *      Map<
   *        entityName:string,
   *        Map<
   *          year:number,
   *          ImpactTableRowsValues
   *        >
   *      >
   *    >
   *   {
   *     "deforestationRiskId": {
   *       "Brazil": {
   *         2010: {
   *           year: 2010
   *           value: 12483
   *           ....
   *         },
   *         2011: {...},
   *         ......
   *       },
   *       "ParanĂ¡": {...},
   *       ....
   *     },
   *     "waterUseIndicatorId": {....},
   *     .....
   *   }
   * dataToRowsValuesFunc will be used to create the RowsValues instances of the given type from the given Impact Table Data instance
   * It also determines the last year with Data, so that another full sweep of the database can be avoided for
   * optimization
   * @param dataForImpactTable
   * @param dataToRowsValuesFunc
   * @private
   */
  static impactTableDataArrayToAuxMapV2<
    Data extends AnyImpactTableData,
    RowsValues extends AnyImpactTableRowsValues,
  >(
    dataForImpactTable: Data[],
    dataToRowsValuesFunc: (data: Data) => RowsValues,
  ): [ImpactDataTableAuxMap<RowsValues>, number] {
    // NOTE: the impact Table Data can be quite large, so to calculate the maximum number of years is not as trivial
    // as using Math.max(...impactTable.map(...)) since the call stack will be exceeded because of no of arguments
    const yearsWithData: Set<number> = new Set();

    const indicatorEntityMap: ImpactDataTableAuxMap<RowsValues> = new Map();

    // Convert the flat structure on array to tree of Maps for easier access
    for (const impactTableData of dataForImpactTable) {
      let entityMap: Map<string, Map<number, RowsValues>> | undefined =
        indicatorEntityMap.get(impactTableData.indicatorId);

      if (!entityMap) {
        entityMap = new Map();
        indicatorEntityMap.set(impactTableData.indicatorId, entityMap);
      }

      let yearMap: Map<number, RowsValues> | undefined = entityMap.get(
        impactTableData.name,
      );
      if (!yearMap) {
        yearMap = new Map();
        entityMap.set(impactTableData.name, yearMap);
      }

      yearMap.set(impactTableData.year, dataToRowsValuesFunc(impactTableData));

      yearsWithData.add(impactTableData.year);
    }

    const lastYearWithData: number = Math.max(...yearsWithData.values());

    return [indicatorEntityMap, lastYearWithData];
  }

  /**
   * This method preprocesses Impact Table data for comparison between actual data and an scenario
   * @param impactTableData
   */
  static processDataForComparison(
    impactTableData: ImpactTableData[],
  ): ActualVsScenarioImpactTableData[] {
    // Separate the data into different maps depending on whether data is from a scenario or not
    const actualData: Map<string, ActualVsScenarioImpactTableData> = new Map();
    const scenarioData: ActualVsScenarioImpactTableData[] = [];
    for (const data of impactTableData) {
      const key: string = BaseImpactService.getImpactTableDataKey(data);

      // By default, if real sourcing data is not affected by scenario - we assume that real impact and scenario impact are the same
      // so scenarioImpact is initialized with impact value for all instances
      const actualVsScenarioData: ActualVsScenarioImpactTableData =
        Object.assign({ scenarioImpact: data.impact }, data);

      data.typeByIntervention !== null
        ? scenarioData.push(actualVsScenarioData)
        : actualData.set(key, actualVsScenarioData);
    }

    // For all actual data check if there's corresponding scenario data and aggregate it, and discarding it afterwards
    // Once finished, the remaining data on the scenario map will be scenario data has no matching actual data
    // (for example, if scenario objective is to use a new material that has never been purchased before, so there is no 'real' data for this material)
    const scenarioDataWithoutRealData: ActualVsScenarioImpactTableData[] = [];
    for (const data of scenarioData.values()) {
      const matchingMatchingData: ActualVsScenarioImpactTableData | undefined =
        actualData.get(BaseImpactService.getImpactTableDataKey(data));

      if (matchingMatchingData) {
        matchingMatchingData.scenarioImpact += data.impact;
      } else {
        scenarioDataWithoutRealData.push(data);
      }
    }

    const result: ActualVsScenarioImpactTableData[] = Array.from(
      actualData.values(),
    );

    //Include the impact for scenario without a matching actual data in the result with an actual impact value of 0
    scenarioDataWithoutRealData.forEach(
      (data: ActualVsScenarioImpactTableData) => {
        //data.scenarioImpact = data.impact;
        data.impact = 0;
        result.push(data);
      },
    );

    return result;
  }

  /**
   * Small helper function to get the combined IndicatorId+EntityName+Year to facilitate pre processing of
   * Impact Table Data before building the impact table
   * @param data
   */
  static getImpactTableDataKey(data: ImpactTableData): string {
    return data.indicatorId + '-' + data.name + '-' + data.year;
  }

  static sortRowValueByYear(
    a: AnyImpactTableRowsValues,
    b: AnyImpactTableRowsValues,
  ): number {
    return a.year - b.year;
  }
}

export type ImpactDataTableAuxMap<T extends AnyImpactTableRowsValues> = Map<
  string,
  Map<string, Map<number, T>>
>;