Vizzuality/landgriffon

View on GitHub
api/src/modules/import-data/sourcing-data/sourcing-data-import.service.ts

Summary

Maintainability
C
7 hrs
Test Coverage
A
92%
import {
  Injectable,
  Logger,
  ServiceUnavailableException,
} from '@nestjs/common';
import { MaterialsService } from 'modules/materials/materials.service';
import { BusinessUnitsService } from 'modules/business-units/business-units.service';
import { SuppliersService } from 'modules/suppliers/suppliers.service';
import { SourcingLocationsService } from 'modules/sourcing-locations/sourcing-locations.service';
import { FileService } from 'modules/import-data/file.service';
import { SourcingData } from 'modules/import-data/sourcing-data/dto-processor.service';
import { Supplier } from 'modules/suppliers/supplier.entity';
import { Material } from 'modules/materials/material.entity';
import { BusinessUnit } from 'modules/business-units/business-unit.entity';
import { GeoCodingAbstractClass } from 'modules/geo-coding/geo-coding-abstract-class';
import { TasksService } from 'modules/tasks/tasks.service';
import { IndicatorsService } from 'modules/indicators/indicators.service';
import { Indicator } from 'modules/indicators/indicator.entity';
import { ImpactService } from 'modules/impact/impact.service';
import { ImpactCalculator } from 'modules/indicator-records/services/impact-calculator.service';
import {
  ExcelValidatorService,
  SourcingDataSheet,
} from 'modules/import-data/sourcing-data/validation/excel-validator.service';
import { ExcelValidationError } from 'modules/import-data/sourcing-data/validation/validators/excel-validation.error';
import { GeoCodingError } from 'modules/geo-coding/errors/geo-coding.error';
import { SourcingDataDbCleaner } from 'modules/import-data/sourcing-data/sourcing-data.db-cleaner';
import { SourcingLocation } from 'modules/sourcing-locations/sourcing-location.entity';

export interface SourcingRecordsSheets extends Record<string, any[]> {
  materials: Record<string, any>[];
  countries: Record<string, any>[];
  businessUnits: Record<string, any>[];
  suppliers: Record<string, any>[];
  sourcingData: Record<string, any>[];
  indicators: Record<string, any>[];
}

const SHEETS_MAP: Record<string, keyof SourcingRecordsSheets> = {
  materials: 'materials',
  'business units': 'businessUnits',
  suppliers: 'suppliers',
  countries: 'countries',
  indicators: 'indicators',
  'for upload': 'sourcingData',
};

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

  constructor(
    protected readonly materialService: MaterialsService,
    protected readonly businessUnitService: BusinessUnitsService,
    protected readonly supplierService: SuppliersService,
    protected readonly sourcingLocationService: SourcingLocationsService,
    protected readonly fileService: FileService<SourcingRecordsSheets>,
    protected readonly geoCodingService: GeoCodingAbstractClass,
    protected readonly tasksService: TasksService,
    protected readonly indicatorService: IndicatorsService,
    protected readonly impactService: ImpactService,
    protected readonly impactCalculator: ImpactCalculator,
    protected readonly excelValidator: ExcelValidatorService,
    protected readonly dbCleaner: SourcingDataDbCleaner,
  ) {}

  async importSourcingData(filePath: string, taskId: string): Promise<any> {
    this.logger.log(`Starting import process`);
    await this.fileService.isFilePresentInFs(filePath);
    try {
      const parsedXLSXDataset: SourcingRecordsSheets =
        await this.fileService.transformToJson(filePath, SHEETS_MAP);

      const { data: dtoMatchedData, validationErrors } =
        await this.excelValidator.validate(
          parsedXLSXDataset as unknown as SourcingDataSheet,
        );
      if (validationErrors.length) {
        throw new ExcelValidationError('Validation Errors', validationErrors);
      }

      //TODO: Implement transactional import. Move geocoding to first step

      await this.dbCleaner.cleanDataBeforeImport();

      const materials: Material[] =
        await this.materialService.findAllUnpaginated();
      if (!materials.length) {
        throw new ServiceUnavailableException(
          'No Materials found present in the DB. Please check the LandGriffon installation manual',
        );
      }
      this.logger.log('Activating Indicators...');
      const activeIndicators: Indicator[] =
        await this.indicatorService.activateIndicators(
          dtoMatchedData.indicators,
        );
      this.logger.log('Activating Materials...');
      const activeMaterials: Material[] =
        await this.materialService.activateMaterials(dtoMatchedData.materials);

      await this.tasksService.updateImportTask({
        taskId,
        newLogs: [
          `Activated indicators: ${activeIndicators
            .map((i: Indicator) => i.name)
            .join(', ')}`,
          `Activated materials: ${activeMaterials
            .map((i: Material) => i.hsCodeId)
            .join(', ')}`,
        ],
      });

      const businessUnits: BusinessUnit[] =
        await this.businessUnitService.createTree(dtoMatchedData.businessUnits);

      const suppliers: Supplier[] = await this.supplierService.createTree(
        dtoMatchedData.suppliers,
      );

      const { geoCodedSourcingData, errors } =
        await this.geoCodingService.geoCodeLocations(
          dtoMatchedData.sourcingData,
        );
      if (errors.length) {
        throw new GeoCodingError(
          'Import failed. There are GeoCoding errors present in the file',
          errors,
        );
      }
      const warnings: string[] = [];
      geoCodedSourcingData.forEach((elem: SourcingData) => {
        if (elem.locationWarning) warnings.push(elem.locationWarning);
      });
      warnings.length > 0 &&
        (await this.tasksService.updateImportTask({
          taskId,
          newLogs: warnings,
        }));

      const sourcingDataWithOrganizationalEntities: SourcingLocation[] =
        await this.relateSourcingDataWithOrganizationalEntities(
          suppliers,
          businessUnits,
          materials,
          geoCodedSourcingData,
        );

      await this.sourcingLocationService.save(
        sourcingDataWithOrganizationalEntities,
      );

      this.logger.log('Generating Indicator Records...');

      // TODO: Current approach calculates Impact for all Sourcing Records present in the DB
      //       Getting H3 data for calculations is done within DB so we need to improve the error handling
      //       TBD: What to do when there is no H3 for a Material

      try {
        await this.impactCalculator.calculateImpactForAllSourcingRecords(
          activeIndicators,
        );
        this.logger.log('Indicator Records generated');
        await this.impactService.updateImpactView();
      } catch (err: any) {
        this.logger.error(err);
        throw new ServiceUnavailableException(
          'Could not calculate Impact for current data. Please contact with the administrator',
        );
      }
    } finally {
      await this.fileService.deleteDataFromFS(filePath);
    }
  }

  /**
   * @note: Type hack as mpath property does not exist on Materials and BusinessUnits, but its created
   * by typeorm when using @Tree('materialized-path)'.
   * It's what we can use to know which material/business unit relates to which sourcing-location
   * in a synchronous way avoiding hitting the DB
   */
  async relateSourcingDataWithOrganizationalEntities(
    suppliers: Supplier[],
    businessUnits: Record<string, any>[],
    materials: Material[],
    sourcingData: SourcingData[],
  ): Promise<SourcingLocation[]> {
    this.logger.log(`Relating sourcing data with organizational entities`);
    this.logger.log(`Supplier count: ${suppliers.length}`);
    this.logger.log(`Business Units count: ${businessUnits.length}`);
    this.logger.log(`Materials count: ${materials.length}`);
    this.logger.log(`Sourcing Data count: ${sourcingData.length}`);

    const materialMap: Record<string, string> = {};
    materials.forEach((material: Material) => {
      if (!material.hsCodeId) {
        return;
      }
      materialMap[material.hsCodeId] = material.id;
    });

    for (const sourcingLocation of sourcingData) {
      for (const supplier of suppliers) {
        if (sourcingLocation.producerId === supplier.mpath) {
          sourcingLocation.producerId = supplier.id;
        }
        if (sourcingLocation.t1SupplierId === supplier.mpath) {
          sourcingLocation.t1SupplierId = supplier.id;
        }
      }
      for (const businessUnit of businessUnits) {
        if (sourcingLocation.businessUnitId === businessUnit.mpath) {
          sourcingLocation.businessUnitId = businessUnit.id;
        }
      }

      const sourcingLocationMaterialId: string = sourcingLocation.materialId;

      if (!(sourcingLocationMaterialId in materialMap)) {
        throw new Error(
          `Could not import sourcing location - material code ${sourcingLocationMaterialId} not found`,
        );
      }
      sourcingLocation.materialId = materialMap[sourcingLocationMaterialId];
    }
    return sourcingData as SourcingLocation[];
  }
}