Vizzuality/landgriffon

View on GitHub
api/src/modules/impact/comparison/actual-vs-scenario.service.ts

Summary

Maintainability
F
1 wk
Test Coverage
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';
import {
  ActualVsScenarioImpactTableData,
  ImpactTableData,
} from 'modules/sourcing-records/sourcing-record.repository';
import { Indicator } from 'modules/indicators/indicator.entity';
import { range } from 'lodash';
import { ImpactTablePurchasedTonnes } from 'modules/impact/dto/response-impact-table.dto';
import { ImpactTableEntityType } from 'types/impact-table-entity.type';
import { FetchSpecification } from 'nestjs-base-service';
import {
  ActualVsScenarioImpactTable,
  ActualVsScenarioImpactTableDataByIndicator,
  ActualVsScenarioImpactTableRows,
  ActualVsScenarioImpactTableRowsValues,
  ActualVsScenarioPaginatedImpactTable,
  ActualVsScenarioIndicatorSumByYear,
} from 'modules/impact/dto/response-actual-scenario.dto';
import {
  BaseImpactService,
  ImpactDataTableAuxMap,
} from 'modules/impact/base-impact.service';

@Injectable()
export class ActualVsScenarioImpactService {
  logger: Logger = new Logger(ActualVsScenarioImpactService.name);

  constructor(
    protected readonly indicatorService: IndicatorsService,
    protected readonly baseService: BaseImpactService,
  ) {}

  async getActualVsScenarioImpactTable(
    dto: GetActualVsScenarioImpactTableDto,
    fetchSpecification: FetchSpecification,
  ): Promise<ActualVsScenarioPaginatedImpactTable> {
    const indicators: Indicator[] =
      await this.indicatorService.getIndicatorsById(dto.indicatorIds);
    this.logger.log('Retrieving data from DB to build Impact Table...');

    //Getting Descendants Ids for the filters, in case Parent Ids were received
    await this.baseService.loadDescendantEntityIds(dto);

    // Get full entity tree in case ids are not passed, otherwise get trees based on
    // given ids and add children and parent ids to them to get full data for aggregations
    const entities: ImpactTableEntityType[] =
      await this.baseService.getEntityTree(dto);

    this.baseService.getFlatListOfEntityIdsForLaterFiltering(dto, entities);

    const dataForActualVsScenarioImpactTable: ImpactTableData[] =
      await this.baseService.getDataForImpactTable(dto, entities);

    const processedDataForComparison: ActualVsScenarioImpactTableData[] =
      BaseImpactService.processDataForComparison(
        dataForActualVsScenarioImpactTable,
      );

    const impactTable: ActualVsScenarioImpactTable =
      this.buildActualVsScenarioImpactTable(
        dto,
        indicators,
        processedDataForComparison,
        entities,
      );

    this.sortEntitiesByImpactOfYear(
      impactTable,
      dto.sortingYear,
      dto.sortingOrder,
    );

    const paginatedTable: any = BaseImpactService.paginateTable(
      impactTable,
      fetchSpecification,
    );

    return paginatedTable;
  }

  private buildActualVsScenarioImpactTable(
    queryDto: GetActualVsScenarioImpactTableDto,
    indicators: Indicator[],
    dataForImpactTable: ActualVsScenarioImpactTableData[],
    entityTree: ImpactTableEntityType[],
  ): ActualVsScenarioImpactTable {
    this.logger.log('Building Impact Table...');
    const { groupBy, startYear, endYear } = queryDto;

    const auxIndicatorMap: Map<string, Indicator> = new Map(
      indicators.map((value: Indicator) => [value.id, value]),
    );

    // Create a range of years by start and endYears
    const rangeOfYears: number[] = range(startYear, endYear + 1);

    //Auxiliary structure in order to avoid scanning the whole table more than once
    const [indicatorEntityMap, lastYearWithData]: [
      ImpactDataTableAuxMap<ActualVsScenarioImpactTableRowsValues>,
      number,
    ] = BaseImpactService.impactTableDataArrayToAuxMapV2<
      ActualVsScenarioImpactTableData,
      ActualVsScenarioImpactTableRowsValues
    >(dataForImpactTable, this.createActualVsScenarioRowValueFromImpactData);

    // construct result impact Table
    const impactTable: ActualVsScenarioImpactTableDataByIndicator[] = [];

    for (const [indicatorId, entityMap] of indicatorEntityMap.entries()) {
      const indicator: Indicator = auxIndicatorMap.get(
        indicatorId,
      ) as Indicator;
      const impactTableDataByIndicator: ActualVsScenarioImpactTableDataByIndicator =
        this.createActualVsScenarioImpactTableDataByIndicator(
          indicator,
          groupBy,
        );
      impactTable.push(impactTableDataByIndicator);

      // since some entities may be missing values for any given year, we need to do another pass to calculate
      // values for missing or projected years, and also calculates the total sum for each year
      this.postProcessYearIndicatorData(
        entityMap,
        rangeOfYears,
        lastYearWithData,
      );

      // copy and populate tree skeleton for each indicator
      const impactTableEntitySkeleton: ActualVsScenarioImpactTableRows[] =
        this.buildActualVsScenarioImpactTableRowsSkeleton(entityTree);

      for (const entity of impactTableEntitySkeleton) {
        this.populateValuesRecursively(entity, entityMap, rangeOfYears);
      }

      impactTableDataByIndicator.rows = impactTableEntitySkeleton;

      impactTableDataByIndicator.yearSum = this.calculateIndicatorSumByYear(
        entityMap,
        rangeOfYears,
        lastYearWithData,
      );
    }

    const purchasedTonnes: ImpactTablePurchasedTonnes[] =
      this.baseService.getTotalPurchasedVolumeByYear(
        rangeOfYears,
        dataForImpactTable,
        lastYearWithData,
      );
    this.logger.log('Impact Table built');

    return { impactTable, purchasedTonnes };
  }

  /**
   * This functions fills, in-place, any missing years in the entities' yearMap, with the calculation based
   * on previous years' data
   * @param rangeOfYears
   * @param lastYearWithData
   * @param entityMap
   * @private
   */
  private postProcessYearIndicatorData(
    entityMap: Map<string, Map<number, ActualVsScenarioImpactTableRowsValues>>,
    rangeOfYears: number[],
    lastYearWithData: number,
  ): void {
    for (const yearMap of entityMap.values()) {
      const auxYearValues: number[] = [];
      const auxYearScenarioValues: number[] = [];

      for (const [index, year] of rangeOfYears.entries()) {
        let dataForYear: ActualVsScenarioImpactTableRowsValues | undefined =
          yearMap.get(year);

        //If the year requested by the users exist in the raw data, append its value. There will always be a first valid value to start with
        if (!dataForYear) {
          // If the year requested does no exist in the raw data, project its value getting the latest value (previous year which comes in ascendant order)
          const isProjected: boolean = year > lastYearWithData;

          const lastYearsValue: number =
            index > 0 ? auxYearValues[index - 1] : 0;
          const lastYearsScenarioValue: number =
            index > 0 ? auxYearScenarioValues[index - 1] || 0 : 0;
          const value: number =
            lastYearsValue +
            (lastYearsValue * this.baseService.growthRate) / 100;
          const comparedScenarioValue: number =
            lastYearsScenarioValue +
            (lastYearsScenarioValue * this.baseService.growthRate) / 100;

          dataForYear = {
            year,
            value,
            comparedScenarioValue,
            isProjected,
            absoluteDifference: 0,
            percentageDifference: 0,
          };
          yearMap.set(year, dataForYear);
        }

        auxYearValues.push(dataForYear.value);
        auxYearScenarioValues.push(dataForYear.comparedScenarioValue || 0);
      }
    }
  }

  /**
   * Returns an Array containing the sum of the values of all entities, by each year in rangeOfYears
   * @param entityMap
   * @param rangeOfYears
   * @param lastYearWithData
   * @private
   */
  private calculateIndicatorSumByYear(
    entityMap: Map<string, Map<number, ActualVsScenarioImpactTableRowsValues>>,
    rangeOfYears: number[],
    lastYearWithData: number,
  ): ActualVsScenarioIndicatorSumByYear[] {
    const yearSumMap: Map<number, number> = new Map();
    const yearScenarioSumMap: Map<number, number> = new Map();

    //Iterate over the entities to aggregate the year totals
    for (const dataByYearMap of entityMap.values()) {
      for (const year of rangeOfYears) {
        const dataByYear: ActualVsScenarioImpactTableRowsValues =
          dataByYearMap.get(year) as ActualVsScenarioImpactTableRowsValues;

        const yearSum: number = (yearSumMap.get(year) || 0) + dataByYear.value;
        const yearScenarioSum: number =
          (yearScenarioSumMap.get(year) || 0) +
          (dataByYear.comparedScenarioValue || 0);

        yearSumMap.set(year, yearSum);
        yearScenarioSumMap.set(year, yearScenarioSum);
      }
    }

    //Return the result Array from the year total Maps
    return rangeOfYears.map((year: number) => {
      const totalSumByYear: number = yearSumMap.get(year) || 0;
      const totalScenarioSumByYear: number = yearScenarioSumMap.get(year) || 0;
      const absoluteDifference: number =
        totalScenarioSumByYear - totalSumByYear;
      const percentageDifference: number =
        ((totalScenarioSumByYear - totalSumByYear) /
          ((totalScenarioSumByYear + totalSumByYear) / 2)) *
        100;

      return {
        year,
        value: totalSumByYear,
        comparedScenarioValue: totalScenarioSumByYear,
        absoluteDifference,
        percentageDifference: isNaN(percentageDifference)
          ? 0
          : percentageDifference,
        isProjected: year > lastYearWithData,
      };
    });
  }

  /**
   * @description Recursive function that populates and returns
   * aggregated data of parent entity and all its children
   */
  private populateValuesRecursively(
    entity: ActualVsScenarioImpactTableRows,
    entityDataMap: Map<
      string,
      Map<number, ActualVsScenarioImpactTableRowsValues>
    >,
    rangeOfYears: number[],
  ): ActualVsScenarioImpactTableRowsValues[] {
    entity.values = [];
    for (const year of rangeOfYears) {
      const rowsValues: ActualVsScenarioImpactTableRowsValues = {
        year: year,
        value: 0,
        comparedScenarioValue: 0,
        absoluteDifference: 0,
        percentageDifference: 0,
        isProjected: false,
      };
      entity.values.push(rowsValues);
    }

    const valuesToAggregate: ActualVsScenarioImpactTableRowsValues[][] = [];
    const selfData:
      | Map<number, ActualVsScenarioImpactTableRowsValues>
      | undefined = entityDataMap.get(entity.name);
    if (selfData) {
      const sortedSelfData: ActualVsScenarioImpactTableRowsValues[] =
        Array.from(selfData.values()).sort(
          BaseImpactService.sortRowValueByYear,
        );
      valuesToAggregate.push(sortedSelfData);
    }
    entity.children.forEach((childEntity: ActualVsScenarioImpactTableRows) => {
      //first aggregate data of child entity and then add returned value for parents aggregation
      const childValues: ActualVsScenarioImpactTableRowsValues[] =
        this.populateValuesRecursively(
          childEntity,
          entityDataMap,
          rangeOfYears,
        );
      valuesToAggregate.push(childValues);
    });

    for (const [valueIndex, entityRowValue] of entity.values.entries()) {
      for (const valueToAggregate of valuesToAggregate) {
        entityRowValue.value += valueToAggregate[valueIndex].value;
        entityRowValue.comparedScenarioValue +=
          valueToAggregate[valueIndex].comparedScenarioValue;
        entityRowValue.isProjected =
          valueToAggregate[valueIndex].isProjected ||
          entityRowValue.isProjected;

        const absoluteDifference: number =
          entityRowValue.comparedScenarioValue - entityRowValue.value;
        const percentageDifference: number =
          ((entityRowValue.comparedScenarioValue - entityRowValue.value) /
            ((entityRowValue.comparedScenarioValue + entityRowValue.value) /
              2)) *
          100;

        entityRowValue.absoluteDifference = isNaN(absoluteDifference)
          ? 0
          : absoluteDifference;
        entityRowValue.percentageDifference = isNaN(percentageDifference)
          ? 0
          : percentageDifference;
      }
    }
    return entity.values;
  }

  private buildActualVsScenarioImpactTableRowsSkeleton(
    entities: ImpactTableEntityType[],
  ): ActualVsScenarioImpactTableRows[] {
    return entities.map((item: ImpactTableEntityType) => {
      return {
        name: item.name || '',
        children:
          item.children?.length > 0
            ? this.buildActualVsScenarioImpactTableRowsSkeleton(item.children)
            : [],
        values: [],
      };
    });
  }

  // For all indicators, entities are sorted by the value of the given sortingYear, in the order given by sortingOrder
  private sortEntitiesByImpactOfYear(
    impactTable: ActualVsScenarioImpactTable,
    sortingYear: number | undefined,
    sortingOrder: ORDER_BY | undefined = ORDER_BY.ASC,
  ): void {
    if (!sortingYear) {
      return;
    }

    for (const impactTableDataByIndicator of impactTable.impactTable) {
      this.sortEntitiesRecursively(
        impactTableDataByIndicator.rows,
        sortingYear,
        sortingOrder,
      );
    }
  }

  // Entities represented by ImpactTableRows will be sorted recursively by the absoluteDifference value of the given
  // sortingYear, in the given sortingOrder
  private sortEntitiesRecursively(
    rows: ActualVsScenarioImpactTableRows[],
    sortingYear: number,
    sortingOrder: ORDER_BY,
  ): void {
    if (rows.length === 0) {
      return;
    }

    for (const row of rows) {
      this.sortEntitiesRecursively(row.children, sortingYear, sortingOrder);
    }

    rows.sort(
      (
        a: ActualVsScenarioImpactTableRows,
        b: ActualVsScenarioImpactTableRows,
      ) =>
        sortingOrder === ORDER_BY.ASC
          ? this.getYearAbsoluteDifference(a, sortingYear) -
            this.getYearAbsoluteDifference(b, sortingYear)
          : this.getYearAbsoluteDifference(b, sortingYear) -
            this.getYearAbsoluteDifference(a, sortingYear),
    );
  }

  // Gets the absolute difference of the given year of the TableRow, if not found, 0 is returned
  // Helper function (for readability) used in sorting the entities by the absolute difference of impact on the given year,
  private getYearAbsoluteDifference(
    row: ActualVsScenarioImpactTableRows,
    year: number,
  ): number {
    const yearValue: ActualVsScenarioImpactTableRowsValues | undefined =
      row.values.find(
        (value: ActualVsScenarioImpactTableRowsValues) => value.year === year,
      );

    return yearValue ? yearValue.absoluteDifference : 0;
  }

  private createActualVsScenarioImpactTableDataByIndicator(
    indicator: Indicator,
    groupBy: GROUP_BY_VALUES,
  ): ActualVsScenarioImpactTableDataByIndicator {
    return {
      indicatorShortName: indicator.shortName as string,
      indicatorId: indicator.id,
      groupBy: groupBy,
      rows: [],
      yearSum: [],
      metadata: { unit: indicator.unit.symbol },
    };
  }

  private createActualVsScenarioRowValueFromImpactData(
    data: ActualVsScenarioImpactTableData,
  ): ActualVsScenarioImpactTableRowsValues {
    return {
      year: data.year,
      value: data.impact,
      comparedScenarioValue: data.scenarioImpact,
      isProjected: false,
      absoluteDifference: 0,
      percentageDifference: 0,
    };
  }
}