diff --git a/.gitignore b/.gitignore index e30c06e7..071fc8f6 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,7 @@ src/scripts/substances_max_min_calculated_tons.json src/scripts/error_response_report.json src/scripts/d2_tracker_events_updated.json +# unzip files +glass-amc-recalculate-server +glass-async-deletions-server diff --git a/i18n/en.pot b/i18n/en.pot index c81c3089..31ee4209 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-08-07T18:05:19.338Z\n" -"PO-Revision-Date: 2024-08-07T18:05:19.338Z\n" +"POT-Creation-Date: 2024-12-12T19:21:44.978Z\n" +"PO-Revision-Date: 2024-12-12T19:21:44.978Z\n" msgid "Template {{id}} not loaded" msgstr "" @@ -345,7 +345,7 @@ msgstr "" msgid "" "IMPORTED - The data has been imported, but validations(if applicable) were " -"not run successfully, in Step 2" +"not run successfully, in Step 2." msgstr "" msgid "" @@ -365,7 +365,7 @@ msgstr "" msgid "Complete" msgstr "" -msgid "Failed to set completed status" +msgid "Error occurred when setting data submission status to COMPLETED" msgstr "" msgid "Deleting Files" @@ -377,6 +377,9 @@ msgstr "" msgid "This might take several minutes, do not refresh the page or press back." msgstr "" +msgid "Marked to be deleted" +msgstr "" + msgid "Error downloading data" msgstr "" @@ -518,6 +521,9 @@ msgstr "" msgid "You don't have read access to this page" msgstr "" +msgid "Save Questionnaire" +msgstr "" + msgid "Set as incomplete" msgstr "" @@ -557,6 +563,9 @@ msgstr "" msgid "Failed to set upload status" msgstr "" +msgid "Failed to calculate consumption data" +msgstr "" + msgid "Importing data and applying validation rules" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index e718ebdd..0668decd 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-08-07T18:05:19.338Z\n" +"POT-Creation-Date: 2024-12-12T19:21:44.978Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -346,7 +346,7 @@ msgstr "" msgid "" "IMPORTED - The data has been imported, but validations(if applicable) were " -"not run successfully, in Step 2" +"not run successfully, in Step 2." msgstr "" msgid "" @@ -367,7 +367,7 @@ msgstr "" msgid "Complete" msgstr "" -msgid "Failed to set completed status" +msgid "Error occurred when setting data submission status to COMPLETED" msgstr "" msgid "Deleting Files" @@ -379,6 +379,9 @@ msgstr "" msgid "This might take several minutes, do not refresh the page or press back." msgstr "" +msgid "Marked to be deleted" +msgstr "" + msgid "Error downloading data" msgstr "" @@ -520,6 +523,9 @@ msgstr "" msgid "You don't have read access to this page" msgstr "" +msgid "Save Questionnaire" +msgstr "" + msgid "Set as incomplete" msgstr "" @@ -559,6 +565,9 @@ msgstr "" msgid "Failed to set upload status" msgstr "" +msgid "Failed to calculate consumption data" +msgstr "" + msgid "Importing data and applying validation rules" msgstr "" diff --git a/package.json b/package.json index 19d125a5..fc9a4b4a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "glass", "description": "DHIS2 Glass App", - "version": "1.6.21", + "version": "1.6.23", "license": "GPL-3.0", "author": "EyeSeeTea team", "homepage": ".", @@ -117,6 +117,8 @@ "script-example": "npx ts-node src/scripts/index.ts example getExample", "start-amc-recalculate": "npx ts-node src/scripts/cliAMC.ts --debug", "build-amc-recalculate": "ncc build src/scripts/cliAMC.ts -m -o $npm_package_name-amc-recalculate-server && zip -r $npm_package_name-amc-recalculate-server.zip $npm_package_name-amc-recalculate-server && npx rimraf $npm_package_name-amc-recalculate-server/", + "start-async-deletions": "npx ts-node -r dotenv/config src/scripts/cliAsyncDeletions.ts", + "build-async-deletions": "ncc build src/scripts/cliAsyncDeletions.ts -m -o $npm_package_name-async-deletions-server && zip -r $npm_package_name-async-deletions-server.zip $npm_package_name-async-deletions-server && npx rimraf $npm_package_name-async-deletions-server/", "amr-agg-data-validation-ris": "npx ts-node -r dotenv/config src/scripts/amr_agg_data_validation.ts", "amr-agg-data-reset-ris": "npx ts-node -r dotenv/config src/scripts/amr_agg_data_reset.ts", "amr-agg-data-validation-sample": "npx ts-node -r dotenv/config src/scripts/amr_agg_data_validation_sample.ts", diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index fd56fc50..a3b318f9 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -93,6 +93,12 @@ import { DownloadEmptyTemplateUseCase } from "./domain/usecases/DownloadEmptyTem import { DownloadPopulatedTemplateUseCase } from "./domain/usecases/DownloadPopulatedTemplateUseCase"; import { CountryDefaultRepository } from "./data/repositories/CountryDefaultRepository"; import { GetAllCountriesUseCase } from "./domain/usecases/GetAllCountriesUseCase"; +import { SetToAsyncDeletionsUseCase } from "./domain/usecases/SetToAsyncDeletionsUseCase"; +import { GetAsyncDeletionsUseCase } from "./domain/usecases/GetAsyncDeletionsUseCase"; +import { DeletePrimaryFileDataUseCase } from "./domain/usecases/data-entry/DeletePrimaryFileDataUseCase"; +import { DeleteSecondaryFileDataUseCase } from "./domain/usecases/data-entry/DeleteSecondaryFileDataUseCase"; +import { DownloadDocumentAsArrayBufferUseCase } from "./domain/usecases/DownloadDocumentAsArrayBufferUseCase"; +import { GetGlassUploadByIdUseCase } from "./domain/usecases/GetGlassUploadByIdUseCase"; export function getCompositionRoot(instance: Instance) { const api = getD2APiFromInstance(instance); @@ -156,6 +162,7 @@ export function getCompositionRoot(instance: Instance) { }), glassUploads: getExecute({ getAll: new GetGlassUploadsUseCase(glassUploadsRepository), + getById: new GetGlassUploadByIdUseCase(glassUploadsRepository), setStatus: new SetUploadStatusUseCase(glassUploadsRepository), getAMRUploadsForCurrentDataSubmission: new GetGlassUploadsByDataSubmissionUseCase( glassUploadsRepository, @@ -166,12 +173,15 @@ export function getCompositionRoot(instance: Instance) { setBatchId: new SetUploadBatchIdUseCase(glassUploadsRepository), saveImportSummaryErrorsOfFiles: new SaveImportSummaryErrorsOfFilesInUploadsUseCase(glassUploadsRepository), getCurrentDataSubmissionFileType: new GetUploadsByDataSubmissionUseCase(glassUploadsRepository), + setToAsyncDeletions: new SetToAsyncDeletionsUseCase(glassUploadsRepository), + getAsyncDeletions: new GetAsyncDeletionsUseCase(glassUploadsRepository), }), glassDocuments: getExecute({ getAll: new GetGlassDocumentsUseCase(glassDocumentsRepository), upload: new UploadDocumentUseCase(glassDocumentsRepository, glassUploadsRepository), deleteByUploadId: new DeleteDocumentInfoByUploadIdUseCase(glassDocumentsRepository, glassUploadsRepository), download: new DownloadDocumentUseCase(glassDocumentsRepository), + downloadAsArrayBuffer: new DownloadDocumentAsArrayBufferUseCase(glassDocumentsRepository), updateSecondaryFileWithPrimaryId: new UpdateSampleUploadWithRisIdUseCase(glassUploadsRepository), }), fileSubmission: getExecute({ @@ -180,7 +190,6 @@ export function getCompositionRoot(instance: Instance) { risIndividualFungalRepository, metadataRepository, dataValuesRepository, - glassModuleRepository, dhis2EventsDefaultRepository, excelRepository, glassDocumentsRepository, @@ -231,6 +240,29 @@ export function getCompositionRoot(instance: Instance) { egaspProgramRepository, metadataRepository ), + deletePrimaryFile: new DeletePrimaryFileDataUseCase({ + risDataRepository, + metadataRepository, + dataValuesRepository, + dhis2EventsDefaultRepository, + excelRepository, + glassDocumentsRepository, + instanceRepository, + glassUploadsRepository, + trackerRepository, + amcSubstanceDataRepository, + }), + deleteSecondaryFile: new DeleteSecondaryFileDataUseCase({ + sampleDataRepository, + dataValuesRepository, + dhis2EventsDefaultRepository, + excelRepository, + glassDocumentsRepository, + metadataRepository, + instanceRepository, + glassUploadsRepository, + trackerRepository, + }), }), questionnaires: getExecute({ get: new GetQuestionnaireUseCase(questionnaireD2Repository), diff --git a/src/data/data-store/DataStoreKeys.ts b/src/data/data-store/DataStoreKeys.ts index 8af96bec..a9353d8d 100644 --- a/src/data/data-store/DataStoreKeys.ts +++ b/src/data/data-store/DataStoreKeys.ts @@ -8,4 +8,5 @@ export const DataStoreKeys = { SIGNALS: "signals", ATC_CLASSIFICATION: "ATCs", AMC_RECALCULATION: "amc-recalculation", + ASYNC_DELETIONS: "async-deletions", }; diff --git a/src/data/repositories/ExcelPopulateDefaultRepository.ts b/src/data/repositories/ExcelPopulateDefaultRepository.ts index 1652c740..f72e536f 100644 --- a/src/data/repositories/ExcelPopulateDefaultRepository.ts +++ b/src/data/repositories/ExcelPopulateDefaultRepository.ts @@ -1,4 +1,4 @@ -import i18n from "@eyeseetea/d2-ui-components/locales"; +import _ from "lodash"; import { Sheet } from "../../domain/entities/Sheet"; import { ExcelRepository, ExcelValue, ReadCellOptions } from "../../domain/repositories/ExcelRepository"; import XLSX, { @@ -19,6 +19,7 @@ import { AMC_SUBSTANCE_CALCULATED_CONSUMPTION_PROGRAM_ID, } from "../../domain/usecases/data-entry/amc/ImportAMCSubstanceLevelData"; import { removeCharacters } from "./utils/string"; +import i18n from "../../locales"; type RowWithCells = XLSX.Row & { _cells: XLSX.Cell[] }; @@ -47,6 +48,15 @@ export class ExcelPopulateDefaultRepository extends ExcelRepository { }); } + public loadTemplateFromArrayBuffer(buffer: ArrayBuffer, programId: Id): FutureData { + const templateId = getTemplateId(programId); + return Future.fromPromise(this.parseFromArrayBuffer(buffer)).map(workbook => { + const id = templateId; + this.workbooks[id] = workbook; + return id; + }); + } + public async toBlob(id: string): Promise { const data = await this.toBuffer(id); return new Blob([data], { @@ -62,6 +72,11 @@ export class ExcelPopulateDefaultRepository extends ExcelRepository { private async parseFile(file: Blob): Promise { return XLSX.fromDataAsync(file); } + + private async parseFromArrayBuffer(buffer: ArrayBuffer): Promise { + return XLSX.fromDataAsync(buffer); + } + public async findRelativeCell(id: string, location?: SheetRef, cellRef?: CellRef): Promise { const workbook = await this.getWorkbook(id); diff --git a/src/data/repositories/GlassUploadsDefaultRepository.ts b/src/data/repositories/GlassUploadsDefaultRepository.ts index 44174ef7..224dacd8 100644 --- a/src/data/repositories/GlassUploadsDefaultRepository.ts +++ b/src/data/repositories/GlassUploadsDefaultRepository.ts @@ -15,6 +15,17 @@ export class GlassUploadsDefaultRepository implements GlassUploadsRepository { return this.dataStoreClient.listCollection(DataStoreKeys.UPLOADS); } + getById(uploadId: Id): FutureData { + return this.dataStoreClient.listCollection(DataStoreKeys.UPLOADS).flatMap(uploads => { + const upload = uploads?.find(upload => upload.id === uploadId); + if (upload) { + return Future.success(upload); + } else { + return Future.error("Upload does not exist"); + } + }); + } + save(upload: GlassUploads): FutureData { return this.dataStoreClient.listCollection(DataStoreKeys.UPLOADS).flatMap(uploads => { const newUploads = [...uploads, upload]; @@ -46,6 +57,36 @@ export class GlassUploadsDefaultRepository implements GlassUploadsRepository { }); } + setEventListDataDeleted(uploadId: string): FutureData { + return this.dataStoreClient.listCollection(DataStoreKeys.UPLOADS).flatMap(uploads => { + const upload = uploads.find(el => el.id === uploadId); + if (upload) { + const restUploads = uploads.filter(upload => upload.id !== uploadId); + return this.dataStoreClient.saveObject(DataStoreKeys.UPLOADS, [ + ...restUploads, + { ...upload, eventListDataDeleted: true }, + ]); + } else { + return Future.error("Upload not found"); + } + }); + } + + setCalculatedEventListDataDeleted(uploadId: string): FutureData { + return this.dataStoreClient.listCollection(DataStoreKeys.UPLOADS).flatMap(uploads => { + const upload = uploads.find(el => el.id === uploadId); + if (upload) { + const restUploads = uploads.filter(upload => upload.id !== uploadId); + return this.dataStoreClient.saveObject(DataStoreKeys.UPLOADS, [ + ...restUploads, + { ...upload, calculatedEventListDataDeleted: true }, + ]); + } else { + return Future.error("Upload not found"); + } + }); + } + delete(id: string): FutureData<{ fileId: string; eventListFileId: string | undefined; @@ -180,4 +221,28 @@ export class GlassUploadsDefaultRepository implements GlassUploadsRepository { } }); } + + setAsyncDeletions(uploadIdsToDelete: Id[]): FutureData { + return this.dataStoreClient.listCollection(DataStoreKeys.ASYNC_DELETIONS).flatMap(asyncDeletionsArray => { + const newAsyncDeletions = [...asyncDeletionsArray, ...uploadIdsToDelete]; + return this.dataStoreClient.saveObject(DataStoreKeys.ASYNC_DELETIONS, newAsyncDeletions).flatMap(() => { + return Future.success(uploadIdsToDelete); + }); + }); + } + + getAsyncDeletions(): FutureData { + return this.dataStoreClient.listCollection(DataStoreKeys.ASYNC_DELETIONS); + } + + removeAsyncDeletions(uploadIdToRemove: Id[]): FutureData { + return this.dataStoreClient.listCollection(DataStoreKeys.ASYNC_DELETIONS).flatMap(asyncDeletionsArray => { + const restAsyncDeletions = asyncDeletionsArray.filter( + uploadIdToBeDeleted => !uploadIdToRemove.includes(uploadIdToBeDeleted) + ); + return this.dataStoreClient.saveObject(DataStoreKeys.ASYNC_DELETIONS, restAsyncDeletions).flatMap(() => { + return Future.success(uploadIdToRemove); + }); + }); + } } diff --git a/src/data/repositories/QuestionnaireD2DefaultRepository.ts b/src/data/repositories/QuestionnaireD2DefaultRepository.ts index 0fe129c2..b32dc322 100644 --- a/src/data/repositories/QuestionnaireD2DefaultRepository.ts +++ b/src/data/repositories/QuestionnaireD2DefaultRepository.ts @@ -179,8 +179,8 @@ export class QuestionnaireD2DefaultRepository implements QuestionnaireRepository } } - saveResponse(questionnaire: QuestionnaireSelector, question: Question): FutureData { - const dataValues = this.getDataValuesForQuestion(questionnaire, question); + saveResponse(questionnaire: QuestionnaireSelector, questions: Question[]): FutureData { + const dataValues = this.getDataValuesForQuestions(questionnaire, questions); return this.postDataValues(dataValues); } @@ -288,6 +288,10 @@ export class QuestionnaireD2DefaultRepository implements QuestionnaireRepository } } + private getDataValuesForQuestions(questionnaire: QuestionnaireSelector, questions: Question[]): D2DataValue[] { + return questions.flatMap(question => this.getDataValuesForQuestion(questionnaire, question)); + } + private getDataValuesForQuestion(questionnaire: QuestionnaireSelector, question: Question): D2DataValue[] { const { type } = question; const { orgUnitId, year } = questionnaire; diff --git a/src/data/repositories/SpreadsheetXlsxDefaultRepository.ts b/src/data/repositories/SpreadsheetXlsxDefaultRepository.ts index c5a6ae87..646e999b 100644 --- a/src/data/repositories/SpreadsheetXlsxDefaultRepository.ts +++ b/src/data/repositories/SpreadsheetXlsxDefaultRepository.ts @@ -37,4 +37,33 @@ export class SpreadsheetXlsxDataSource implements SpreadsheetDataSource { return { name: "", sheets: [] }; } } + + async readFromArrayBuffer(arrayBuffer: ArrayBuffer, fileName?: string): Async { + try { + const workbook = XLSX.read(arrayBuffer, { cellDates: true }); + + const sheets = _(workbook.Sheets) + .toPairs() + .map(([sheetName, worksheet]): Sheet => { + const headers = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: "" })[0] || []; + const rows = XLSX.utils.sheet_to_json>(worksheet, { raw: true, skipHidden: false }); + + return { + name: sheetName, + headers, + rows, + }; + }) + .value(); + + const spreadsheet: Spreadsheet = { + name: fileName || "", + sheets, + }; + + return spreadsheet; + } catch (e) { + return { name: "", sheets: [] }; + } + } } diff --git a/src/data/repositories/TrackerDefaultRepository.ts b/src/data/repositories/TrackerDefaultRepository.ts index 2623a4be..3361be6c 100644 --- a/src/data/repositories/TrackerDefaultRepository.ts +++ b/src/data/repositories/TrackerDefaultRepository.ts @@ -1,13 +1,16 @@ +import _ from "lodash"; import { getD2APiFromInstance } from "../../utils/d2-api"; import { Instance } from "../entities/Instance"; -import { FutureData } from "../../domain/entities/Future"; +import { Future, FutureData } from "../../domain/entities/Future"; import { D2Api } from "@eyeseetea/d2-api/2.34"; import { TrackerRepository } from "../../domain/repositories/TrackerRepository"; import { ImportStrategy } from "../../domain/entities/data-entry/DataValuesSaveSummary"; import { apiToFuture } from "../../utils/futures"; import { TrackerPostRequest, TrackerPostResponse } from "@eyeseetea/d2-api/api/tracker"; import { importApiTracker } from "./utils/importApiTracker"; +import { Id } from "../../domain/entities/Ref"; +const CHUNKED_SIZE = 100; export class TrackerDefaultRepository implements TrackerRepository { private api: D2Api; @@ -19,6 +22,49 @@ export class TrackerDefaultRepository implements TrackerRepository { return importApiTracker(this.api, req, action); } + getExistingTrackedEntitiesIdsByIds(trackEntitiesIds: Id[], programId: Id): FutureData { + const chunkedTrackEntitiesIds = _(trackEntitiesIds).chunk(CHUNKED_SIZE).value(); + return Future.sequential( + chunkedTrackEntitiesIds.flatMap(trackEntitiesIdsChunk => { + const trackEntitiesIdsString = trackEntitiesIdsChunk.join(";"); + return apiToFuture( + this.api.tracker.trackedEntities.get({ + trackedEntity: trackEntitiesIdsString, + fields: { + trackedEntity: true, + }, + program: programId, + enrollmentEnrolledBefore: new Date().toISOString(), + pageSize: CHUNKED_SIZE, + }) + ).map(response => { + return _.compact(response.instances.map(instance => instance.trackedEntity)); + }); + }) + ).flatMap(trackedEntitiesIds => Future.success(_.flatten(trackedEntitiesIds))); + } + + getExistingEventsIdsByIds(eventIds: Id[], programId: Id): FutureData { + const chunkedEventIds = _(eventIds).chunk(CHUNKED_SIZE).value(); + return Future.sequential( + chunkedEventIds.flatMap(eventIdsChunk => { + const eventIdsString = eventIdsChunk.join(";"); + return apiToFuture( + this.api.tracker.events.get({ + event: eventIdsString, + fields: { + event: true, + }, + program: programId, + pageSize: CHUNKED_SIZE, + }) + ).map(response => { + return response.instances.map(instance => instance.event); + }); + }) + ).flatMap(eventIds => Future.success(_.flatten(eventIds))); + } + public getProgramMetadata(programID: string, programStageId: string): FutureData { return apiToFuture( this.api.models.programs.get({ diff --git a/src/data/repositories/data-entry/AMCProductDataDefaultRepository.ts b/src/data/repositories/data-entry/AMCProductDataDefaultRepository.ts index cc36d9cc..3aa885c7 100644 --- a/src/data/repositories/data-entry/AMCProductDataDefaultRepository.ts +++ b/src/data/repositories/data-entry/AMCProductDataDefaultRepository.ts @@ -101,7 +101,7 @@ export class AMCProductDataDefaultRepository implements AMCProductDataRepository getProductRegisterAndRawProductConsumptionByProductIds( orgUnitId: Id, - productIds: string[], + productIds: Id[], period: string, productIdsChunkSize: number, chunked?: boolean @@ -156,7 +156,6 @@ export class AMCProductDataDefaultRepository implements AMCProductDataRepository return Future.sequential( chunkedProductIds.flatMap(productIdsChunk => { const productIdsString = productIdsChunk.join(";"); - const filter = `${AMR_GLASS_AMC_TEA_PRODUCT_ID}:IN:${productIdsString}`; // TODO: change pageSize to skipPaging:true when new version of d2-api return apiToFuture( @@ -165,7 +164,7 @@ export class AMCProductDataDefaultRepository implements AMCProductDataRepository program: AMC_PRODUCT_REGISTER_PROGRAM_ID, programStage: AMC_RAW_PRODUCT_CONSUMPTION_STAGE_ID, orgUnit: orgUnit, - filter: filter, + trackedEntity: productIdsString, enrollmentEnrolledAfter: enrollmentEnrolledAfter, enrollmentEnrolledBefore: enrollmentEnrolledBefore, pageSize: productIdsChunk.length, @@ -189,14 +188,13 @@ export class AMCProductDataDefaultRepository implements AMCProductDataRepository let page = 1; let result; const productIdsString = productIds.join(";"); - const filter = `${AMR_GLASS_AMC_TEA_PRODUCT_ID}:IN:${productIdsString}`; const enrollmentEnrolledAfter = `${period}-1-1`; const enrollmentEnrolledBefore = `${period}-12-31`; do { result = await this.getTrackedEntitiesOfPage({ orgUnit, - filter, + trackedEntity: productIdsString, page, pageSize, enrollmentEnrolledBefore, @@ -249,7 +247,7 @@ export class AMCProductDataDefaultRepository implements AMCProductDataRepository orgUnit: Id; page: number; pageSize: number; - filter?: string; + trackedEntity?: string; totalPages?: boolean; enrollmentEnrolledAfter?: string; enrollmentEnrolledBefore?: string; diff --git a/src/data/repositories/data-entry/RISDataCSVDefaultRepository.ts b/src/data/repositories/data-entry/RISDataCSVDefaultRepository.ts index 8a5f6dda..f537b300 100644 --- a/src/data/repositories/data-entry/RISDataCSVDefaultRepository.ts +++ b/src/data/repositories/data-entry/RISDataCSVDefaultRepository.ts @@ -1,6 +1,7 @@ import { RISData } from "../../../domain/entities/data-entry/amr-external/RISData"; import { Future, FutureData } from "../../../domain/entities/Future"; import { RISDataRepository } from "../../../domain/repositories/data-entry/RISDataRepository"; +import { Row } from "../../../domain/repositories/SpreadsheetXlsxRepository"; import { SpreadsheetXlsxDataSource } from "../SpreadsheetXlsxDefaultRepository"; import { doesColumnExist, getNumberValue, getTextValue } from "../utils/CSVUtils"; @@ -8,29 +9,37 @@ export class RISDataCSVDefaultRepository implements RISDataRepository { get(file: File): FutureData { return Future.fromPromise(new SpreadsheetXlsxDataSource().read(file)).map(spreadsheet => { const sheet = spreadsheet.sheets[0]; //Only one sheet for AMR RIS + return this.mapSheetRowsToRISData(sheet?.rows || []); + }); + } + + getFromArayBuffer(arrayBuffer: ArrayBuffer): FutureData { + return Future.fromPromise(new SpreadsheetXlsxDataSource().readFromArrayBuffer(arrayBuffer)).map(spreadsheet => { + const sheet = spreadsheet.sheets[0]; //Only one sheet for AMR RIS + return this.mapSheetRowsToRISData(sheet?.rows || []); + }); + } - return ( - sheet?.rows.map(row => { - return { - COUNTRY: getTextValue(row, "COUNTRY"), - YEAR: getNumberValue(row, "YEAR"), - SPECIMEN: getTextValue(row, "SPECIMEN"), - PATHOGEN: getTextValue(row, "PATHOGEN"), - GENDER: getTextValue(row, "GENDER"), - ORIGIN: getTextValue(row, "ORIGIN"), - AGEGROUP: getTextValue(row, "AGEGROUP"), - ANTIBIOTIC: getTextValue(row, "ANTIBIOTIC"), - RESISTANT: getNumberValue(row, "RESISTANT"), - INTERMEDIATE: getNumberValue(row, "INTERMEDIATE"), - NONSUSCEPTIBLE: getNumberValue(row, "NONSUSCEPTIBLE"), - SUSCEPTIBLE: getNumberValue(row, "SUSCEPTIBLE"), - UNKNOWN_NO_AST: getNumberValue(row, "UNKNOWN_NO_AST"), - UNKNOWN_NO_BREAKPOINTS: getNumberValue(row, "UNKNOWN_NO_BREAKPOINTS"), - BATCHIDDS: getTextValue(row, "BATCHID"), - ABCLASS: this.validateABCLASS(getTextValue(row, "ABCLASS")), - }; - }) || [] - ); + private mapSheetRowsToRISData(rows: Row[]): RISData[] { + return rows.map(row => { + return { + COUNTRY: getTextValue(row, "COUNTRY"), + YEAR: getNumberValue(row, "YEAR"), + SPECIMEN: getTextValue(row, "SPECIMEN"), + PATHOGEN: getTextValue(row, "PATHOGEN"), + GENDER: getTextValue(row, "GENDER"), + ORIGIN: getTextValue(row, "ORIGIN"), + AGEGROUP: getTextValue(row, "AGEGROUP"), + ANTIBIOTIC: getTextValue(row, "ANTIBIOTIC"), + RESISTANT: getNumberValue(row, "RESISTANT"), + INTERMEDIATE: getNumberValue(row, "INTERMEDIATE"), + NONSUSCEPTIBLE: getNumberValue(row, "NONSUSCEPTIBLE"), + SUSCEPTIBLE: getNumberValue(row, "SUSCEPTIBLE"), + UNKNOWN_NO_AST: getNumberValue(row, "UNKNOWN_NO_AST"), + UNKNOWN_NO_BREAKPOINTS: getNumberValue(row, "UNKNOWN_NO_BREAKPOINTS"), + BATCHIDDS: getTextValue(row, "BATCHID"), + ABCLASS: this.validateABCLASS(getTextValue(row, "ABCLASS")), + }; }); } diff --git a/src/data/repositories/data-entry/SampleDataCSVDeafultRepository.ts b/src/data/repositories/data-entry/SampleDataCSVDeafultRepository.ts index fc98efda..7d0ba1e7 100644 --- a/src/data/repositories/data-entry/SampleDataCSVDeafultRepository.ts +++ b/src/data/repositories/data-entry/SampleDataCSVDeafultRepository.ts @@ -1,6 +1,7 @@ import { SampleData } from "../../../domain/entities/data-entry/amr-external/SampleData"; import { Future, FutureData } from "../../../domain/entities/Future"; import { SampleDataRepository } from "../../../domain/repositories/data-entry/SampleDataRepository"; +import { Row } from "../../../domain/repositories/SpreadsheetXlsxRepository"; import { SpreadsheetXlsxDataSource } from "../SpreadsheetXlsxDefaultRepository"; import { doesColumnExist, getNumberValue, getTextValue } from "../utils/CSVUtils"; @@ -8,22 +9,30 @@ export class SampleDataCSVDeafultRepository implements SampleDataRepository { get(file: File): FutureData { return Future.fromPromise(new SpreadsheetXlsxDataSource().read(file)).map(spreadsheet => { const sheet = spreadsheet.sheets[0]; //Only one sheet for AMR RIS + return this.mapSheetRowsToSampleData(sheet?.rows || []); + }); + } + + getFromArayBuffer(arrayBuffer: ArrayBuffer): FutureData { + return Future.fromPromise(new SpreadsheetXlsxDataSource().readFromArrayBuffer(arrayBuffer)).map(spreadsheet => { + const sheet = spreadsheet.sheets[0]; //Only one sheet for AMR RIS + return this.mapSheetRowsToSampleData(sheet?.rows || []); + }); + } - return ( - sheet?.rows.map(row => { - return { - COUNTRY: getTextValue(row, "COUNTRY"), - YEAR: getNumberValue(row, "YEAR"), - SPECIMEN: getTextValue(row, "SPECIMEN"), - GENDER: getTextValue(row, "GENDER"), - ORIGIN: getTextValue(row, "ORIGIN"), - AGEGROUP: getTextValue(row, "AGEGROUP"), - NUMINFECTED: getNumberValue(row, "NUMINFECTED"), - NUMSAMPLEDPATIENTS: getNumberValue(row, "NUMSAMPLEDPATIENTS"), - BATCHIDDS: getTextValue(row, "BATCHID"), - }; - }) || [] - ); + private mapSheetRowsToSampleData(rows: Row[]): SampleData[] { + return rows.map(row => { + return { + COUNTRY: getTextValue(row, "COUNTRY"), + YEAR: getNumberValue(row, "YEAR"), + SPECIMEN: getTextValue(row, "SPECIMEN"), + GENDER: getTextValue(row, "GENDER"), + ORIGIN: getTextValue(row, "ORIGIN"), + AGEGROUP: getTextValue(row, "AGEGROUP"), + NUMINFECTED: getNumberValue(row, "NUMINFECTED"), + NUMSAMPLEDPATIENTS: getNumberValue(row, "NUMSAMPLEDPATIENTS"), + BATCHIDDS: getTextValue(row, "BATCHID"), + }; }); } diff --git a/src/domain/entities/GlassUploads.ts b/src/domain/entities/GlassUploads.ts index 732a1da3..dc9d00f4 100644 --- a/src/domain/entities/GlassUploads.ts +++ b/src/domain/entities/GlassUploads.ts @@ -1,4 +1,5 @@ import { ImportSummaryErrors } from "./data-entry/ImportSummary"; +import { Id } from "./Ref"; export interface GlassUploads { id: string; @@ -15,11 +16,13 @@ export interface GlassUploads { uploadDate: string; dataSubmission: string; module: string; - orgUnit: string; + orgUnit: Id; records?: number; rows?: number; correspondingRisUploadId: string; eventListFileId?: string; calculatedEventListFileId?: string; importSummary?: ImportSummaryErrors; + eventListDataDeleted?: boolean; + calculatedEventListDataDeleted?: boolean; } diff --git a/src/domain/entities/Questionnaire.ts b/src/domain/entities/Questionnaire.ts index f0b42dde..739b5719 100644 --- a/src/domain/entities/Questionnaire.ts +++ b/src/domain/entities/Questionnaire.ts @@ -102,6 +102,7 @@ export interface QuestionOption extends NamedRef { export type QuestionnaireRule = | RuleToggleSectionsVisibility | RuleSectionValuesHigherThan + | RuleSectionValuesHigherThanOrEqualTo | RuleQuestionValueLessThanConst | RuleQuestionValueDoubleOfAnother; @@ -117,6 +118,12 @@ interface RuleSectionValuesHigherThan { errorMessage: string; } +interface RuleSectionValuesHigherThanOrEqualTo { + type: "sectionValuesHigherThanOrEqualTo"; + dataElementCodesLowerToHigher: Dictionary; + errorMessage: string; +} + interface RuleQuestionValueLessThanConst { type: "questionValueLessThanConst"; dataElementCode: Code; @@ -156,7 +163,10 @@ export class QuestionnarieM { switch (ruleType) { case "setSectionsVisibility": { const toggleQuestion = questionsByCode[rule.dataElementCode]; - const areRuleSectionsVisible = Boolean(toggleQuestion?.value); + const areRuleSectionsVisible = + toggleQuestion?.type === "select" + ? toggleQuestion.value?.name === "Yes" + : Boolean(toggleQuestion?.value); return { ...questionnaireAcc, @@ -193,6 +203,22 @@ export class QuestionnarieM { }), }; } + case "sectionValuesHigherThanOrEqualTo": { + return { + ...questionnaireAcc, + sections: questionnaireAcc.sections.map((section): QuestionnaireSection => { + return { + ...section, + questions: this.applyRuleSectionValuesHigherThan( + questionsByCode, + rule, + section.questions, + true + ), + }; + }), + }; + } case "questionValueLessThanConst": { return { ...questionnaireAcc, @@ -223,8 +249,9 @@ export class QuestionnarieM { private static applyRuleSectionValuesHigherThan( questionsByCode: Dictionary, - rule: RuleSectionValuesHigherThan, - questions: Question[] + rule: RuleSectionValuesHigherThan | RuleSectionValuesHigherThanOrEqualTo, + questions: Question[], + orEqualTo = false ): Question[] { return questions.map((question): Question => { const questionWithHigherValueCode = rule.dataElementCodesLowerToHigher[question.code]; @@ -236,6 +263,14 @@ export class QuestionnarieM { ...question, validationError: this.addValidationError(question, rule.errorMessage), }; + } else if ( + orEqualTo && + parseFloat(questionWithHigherValue?.value as string) <= parseFloat(question?.value as string) + ) { + return { + ...question, + validationError: this.addValidationError(question, rule.errorMessage), + }; } else { return { ...question, diff --git a/src/domain/entities/data-entry/amc/ProductRegistryAttributes.ts b/src/domain/entities/data-entry/amc/ProductRegistryAttributes.ts index 6847d4fb..605fd925 100644 --- a/src/domain/entities/data-entry/amc/ProductRegistryAttributes.ts +++ b/src/domain/entities/data-entry/amc/ProductRegistryAttributes.ts @@ -1,3 +1,4 @@ +import { Maybe } from "../../../../types/utils"; import { UnitCode, RouteOfAdministrationCode, SaltCode } from "../../GlassAtcVersionData"; export type ProductRegistryAttributes = { @@ -5,12 +6,12 @@ export type ProductRegistryAttributes = { AMR_GLASS_AMC_TEA_PACKSIZE: number; AMR_GLASS_AMC_TEA_STRENGTH: number; AMR_GLASS_AMC_TEA_STRENGTH_UNIT: UnitCode; - AMR_GLASS_AMC_TEA_CONC_VOLUME: number; + AMR_GLASS_AMC_TEA_CONC_VOLUME: Maybe; AMR_GLASS_AMC_TEA_ATC: string; - AMR_GLASS_AMC_TEA_COMBINATION?: string; + AMR_GLASS_AMC_TEA_COMBINATION: Maybe; AMR_GLASS_AMC_TEA_ROUTE_ADMIN: RouteOfAdministrationCode; AMR_GLASS_AMC_TEA_SALT: SaltCode; - AMR_GLASS_AMC_TEA_VOLUME: number; + AMR_GLASS_AMC_TEA_VOLUME: Maybe; }; export const PRODUCT_REGISTRY_ATTRIBUTES_KEYS = [ diff --git a/src/domain/entities/data-entry/amc/RawProductConsumption.ts b/src/domain/entities/data-entry/amc/RawProductConsumption.ts index e4b506b5..d09c6858 100644 --- a/src/domain/entities/data-entry/amc/RawProductConsumption.ts +++ b/src/domain/entities/data-entry/amc/RawProductConsumption.ts @@ -1,6 +1,8 @@ +import { Maybe } from "../../../../types/utils"; + export type RawProductConsumption = { AMR_GLASS_AMC_TEA_PRODUCT_ID: string; - packages_manual: number; + packages_manual: Maybe; data_status_manual: number; health_sector_manual: string; health_level_manual: string; diff --git a/src/domain/entities/data-entry/amc/RawSubstanceConsumptionCalculated.ts b/src/domain/entities/data-entry/amc/RawSubstanceConsumptionCalculated.ts index cb5dda9e..57fe6730 100644 --- a/src/domain/entities/data-entry/amc/RawSubstanceConsumptionCalculated.ts +++ b/src/domain/entities/data-entry/amc/RawSubstanceConsumptionCalculated.ts @@ -37,7 +37,7 @@ export type DDDPerProductConsumptionPackages = { year: string; health_sector_manual: string; health_level_manual: string; - dddConsumptionPackages: number; + dddConsumptionPackages: Maybe; dddUnit: UnitCode; }; @@ -46,7 +46,7 @@ export type ContentTonnesPerProduct = { year: string; health_sector_manual: string; health_level_manual: string; - contentTonnes: number; + contentTonnes: Maybe; }; export type RawSubstanceConsumptionCalculated = { @@ -58,10 +58,10 @@ export type RawSubstanceConsumptionCalculated = { data_status_autocalculated: number; health_sector_autocalculated: string; health_level_autocalculated: string; - kilograms_autocalculated: number; - packages_autocalculated: number; + kilograms_autocalculated: Maybe; + packages_autocalculated: Maybe; atc_version_autocalculated: ATCVersionKey; - ddds_autocalculated: number; + ddds_autocalculated: Maybe; am_class: Maybe; atc2: Maybe; atc3: Maybe; diff --git a/src/domain/entities/data-entry/amc/RawSubstanceConsumptionData.ts b/src/domain/entities/data-entry/amc/RawSubstanceConsumptionData.ts index 73d97ff4..700eb441 100644 --- a/src/domain/entities/data-entry/amc/RawSubstanceConsumptionData.ts +++ b/src/domain/entities/data-entry/amc/RawSubstanceConsumptionData.ts @@ -1,3 +1,4 @@ +import { Maybe } from "../../../../types/utils"; import { ATCCodeLevel5, RouteOfAdministrationCode, SaltCode } from "../../GlassAtcVersionData"; import { Id } from "../../Ref"; @@ -9,8 +10,8 @@ export type RawSubstanceConsumptionData = { packages_manual: number; ddds_manual: number; atc_version_manual: string; - tons_manual: number; - data_status_manual: number; + tons_manual: Maybe; + data_status_manual: Maybe; health_sector_manual: string; health_level_manual: string; report_date: string; diff --git a/src/domain/entities/data-entry/amc/SubstanceConsumptionCalculated.ts b/src/domain/entities/data-entry/amc/SubstanceConsumptionCalculated.ts index 7e66fcae..7217f1ce 100644 --- a/src/domain/entities/data-entry/amc/SubstanceConsumptionCalculated.ts +++ b/src/domain/entities/data-entry/amc/SubstanceConsumptionCalculated.ts @@ -13,11 +13,11 @@ export type SubstanceConsumptionCalculated = { atc_autocalculated: ATCCodeLevel5; route_admin_autocalculated: RouteOfAdministrationCode; salt_autocalculated: SaltCode; - packages_autocalculated: number; - ddds_autocalculated: number; + packages_autocalculated: Maybe; + ddds_autocalculated: Maybe; atc_version_autocalculated: ATCVersionKey; - kilograms_autocalculated: number; - data_status_autocalculated: number; + kilograms_autocalculated: Maybe; + data_status_autocalculated: Maybe; health_sector_autocalculated: string; health_level_autocalculated: string; am_class: Maybe; diff --git a/src/domain/repositories/ExcelRepository.ts b/src/domain/repositories/ExcelRepository.ts index bd8aeafe..eaf48e1d 100644 --- a/src/domain/repositories/ExcelRepository.ts +++ b/src/domain/repositories/ExcelRepository.ts @@ -11,6 +11,7 @@ export interface ReadCellOptions { export abstract class ExcelRepository { public abstract loadTemplate(file: Blob, programId: Id): FutureData; + public abstract loadTemplateFromArrayBuffer(buffer: ArrayBuffer, programId: Id): FutureData; public abstract toBlob(id: string): Promise; public abstract findRelativeCell(id: string, location?: SheetRef, cell?: CellRef): Promise; public abstract writeCell(id: string, cellRef: CellRef, value: ExcelValue): Promise; diff --git a/src/domain/repositories/GlassUploadsRepository.ts b/src/domain/repositories/GlassUploadsRepository.ts index 1857eb05..2999feda 100644 --- a/src/domain/repositories/GlassUploadsRepository.ts +++ b/src/domain/repositories/GlassUploadsRepository.ts @@ -5,6 +5,7 @@ import { ImportSummaryErrors } from "../entities/data-entry/ImportSummary"; export interface GlassUploadsRepository { getAll(): FutureData; + getById(uploadId: Id): FutureData; save(upload: GlassUploads): FutureData; setStatus(id: string, status: string): FutureData; setBatchId(id: string, batchId: string): FutureData; @@ -26,4 +27,9 @@ export interface GlassUploadsRepository { getUploadsByDataSubmission(dataSubmissionId: Id): FutureData; getEventListFileIdByUploadId(id: string): FutureData; setCalculatedEventListFileId(uploadId: string, calculatedEventListFileId: string): FutureData; + setAsyncDeletions(uploadIdsToDelete: Id[]): FutureData; + getAsyncDeletions(): FutureData; + removeAsyncDeletions(uploadIdToRemove: Id[]): FutureData; + setEventListDataDeleted(id: string): FutureData; + setCalculatedEventListDataDeleted(id: string): FutureData; } diff --git a/src/domain/repositories/QuestionnaireRepository.ts b/src/domain/repositories/QuestionnaireRepository.ts index 95df2574..6ab31405 100644 --- a/src/domain/repositories/QuestionnaireRepository.ts +++ b/src/domain/repositories/QuestionnaireRepository.ts @@ -12,5 +12,5 @@ export interface QuestionnaireRepository { getProgramList(module: GlassModule, options: { orgUnitId: Id; year: string }): FutureData; get(module: GlassModule, selector: QuestionnaireSelector, captureAccess: boolean): FutureData; setCompletion(questionnaire: QuestionnaireSelector, value: boolean): FutureData; - saveResponse(questionnaire: QuestionnaireSelector, question: Question): FutureData; + saveResponse(questionnaire: QuestionnaireSelector, questions: Question[]): FutureData; } diff --git a/src/domain/repositories/SpreadsheetXlsxRepository.ts b/src/domain/repositories/SpreadsheetXlsxRepository.ts index 1c9a0a27..1d288a3d 100644 --- a/src/domain/repositories/SpreadsheetXlsxRepository.ts +++ b/src/domain/repositories/SpreadsheetXlsxRepository.ts @@ -1,6 +1,7 @@ export type Async = Promise; export interface SpreadsheetDataSource { read(inputFile: File): Async; + readFromArrayBuffer(arrayBuffer: ArrayBuffer, fileName?: string): Async; } export interface Spreadsheet { diff --git a/src/domain/repositories/TrackerRepository.ts b/src/domain/repositories/TrackerRepository.ts index 0989ded4..31ade111 100644 --- a/src/domain/repositories/TrackerRepository.ts +++ b/src/domain/repositories/TrackerRepository.ts @@ -1,8 +1,11 @@ import { TrackerPostRequest, TrackerPostResponse } from "@eyeseetea/d2-api/api/tracker"; import { FutureData } from "../entities/Future"; import { ImportStrategy } from "../entities/data-entry/DataValuesSaveSummary"; +import { Id } from "../entities/Ref"; export interface TrackerRepository { import(req: TrackerPostRequest, action: ImportStrategy): FutureData; getProgramMetadata(programID: string, programStageId: string): FutureData; + getExistingTrackedEntitiesIdsByIds(trackEntitiesIds: Id[], programId: Id): FutureData; + getExistingEventsIdsByIds(eventIds: Id[], programId: Id): FutureData; } diff --git a/src/domain/repositories/data-entry/AMCProductDataRepository.ts b/src/domain/repositories/data-entry/AMCProductDataRepository.ts index 148d9942..dd335489 100644 --- a/src/domain/repositories/data-entry/AMCProductDataRepository.ts +++ b/src/domain/repositories/data-entry/AMCProductDataRepository.ts @@ -23,7 +23,7 @@ export interface AMCProductDataRepository { getProductRegisterProgramMetadata(): FutureData; getProductRegisterAndRawProductConsumptionByProductIds( orgUnitId: Id, - productIds: string[], + productIds: Id[], period: string, productIdsChunkSize: number, chunked?: boolean diff --git a/src/domain/repositories/data-entry/RISDataRepository.ts b/src/domain/repositories/data-entry/RISDataRepository.ts index a9a82963..5f618bb0 100644 --- a/src/domain/repositories/data-entry/RISDataRepository.ts +++ b/src/domain/repositories/data-entry/RISDataRepository.ts @@ -4,4 +4,5 @@ import { FutureData } from "../../entities/Future"; export interface RISDataRepository { get(file: File): FutureData; validate(file: File): FutureData<{ isValid: boolean; specimens: string[]; rows: number }>; + getFromArayBuffer(arrayBuffer: ArrayBuffer): FutureData; } diff --git a/src/domain/repositories/data-entry/SampleDataRepository.ts b/src/domain/repositories/data-entry/SampleDataRepository.ts index 22a864c0..3633b656 100644 --- a/src/domain/repositories/data-entry/SampleDataRepository.ts +++ b/src/domain/repositories/data-entry/SampleDataRepository.ts @@ -4,4 +4,5 @@ import { FutureData } from "../../entities/Future"; export interface SampleDataRepository { get(file: File): FutureData; validate(file: File): FutureData<{ isValid: boolean; rows: number }>; + getFromArayBuffer(arrayBuffer: ArrayBuffer): FutureData; } diff --git a/src/domain/usecases/DeleteDocumentInfoByUploadIdUseCase.tsx b/src/domain/usecases/DeleteDocumentInfoByUploadIdUseCase.tsx index 1c9367a1..e6f4fbaf 100644 --- a/src/domain/usecases/DeleteDocumentInfoByUploadIdUseCase.tsx +++ b/src/domain/usecases/DeleteDocumentInfoByUploadIdUseCase.tsx @@ -6,11 +6,11 @@ import { GlassUploadsRepository } from "../repositories/GlassUploadsRepository"; export class DeleteDocumentInfoByUploadIdUseCase implements UseCase { constructor( private glassDocumentsRepository: GlassDocumentsRepository, - private GlassUploadsRepository: GlassUploadsRepository + private glassUploadsRepository: GlassUploadsRepository ) {} public execute(uploadId: string): FutureData { - return this.GlassUploadsRepository.delete(uploadId).flatMap(uploadFileData => { + return this.glassUploadsRepository.delete(uploadId).flatMap(uploadFileData => { return this.glassDocumentsRepository.delete(uploadFileData.fileId).flatMap(id => { return this.glassDocumentsRepository.deleteDocumentApi(id).flatMap(_data => { return Future.joinObj({ diff --git a/src/domain/usecases/DownloadDocumentAsArrayBufferUseCase.ts b/src/domain/usecases/DownloadDocumentAsArrayBufferUseCase.ts new file mode 100644 index 00000000..538b5ffb --- /dev/null +++ b/src/domain/usecases/DownloadDocumentAsArrayBufferUseCase.ts @@ -0,0 +1,16 @@ +import { UseCase } from "../../CompositionRoot"; +import { Future, FutureData } from "../entities/Future"; +import { GlassDocumentsRepository } from "../repositories/GlassDocumentsRepository"; + +export class DownloadDocumentAsArrayBufferUseCase implements UseCase { + constructor(private glassDocumentsRepository: GlassDocumentsRepository) {} + + public execute(id: string): FutureData { + if (id !== "") return this.glassDocumentsRepository.download(id).flatMap(blob => fromBlobToArrayBuffer(blob)); + else return Future.error("File id not found"); + } +} + +function fromBlobToArrayBuffer(blob: Blob): FutureData { + return Future.fromPromise(blob.arrayBuffer()); +} diff --git a/src/domain/usecases/GetAsyncDeletionsUseCase.tsx b/src/domain/usecases/GetAsyncDeletionsUseCase.tsx new file mode 100644 index 00000000..4757c261 --- /dev/null +++ b/src/domain/usecases/GetAsyncDeletionsUseCase.tsx @@ -0,0 +1,12 @@ +import { UseCase } from "../../CompositionRoot"; +import { FutureData } from "../entities/Future"; +import { Id } from "../entities/Ref"; +import { GlassUploadsRepository } from "../repositories/GlassUploadsRepository"; + +export class GetAsyncDeletionsUseCase implements UseCase { + constructor(private glassUploadsRepository: GlassUploadsRepository) {} + + public execute(): FutureData { + return this.glassUploadsRepository.getAsyncDeletions(); + } +} diff --git a/src/domain/usecases/GetGlassUploadByIdUseCase.ts b/src/domain/usecases/GetGlassUploadByIdUseCase.ts new file mode 100644 index 00000000..362527ee --- /dev/null +++ b/src/domain/usecases/GetGlassUploadByIdUseCase.ts @@ -0,0 +1,13 @@ +import { UseCase } from "../../CompositionRoot"; +import { FutureData } from "../entities/Future"; +import { GlassUploads } from "../entities/GlassUploads"; +import { Id } from "../entities/Ref"; +import { GlassUploadsRepository } from "../repositories/GlassUploadsRepository"; + +export class GetGlassUploadByIdUseCase implements UseCase { + constructor(private glassUploadsRepository: GlassUploadsRepository) {} + + public execute(uploadId: Id): FutureData { + return this.glassUploadsRepository.getById(uploadId); + } +} diff --git a/src/domain/usecases/RemoveAsyncDeletionsUseCase.tsx b/src/domain/usecases/RemoveAsyncDeletionsUseCase.tsx new file mode 100644 index 00000000..360a6e05 --- /dev/null +++ b/src/domain/usecases/RemoveAsyncDeletionsUseCase.tsx @@ -0,0 +1,12 @@ +import { UseCase } from "../../CompositionRoot"; +import { FutureData } from "../entities/Future"; +import { Id } from "../entities/Ref"; +import { GlassUploadsRepository } from "../repositories/GlassUploadsRepository"; + +export class RemoveAsyncDeletionsUseCase implements UseCase { + constructor(private glassUploadsRepository: GlassUploadsRepository) {} + + public execute(uploadIdsToRemove: Id[]): FutureData { + return this.glassUploadsRepository.removeAsyncDeletions(uploadIdsToRemove); + } +} diff --git a/src/domain/usecases/SaveQuestionUseCase.ts b/src/domain/usecases/SaveQuestionUseCase.ts index 4b8e04db..af93b857 100644 --- a/src/domain/usecases/SaveQuestionUseCase.ts +++ b/src/domain/usecases/SaveQuestionUseCase.ts @@ -2,9 +2,9 @@ import { Question, QuestionnaireSelector } from "../entities/Questionnaire"; import { QuestionnaireRepository } from "../repositories/QuestionnaireRepository"; export class SaveQuestionnaireResponseUseCase { - constructor(private questionnaireReposotory: QuestionnaireRepository) {} + constructor(private questionnaireRepository: QuestionnaireRepository) {} - execute(questionnaire: QuestionnaireSelector, question: Question) { - return this.questionnaireReposotory.saveResponse(questionnaire, question); + execute(questionnaire: QuestionnaireSelector, questions: Question[]) { + return this.questionnaireRepository.saveResponse(questionnaire, questions); } } diff --git a/src/domain/usecases/SendNotificationsUseCase.ts b/src/domain/usecases/SendNotificationsUseCase.ts index 655db4be..e88b21ec 100644 --- a/src/domain/usecases/SendNotificationsUseCase.ts +++ b/src/domain/usecases/SendNotificationsUseCase.ts @@ -1,19 +1,14 @@ import { UseCase } from "../../CompositionRoot"; -import { UsersDefaultRepository } from "../../data/repositories/UsersDefaultRepository"; import { FutureData } from "../entities/Future"; import { NotificationRepository } from "../repositories/NotificationRepository"; +import { UsersRepository } from "../repositories/UsersRepository"; export class SendNotificationsUseCase implements UseCase { - constructor( - private notificationRepository: NotificationRepository, - private usersDefaultRepository: UsersDefaultRepository - ) {} + constructor(private notificationRepository: NotificationRepository, private usersRepository: UsersRepository) {} public execute(subject: string, message: string, usergroupIds: string[], orgUnitPath: string): FutureData { - return this.usersDefaultRepository - .getUsersFilteredbyOUsAndUserGroups(orgUnitPath, usergroupIds) - .flatMap(users => { - return this.notificationRepository.send(subject, message, users); - }); + return this.usersRepository.getUsersFilteredbyOUsAndUserGroups(orgUnitPath, usergroupIds).flatMap(users => { + return this.notificationRepository.send(subject, message, users); + }); } } diff --git a/src/domain/usecases/SetToAsyncDeletionsUseCase.tsx b/src/domain/usecases/SetToAsyncDeletionsUseCase.tsx new file mode 100644 index 00000000..de762be2 --- /dev/null +++ b/src/domain/usecases/SetToAsyncDeletionsUseCase.tsx @@ -0,0 +1,12 @@ +import { UseCase } from "../../CompositionRoot"; +import { FutureData } from "../entities/Future"; +import { Id } from "../entities/Ref"; +import { GlassUploadsRepository } from "../repositories/GlassUploadsRepository"; + +export class SetToAsyncDeletionsUseCase implements UseCase { + constructor(private glassUploadsRepository: GlassUploadsRepository) {} + + public execute(uploadIdsToDelete: Id[]): FutureData { + return this.glassUploadsRepository.setAsyncDeletions(uploadIdsToDelete); + } +} diff --git a/src/domain/usecases/data-entry/DeleteBLTemplateEventProgram.ts b/src/domain/usecases/data-entry/DeleteBLTemplateEventProgram.ts new file mode 100644 index 00000000..2dcdd5d6 --- /dev/null +++ b/src/domain/usecases/data-entry/DeleteBLTemplateEventProgram.ts @@ -0,0 +1,213 @@ +import { D2TrackerEvent } from "@eyeseetea/d2-api/api/trackerEvents"; +import _ from "lodash"; + +import { Dhis2EventsDefaultRepository } from "../../../data/repositories/Dhis2EventsDefaultRepository"; +import { ImportSummary } from "../../entities/data-entry/ImportSummary"; +import { Future, FutureData } from "../../entities/Future"; +import { ExcelRepository } from "../../repositories/ExcelRepository"; +import { GlassDocumentsRepository } from "../../repositories/GlassDocumentsRepository"; +import { MetadataRepository } from "../../repositories/MetadataRepository"; +import * as templates from "../../entities/data-entry/program-templates"; +import { InstanceRepository } from "../../repositories/InstanceRepository"; +import { getStringFromFileBlob } from "./utils/fileToString"; +import { mapToImportSummary, readTemplate } from "./ImportBLTemplateEventProgram"; +import { GlassUploads } from "../../entities/GlassUploads"; +import { GlassUploadsRepository } from "../../repositories/GlassUploadsRepository"; +import { Id } from "../../entities/Ref"; +import { TrackerRepository } from "../../repositories/TrackerRepository"; + +// NOTICE: code adapted for node environment from ImportBLTemplateEventProgram.ts (only DELETE) +export class DeleteBLTemplateEventProgram { + constructor( + private excelRepository: ExcelRepository, + private instanceRepository: InstanceRepository, + private glassDocumentsRepository: GlassDocumentsRepository, + private dhis2EventsDefaultRepository: Dhis2EventsDefaultRepository, + private metadataRepository: MetadataRepository, + private glassUploadsRepository: GlassUploadsRepository, + private trackerRepository: TrackerRepository + ) {} + + public delete( + arrayBuffer: ArrayBuffer, + programId: string, + upload: GlassUploads, + calculatedProgramId?: Id + ): FutureData { + return this.excelRepository.loadTemplateFromArrayBuffer(arrayBuffer, programId).flatMap(_templateId => { + const template = _.values(templates) + .map(TemplateClass => new TemplateClass()) + .filter(t => t.id === "PROGRAM_GENERATED_v4")[0]; + return this.instanceRepository.getProgram(programId).flatMap(program => { + if (template) { + return readTemplate( + template, + program, + this.excelRepository, + this.instanceRepository, + programId + ).flatMap(dataPackage => { + if (dataPackage) { + return this.buildEventsPayload(upload, programId, calculatedProgramId).flatMap( + ({ events, calculatedEvents }) => { + return this.deleteEvents(upload.id, events).flatMap(importSummary => { + if (importSummary.status === "SUCCESS") { + return this.deleteCalculatedEvents(upload.id, calculatedEvents).flatMap( + importSummaryCalculatedEvents => { + return Future.success({ + ...importSummaryCalculatedEvents, + importCount: { + imported: + importSummary.importCount.imported + + importSummaryCalculatedEvents.importCount.imported, + updated: + importSummary.importCount.updated + + importSummaryCalculatedEvents.importCount.updated, + ignored: + importSummary.importCount.ignored + + importSummaryCalculatedEvents.importCount.ignored, + deleted: + importSummary.importCount.deleted + + importSummaryCalculatedEvents.importCount.deleted, + }, + nonBlockingErrors: [ + ...importSummary.nonBlockingErrors, + ...importSummaryCalculatedEvents.nonBlockingErrors, + ], + blockingErrors: [ + ...importSummary.blockingErrors, + ...importSummaryCalculatedEvents.blockingErrors, + ], + }); + } + ); + } else { + return Future.success(importSummary); + } + }); + } + ); + } else { + return Future.error("Unknown template"); + } + }); + } else { + return Future.error("Unknown template"); + } + }); + }); + } + + private deleteEvents(uploadId: Id, events: D2TrackerEvent[]): FutureData { + if (!events.length) { + const summary: ImportSummary = { + status: "SUCCESS", + importCount: { + ignored: 0, + imported: 0, + deleted: 0, + updated: 0, + }, + nonBlockingErrors: [], + blockingErrors: [], + }; + return this.glassUploadsRepository.setEventListDataDeleted(uploadId).flatMap(() => { + return Future.success(summary); + }); + } + + return this.dhis2EventsDefaultRepository.import({ events }, "DELETE").flatMap(result => { + return mapToImportSummary(result, "event", this.metadataRepository).flatMap(({ importSummary }) => { + if (importSummary.status === "SUCCESS") { + return this.glassUploadsRepository.setEventListDataDeleted(uploadId).flatMap(() => { + return Future.success(importSummary); + }); + } else { + return Future.success(importSummary); + } + }); + }); + } + + private deleteCalculatedEvents(uploadId: Id, calculatedEvents: D2TrackerEvent[]): FutureData { + if (!calculatedEvents.length) { + const summary: ImportSummary = { + status: "SUCCESS", + importCount: { + ignored: 0, + imported: 0, + deleted: 0, + updated: 0, + }, + nonBlockingErrors: [], + blockingErrors: [], + }; + return this.glassUploadsRepository.setCalculatedEventListDataDeleted(uploadId).flatMap(() => { + return Future.success(summary); + }); + } + + return this.dhis2EventsDefaultRepository.import({ events: calculatedEvents }, "DELETE").flatMap(result => { + return mapToImportSummary(result, "event", this.metadataRepository).flatMap(({ importSummary }) => { + if (importSummary.status === "SUCCESS") { + return this.glassUploadsRepository.setCalculatedEventListDataDeleted(uploadId).flatMap(() => { + return Future.success(importSummary); + }); + } else { + return Future.success(importSummary); + } + }); + }); + } + + private buildEventsPayload( + upload: GlassUploads, + programId: Id, + calculatedProgramId?: Id + ): FutureData<{ + events: D2TrackerEvent[]; + calculatedEvents: D2TrackerEvent[]; + }> { + const { eventListFileId, eventListDataDeleted, calculatedEventListFileId, calculatedEventListDataDeleted } = + upload; + return Future.joinObj({ + events: + eventListFileId && !eventListDataDeleted + ? this.getEventsFromListFileId(eventListFileId, programId) + : Future.success([]), + calculatedEvents: + calculatedEventListFileId && !calculatedEventListDataDeleted && calculatedProgramId + ? this.getEventsFromListFileId(calculatedEventListFileId, calculatedProgramId) + : Future.success([]), + }).flatMap(({ events, calculatedEvents }) => { + return Future.success({ + events: events, + calculatedEvents: calculatedEvents, + }); + }); + } + + private getEventsFromListFileId(listFileId: string, programId: Id): FutureData { + return this.glassDocumentsRepository.download(listFileId).flatMap(eventListFileBlob => { + return getStringFromFileBlob(eventListFileBlob).flatMap(_events => { + const eventIdList: Id[] = JSON.parse(_events); + return this.trackerRepository + .getExistingEventsIdsByIds(eventIdList, programId) + .flatMap(existingEventsIds => { + const events: D2TrackerEvent[] = existingEventsIds.map(eventId => { + return { + event: eventId, + program: "", + status: "COMPLETED", + orgUnit: "", + occurredAt: "", + attributeOptionCombo: "", + dataValues: [], + }; + }); + return Future.success(events); + }); + }); + }); + } +} diff --git a/src/domain/usecases/data-entry/DeletePrimaryFileDataUseCase.ts b/src/domain/usecases/data-entry/DeletePrimaryFileDataUseCase.ts new file mode 100644 index 00000000..e18139de --- /dev/null +++ b/src/domain/usecases/data-entry/DeletePrimaryFileDataUseCase.ts @@ -0,0 +1,67 @@ +import { Future, FutureData } from "../../entities/Future"; +import { MetadataRepository } from "../../repositories/MetadataRepository"; +import { DataValuesRepository } from "../../repositories/data-entry/DataValuesRepository"; +import { RISDataRepository } from "../../repositories/data-entry/RISDataRepository"; +import { ImportSummary } from "../../entities/data-entry/ImportSummary"; +import { Dhis2EventsDefaultRepository } from "../../../data/repositories/Dhis2EventsDefaultRepository"; +import { ExcelRepository } from "../../repositories/ExcelRepository"; +import { GlassDocumentsRepository } from "../../repositories/GlassDocumentsRepository"; +import { TrackerRepository } from "../../repositories/TrackerRepository"; +import { AMCSubstanceDataRepository } from "../../repositories/data-entry/AMCSubstanceDataRepository"; +import { GlassUploadsRepository } from "../../repositories/GlassUploadsRepository"; +import { InstanceRepository } from "../../repositories/InstanceRepository"; +import { UseCase } from "../../../CompositionRoot"; +import { GlassModule } from "../../entities/GlassModule"; +import { GlassUploads } from "../../entities/GlassUploads"; +import { DeleteAMCProductLevelData } from "./amc/DeleteAMCProductLevelData"; +import { DeleteRISIndividualFungalFile } from "./amr-individual-fungal/DeleteRISIndividualFungalFile"; +import { DeleteRISDataset } from "./amr/DeleteRISDataset"; +import { DeleteEGASPDataset } from "./egasp/DeleteEGASPDataset"; + +export class DeletePrimaryFileDataUseCase implements UseCase { + constructor( + private options: { + risDataRepository: RISDataRepository; + metadataRepository: MetadataRepository; + dataValuesRepository: DataValuesRepository; + dhis2EventsDefaultRepository: Dhis2EventsDefaultRepository; + excelRepository: ExcelRepository; + glassDocumentsRepository: GlassDocumentsRepository; + instanceRepository: InstanceRepository; + glassUploadsRepository: GlassUploadsRepository; + trackerRepository: TrackerRepository; + amcSubstanceDataRepository: AMCSubstanceDataRepository; + } + ) {} + + public execute( + currentModule: GlassModule, + upload: GlassUploads, + arrayBuffer: ArrayBuffer + ): FutureData { + const { name: currentModuleName } = currentModule; + switch (currentModuleName) { + case "AMR": { + return new DeleteRISDataset(this.options).delete(arrayBuffer); + } + + case "EGASP": { + return new DeleteEGASPDataset(this.options).delete(arrayBuffer, upload); + } + + case "AMR - Individual": + case "AMR - Fungal": { + const programId = currentModule.programs !== undefined ? currentModule.programs.at(0)?.id : undefined; + return new DeleteRISIndividualFungalFile(this.options).delete(upload, programId); + } + + case "AMC": { + return new DeleteAMCProductLevelData(this.options).delete(arrayBuffer, upload); + } + + default: { + return Future.error(`Primary upload async deletion for module ${currentModuleName} not found`); + } + } + } +} diff --git a/src/domain/usecases/data-entry/DeleteSecondaryFileDataUseCase.tsx b/src/domain/usecases/data-entry/DeleteSecondaryFileDataUseCase.tsx new file mode 100644 index 00000000..97b16881 --- /dev/null +++ b/src/domain/usecases/data-entry/DeleteSecondaryFileDataUseCase.tsx @@ -0,0 +1,53 @@ +import { UseCase } from "../../../CompositionRoot"; +import { Future, FutureData } from "../../entities/Future"; +import { MetadataRepository } from "../../repositories/MetadataRepository"; +import { DataValuesRepository } from "../../repositories/data-entry/DataValuesRepository"; +import { SampleDataRepository } from "../../repositories/data-entry/SampleDataRepository"; +import { ImportSummary } from "../../entities/data-entry/ImportSummary"; +import { ExcelRepository } from "../../repositories/ExcelRepository"; +import { GlassUploadsRepository } from "../../repositories/GlassUploadsRepository"; +import { Dhis2EventsDefaultRepository } from "../../../data/repositories/Dhis2EventsDefaultRepository"; +import { GlassDocumentsRepository } from "../../repositories/GlassDocumentsRepository"; +import { InstanceRepository } from "../../repositories/InstanceRepository"; +import { GlassModule } from "../../entities/GlassModule"; +import { GlassUploads } from "../../entities/GlassUploads"; +import { DeleteAMCSubstanceLevelData } from "./amc/DeleteAMCSubstanceLevelData"; +import { DeleteSampleDataset } from "./amr/DeleteSampleDataset"; +import { TrackerRepository } from "../../repositories/TrackerRepository"; + +export class DeleteSecondaryFileDataUseCase implements UseCase { + constructor( + private options: { + sampleDataRepository: SampleDataRepository; + dataValuesRepository: DataValuesRepository; + dhis2EventsDefaultRepository: Dhis2EventsDefaultRepository; + excelRepository: ExcelRepository; + glassDocumentsRepository: GlassDocumentsRepository; + metadataRepository: MetadataRepository; + instanceRepository: InstanceRepository; + glassUploadsRepository: GlassUploadsRepository; + trackerRepository: TrackerRepository; + } + ) {} + + public execute( + currentModule: GlassModule, + upload: GlassUploads, + arrayBuffer: ArrayBuffer + ): FutureData { + const { name: currentModuleName } = currentModule; + switch (currentModuleName) { + case "AMR": + case "AMR - Individual": { + return new DeleteSampleDataset(this.options).delete(arrayBuffer); + } + + case "AMC": { + return new DeleteAMCSubstanceLevelData(this.options).delete(arrayBuffer, upload); + } + default: { + return Future.error(`Secondary upload async deletion for module ${currentModuleName} not found`); + } + } + } +} diff --git a/src/domain/usecases/data-entry/ImportBLTemplateEventProgram.ts b/src/domain/usecases/data-entry/ImportBLTemplateEventProgram.ts index 04e98098..e201f752 100644 --- a/src/domain/usecases/data-entry/ImportBLTemplateEventProgram.ts +++ b/src/domain/usecases/data-entry/ImportBLTemplateEventProgram.ts @@ -1,3 +1,5 @@ +import _ from "lodash"; +import moment from "moment"; import { Dhis2EventsDefaultRepository } from "../../../data/repositories/Dhis2EventsDefaultRepository"; import { ImportStrategy } from "../../entities/data-entry/DataValuesSaveSummary"; import { ConsistencyError, ImportSummary } from "../../entities/data-entry/ImportSummary"; @@ -21,11 +23,10 @@ import { Template } from "../../entities/Template"; import { ExcelReader } from "../../utils/ExcelReader"; import { InstanceRepository } from "../../repositories/InstanceRepository"; import { AMC_RAW_SUBSTANCE_CONSUMPTION_PROGRAM_ID } from "./amc/ImportAMCSubstanceLevelData"; -import { GlassATCDefaultRepository } from "../../../data/repositories/GlassATCDefaultRepository"; +import { GlassATCRepository } from "../../repositories/GlassATCRepository"; import { ListGlassATCLastVersionKeysByYear } from "../../entities/GlassAtcVersionData"; -import moment from "moment"; -const ATC_VERSION_DATA_ELEMENT_ID = "aCuWz3HZ5Ti"; +export const ATC_VERSION_DATA_ELEMENT_ID = "aCuWz3HZ5Ti"; export class ImportBLTemplateEventProgram { constructor( @@ -36,7 +37,7 @@ export class ImportBLTemplateEventProgram { private dhis2EventsDefaultRepository: Dhis2EventsDefaultRepository, private metadataRepository: MetadataRepository, private programRulesMetadataRepository: ProgramRulesMetadataRepository, - private glassAtcRepository: GlassATCDefaultRepository + private glassAtcRepository: GlassATCRepository ) {} public import( @@ -73,6 +74,9 @@ export class ImportBLTemplateEventProgram { calculatedEventListFileId ).flatMap(events => { if (action === "CREATE_AND_UPDATE") { + if (!events.length) + return Future.error("The file is empty or failed while reading the file."); + //Run validations on import only return this.validateEvents( events, @@ -128,6 +132,7 @@ export class ImportBLTemplateEventProgram { }); } //action === "DELETE" else { + // NOTICE: check also DeleteBLTemplateEventProgram.ts that contains same code adapted for node environment (only DELETE) return this.deleteEvents(events); } }); diff --git a/src/domain/usecases/data-entry/ImportPrimaryFileUseCase.tsx b/src/domain/usecases/data-entry/ImportPrimaryFileUseCase.tsx index fd00c313..c6ed99be 100644 --- a/src/domain/usecases/data-entry/ImportPrimaryFileUseCase.tsx +++ b/src/domain/usecases/data-entry/ImportPrimaryFileUseCase.tsx @@ -10,18 +10,17 @@ import { ImportEGASPFile } from "./egasp/ImportEGASPFile"; import { Dhis2EventsDefaultRepository } from "../../../data/repositories/Dhis2EventsDefaultRepository"; import { ExcelRepository } from "../../repositories/ExcelRepository"; import { GlassDocumentsRepository } from "../../repositories/GlassDocumentsRepository"; -import { GlassUploadsDefaultRepository } from "../../../data/repositories/GlassUploadsDefaultRepository"; import { ProgramRulesMetadataRepository } from "../../repositories/program-rules/ProgramRulesMetadataRepository"; import { ImportRISIndividualFungalFile } from "./amr-individual-fungal/ImportRISIndividualFungalFile"; import { RISIndividualFungalDataRepository } from "../../repositories/data-entry/RISIndividualFungalDataRepository"; import { TrackerRepository } from "../../repositories/TrackerRepository"; -import { GlassModuleDefaultRepository } from "../../../data/repositories/GlassModuleDefaultRepository"; import { ImportAMCProductLevelData } from "./amc/ImportAMCProductLevelData"; -import { InstanceDefaultRepository } from "../../../data/repositories/InstanceDefaultRepository"; -import { GlassATCDefaultRepository } from "../../../data/repositories/GlassATCDefaultRepository"; import { AMCProductDataRepository } from "../../repositories/data-entry/AMCProductDataRepository"; import { AMCSubstanceDataRepository } from "../../repositories/data-entry/AMCSubstanceDataRepository"; import { Country } from "../../entities/Country"; +import { GlassUploadsRepository } from "../../repositories/GlassUploadsRepository"; +import { InstanceRepository } from "../../repositories/InstanceRepository"; +import { GlassATCRepository } from "../../repositories/GlassATCRepository"; export class ImportPrimaryFileUseCase { constructor( @@ -29,19 +28,18 @@ export class ImportPrimaryFileUseCase { private risIndividualFungalRepository: RISIndividualFungalDataRepository, private metadataRepository: MetadataRepository, private dataValuesRepository: DataValuesRepository, - private moduleRepository: GlassModuleRepository, private dhis2EventsDefaultRepository: Dhis2EventsDefaultRepository, private excelRepository: ExcelRepository, private glassDocumentsRepository: GlassDocumentsRepository, - private glassUploadsRepository: GlassUploadsDefaultRepository, + private glassUploadsRepository: GlassUploadsRepository, private trackerRepository: TrackerRepository, - private glassModuleDefaultRepository: GlassModuleDefaultRepository, - private instanceRepository: InstanceDefaultRepository, + private glassModuleRepository: GlassModuleRepository, + private instanceRepository: InstanceRepository, private programRulesMetadataRepository: ProgramRulesMetadataRepository, - private atcRepository: GlassATCDefaultRepository, + private atcRepository: GlassATCRepository, private amcProductRepository: AMCProductDataRepository, private amcSubstanceDataRepository: AMCSubstanceDataRepository, - private glassAtcRepository: GlassATCDefaultRepository + private glassAtcRepository: GlassATCRepository ) {} public execute( @@ -64,7 +62,7 @@ export class ImportPrimaryFileUseCase { this.risDataRepository, this.metadataRepository, this.dataValuesRepository, - this.moduleRepository + this.glassModuleRepository ); return importRISFile.importRISFile(inputFile, batchId, period, action, orgUnitId, countryCode, dryRun); } @@ -102,7 +100,7 @@ export class ImportPrimaryFileUseCase { this.metadataRepository, this.programRulesMetadataRepository ); - return this.glassModuleDefaultRepository.getByName(moduleName).flatMap(module => { + return this.glassModuleRepository.getByName(moduleName).flatMap(module => { return importRISIndividualFungalFile.importRISIndividualFungalFile( inputFile, action, diff --git a/src/domain/usecases/data-entry/ImportSecondaryFileUseCase.tsx b/src/domain/usecases/data-entry/ImportSecondaryFileUseCase.tsx index c6a19d61..d1b2304b 100644 --- a/src/domain/usecases/data-entry/ImportSecondaryFileUseCase.tsx +++ b/src/domain/usecases/data-entry/ImportSecondaryFileUseCase.tsx @@ -8,12 +8,12 @@ import { ImportStrategy } from "../../entities/data-entry/DataValuesSaveSummary" import { ImportSampleFile } from "./amr/ImportSampleFile"; import { ImportAMCSubstanceLevelData } from "./amc/ImportAMCSubstanceLevelData"; import { ExcelRepository } from "../../repositories/ExcelRepository"; -import { InstanceDefaultRepository } from "../../../data/repositories/InstanceDefaultRepository"; -import { GlassDocumentsDefaultRepository } from "../../../data/repositories/GlassDocumentsDefaultRepository"; import { GlassUploadsRepository } from "../../repositories/GlassUploadsRepository"; import { Dhis2EventsDefaultRepository } from "../../../data/repositories/Dhis2EventsDefaultRepository"; import { ProgramRulesMetadataRepository } from "../../repositories/program-rules/ProgramRulesMetadataRepository"; -import { GlassATCDefaultRepository } from "../../../data/repositories/GlassATCDefaultRepository"; +import { GlassATCRepository } from "../../repositories/GlassATCRepository"; +import { GlassDocumentsRepository } from "../../repositories/GlassDocumentsRepository"; +import { InstanceRepository } from "../../repositories/InstanceRepository"; export class ImportSecondaryFileUseCase implements UseCase { constructor( @@ -21,12 +21,12 @@ export class ImportSecondaryFileUseCase implements UseCase { private metadataRepository: MetadataRepository, private dataValuesRepository: DataValuesRepository, private excelRepository: ExcelRepository, - private instanceRepository: InstanceDefaultRepository, - private glassDocumentsRepository: GlassDocumentsDefaultRepository, + private instanceRepository: InstanceRepository, + private glassDocumentsRepository: GlassDocumentsRepository, private glassUploadsRepository: GlassUploadsRepository, private dhis2EventsDefaultRepository: Dhis2EventsDefaultRepository, private programRulesMetadataRepository: ProgramRulesMetadataRepository, - private glassAtcRepository: GlassATCDefaultRepository + private glassAtcRepository: GlassATCRepository ) {} public execute( diff --git a/src/domain/usecases/data-entry/amc/CalculateConsumptionDataProductLevelUseCase.ts b/src/domain/usecases/data-entry/amc/CalculateConsumptionDataProductLevelUseCase.ts index 505c6bcb..98c4e5bf 100644 --- a/src/domain/usecases/data-entry/amc/CalculateConsumptionDataProductLevelUseCase.ts +++ b/src/domain/usecases/data-entry/amc/CalculateConsumptionDataProductLevelUseCase.ts @@ -5,17 +5,12 @@ import { COMB_CODE_PRODUCT_NOT_HAVE_ATC, createAtcVersionKey, } from "../../../entities/GlassAtcVersionData"; -import { mapToImportSummary, readTemplate } from "../ImportBLTemplateEventProgram"; +import { mapToImportSummary } from "../ImportBLTemplateEventProgram"; import { ExcelRepository } from "../../../repositories/ExcelRepository"; import { GlassATCRepository } from "../../../repositories/GlassATCRepository"; import { InstanceRepository } from "../../../repositories/InstanceRepository"; import { AMCProductDataRepository } from "../../../repositories/data-entry/AMCProductDataRepository"; -import { - AMC_PRODUCT_REGISTER_PROGRAM_ID, - AMR_GLASS_AMC_TEA_PRODUCT_ID, - AMC_RAW_SUBSTANCE_CONSUMPTION_CALCULATED_STAGE_ID, -} from "../../../../data/repositories/data-entry/AMCProductDataDefaultRepository"; -import * as templates from "../../../entities/data-entry/program-templates"; +import { AMC_RAW_SUBSTANCE_CONSUMPTION_CALCULATED_STAGE_ID } from "../../../../data/repositories/data-entry/AMCProductDataDefaultRepository"; import { MetadataRepository } from "../../../repositories/MetadataRepository"; import { ImportSummary } from "../../../entities/data-entry/ImportSummary"; import { getConsumptionDataProductLevel } from "./utils/getConsumptionDataProductLevel"; @@ -27,8 +22,8 @@ import { TrackerPostResponse } from "@eyeseetea/d2-api/api/tracker"; import { mapRawSubstanceCalculatedToSubstanceCalculated } from "./utils/mapRawSubstanceCalculatedToSubstanceCalculated"; import { GlassUploadsRepository } from "../../../repositories/GlassUploadsRepository"; import { GlassDocumentsRepository } from "../../../repositories/GlassDocumentsRepository"; +import { getStringFromFileBlob } from "../utils/fileToString"; -const TEMPLATE_ID = "TRACKER_PROGRAM_GENERATED_v3"; const IMPORT_SUMMARY_EVENT_TYPE = "event"; const IMPORT_STRATEGY_CREATE_AND_UPDATE = "CREATE_AND_UPDATE"; const AMR_GLASS_AMC_TEA_ATC = "aK1JpD14imM"; @@ -47,18 +42,17 @@ export class CalculateConsumptionDataProductLevelUseCase { private glassDocumentsRepository: GlassDocumentsRepository ) {} - public execute( - period: string, - orgUnitId: Id, - file: File, - moduleName: string, - uploadId: Id - ): FutureData { - return this.getProductIdsFromFile(file).flatMap(productIds => { + public execute(period: string, orgUnitId: Id, moduleName: string, uploadId: Id): FutureData { + return this.getIdsInListUpload(uploadId).flatMap(ids => { + if (!ids.length) { + logger.error(`[${new Date().toISOString()}] Products not found.`); + return Future.error("Products not found."); + } + logger.info( - `[${new Date().toISOString()}] Calculating raw substance consumption data for the following products (total: ${ - productIds.length - }): ${productIds.join(", ")}` + `[${new Date().toISOString()}] Calculating raw substance consumption data in org unit ${orgUnitId} and period ${period} for the following products (total: ${ + ids.length + }): ${ids.join(", ")}` ); return this.glassModuleRepository.getByName(moduleName).flatMap(module => { if (!module.chunkSizes?.productIds) { @@ -71,7 +65,7 @@ export class CalculateConsumptionDataProductLevelUseCase { productDataTrackedEntities: this.amcProductDataRepository.getProductRegisterAndRawProductConsumptionByProductIds( orgUnitId, - productIds, + ids, period, module.chunkSizes?.productIds, true @@ -216,45 +210,19 @@ export class CalculateConsumptionDataProductLevelUseCase { }); } - private getProductIdsFromFile(file: File): FutureData { - return this.excelRepository.loadTemplate(file, AMC_PRODUCT_REGISTER_PROGRAM_ID).flatMap(_templateId => { - const amcTemplate = _.values(templates) - .map(TemplateClass => new TemplateClass()) - .filter(t => t.id === TEMPLATE_ID)[0]; - return this.instanceRepository.getProgram(AMC_PRODUCT_REGISTER_PROGRAM_ID).flatMap(amcProgram => { - if (!amcTemplate) { - logger.error(`[${new Date().toISOString()}] Product level: cannot find template`); - return Future.error("Cannot find template"); - } - - return readTemplate( - amcTemplate, - amcProgram, - this.excelRepository, - this.instanceRepository, - AMC_PRODUCT_REGISTER_PROGRAM_ID - ).flatMap(amcProductData => { - if (!amcProductData) { - logger.error(`[${new Date().toISOString()}] Product level: cannot find data package`); - return Future.error("Cannot find data package"); - } - - if (amcProductData.type !== "trackerPrograms") { - logger.error(`[${new Date().toISOString()}] Product level: incorrect data package`); - return Future.error("Incorrect data package"); - } - - const productIds = amcProductData.trackedEntityInstances - .map(({ attributeValues }) => { - return attributeValues.find( - ({ attribute }) => attribute.id === AMR_GLASS_AMC_TEA_PRODUCT_ID - )?.value; - }) - .filter(Boolean) as string[]; - - return Future.success(productIds); + private getIdsInListUpload(uploadId: string): FutureData { + return this.glassUploadsRepository.getById(uploadId).flatMap(upload => { + if (!upload?.eventListFileId) { + logger.error(`[${new Date().toISOString()}] Cannot find upload with id ${uploadId}`); + return Future.error("Cannot find upload"); + } else { + return this.glassDocumentsRepository.download(upload.eventListFileId).flatMap(listFileFileBlob => { + return getStringFromFileBlob(listFileFileBlob).flatMap(idsList => { + const ids: Id[] = JSON.parse(idsList); + return Future.success(ids); + }); }); - }); + } }); } @@ -305,46 +273,48 @@ export class CalculateConsumptionDataProductLevelUseCase { undefined, importSubstancesResult.eventIdLineNoMap ).flatMap(importSubstancesSummary => { - return this.uploadIdListFileAndSave(uploadId, importSubstancesSummary, moduleName).flatMap( - importSubstancesSummaryImportSummary => { - return mapToImportSummary( - importProductResponse, - IMPORT_SUMMARY_EVENT_TYPE, - this.metadataRepository - ).flatMap(importProductSummary => { - return Future.success({ - ...importSubstancesSummaryImportSummary, - importCount: { - imported: - importProductSummary.importSummary.importCount.imported + - importSubstancesSummaryImportSummary.importCount.imported, - updated: - importProductSummary.importSummary.importCount.updated + - importSubstancesSummaryImportSummary.importCount.updated, - ignored: - importProductSummary.importSummary.importCount.ignored + - importSubstancesSummaryImportSummary.importCount.ignored, - deleted: - importProductSummary.importSummary.importCount.deleted + - importSubstancesSummaryImportSummary.importCount.deleted, - }, - nonBlockingErrors: [ - ...importProductSummary.importSummary.nonBlockingErrors, - ...importSubstancesSummaryImportSummary.nonBlockingErrors, - ], - blockingErrors: [ - ...importProductSummary.importSummary.blockingErrors, - ...importSubstancesSummaryImportSummary.blockingErrors, - ], - }); + return this.uploadCalculatedIdListFileAndSave( + uploadId, + importSubstancesSummary, + moduleName + ).flatMap(importSubstancesSummaryImportSummary => { + return mapToImportSummary( + importProductResponse, + IMPORT_SUMMARY_EVENT_TYPE, + this.metadataRepository + ).flatMap(importProductSummary => { + return Future.success({ + ...importSubstancesSummaryImportSummary, + importCount: { + imported: + importProductSummary.importSummary.importCount.imported + + importSubstancesSummaryImportSummary.importCount.imported, + updated: + importProductSummary.importSummary.importCount.updated + + importSubstancesSummaryImportSummary.importCount.updated, + ignored: + importProductSummary.importSummary.importCount.ignored + + importSubstancesSummaryImportSummary.importCount.ignored, + deleted: + importProductSummary.importSummary.importCount.deleted + + importSubstancesSummaryImportSummary.importCount.deleted, + }, + nonBlockingErrors: [ + ...importProductSummary.importSummary.nonBlockingErrors, + ...importSubstancesSummaryImportSummary.nonBlockingErrors, + ], + blockingErrors: [ + ...importProductSummary.importSummary.blockingErrors, + ...importSubstancesSummaryImportSummary.blockingErrors, + ], }); - } - ); + }); + }); }); }); } - private uploadIdListFileAndSave( + private uploadCalculatedIdListFileAndSave( uploadId: string, summary: { importSummary: ImportSummary; eventIdList: string[] }, moduleName: string diff --git a/src/domain/usecases/data-entry/amc/CalculateConsumptionDataSubstanceLevelUseCase.ts b/src/domain/usecases/data-entry/amc/CalculateConsumptionDataSubstanceLevelUseCase.ts index 7ac657f6..84c741b7 100644 --- a/src/domain/usecases/data-entry/amc/CalculateConsumptionDataSubstanceLevelUseCase.ts +++ b/src/domain/usecases/data-entry/amc/CalculateConsumptionDataSubstanceLevelUseCase.ts @@ -10,7 +10,7 @@ import { GlassUploadsRepository } from "../../../repositories/GlassUploadsReposi import { MetadataRepository } from "../../../repositories/MetadataRepository"; import { AMCSubstanceDataRepository } from "../../../repositories/data-entry/AMCSubstanceDataRepository"; import { mapToImportSummary } from "../ImportBLTemplateEventProgram"; -import { getStringFromFile } from "../utils/fileToString"; +import { getStringFromFileBlob } from "../utils/fileToString"; import { getConsumptionDataSubstanceLevel } from "./utils/getConsumptionDataSubstanceLevel"; const IMPORT_SUMMARY_EVENT_TYPE = "event"; @@ -27,9 +27,14 @@ export class CalculateConsumptionDataSubstanceLevelUseCase { ) {} public execute(uploadId: Id, period: string, orgUnitId: Id, moduleName: string): FutureData { - return this.getEventsIdsFromUploadId(uploadId).flatMap(substanceIds => { + return this.getIdsInEventListUpload(uploadId).flatMap(substanceIds => { + if (!substanceIds.length) { + logger.error(`[${new Date().toISOString()}] Substances not found.`); + return Future.error("Substances not found."); + } + logger.info( - `[${new Date().toISOString()}] Calculating consumption data in substance level for the following raw substance consumption data (total: ${ + `[${new Date().toISOString()}] Calculating consumption data in substance level in org unit ${orgUnitId} and period ${period} for the following raw substance consumption data (total: ${ substanceIds.length }): ${substanceIds.join(", ")}` ); @@ -133,7 +138,13 @@ export class CalculateConsumptionDataSubstanceLevelUseCase { this.metadataRepository, undefined, eventIdLineNoMap - ).flatMap(summary => this.uploadIdListFileAndSave(uploadId, summary, moduleName)); + ).flatMap(summary => + this.uploadCalculatedEventListFileIdAndSaveInUploads( + uploadId, + summary, + moduleName + ) + ); }); }); }); @@ -142,18 +153,23 @@ export class CalculateConsumptionDataSubstanceLevelUseCase { }); } - private getEventsIdsFromUploadId(uploadId: Id): FutureData { - return this.glassUploadsRepository.getEventListFileIdByUploadId(uploadId).flatMap(eventListFileId => { - return this.glassDocumentsRepository.download(eventListFileId).flatMap(file => { - return Future.fromPromise(getStringFromFile(file)).flatMap(_events => { - const eventIdList: string[] = JSON.parse(_events); - return Future.success(eventIdList); + private getIdsInEventListUpload(uploadId: string): FutureData { + return this.glassUploadsRepository.getById(uploadId).flatMap(upload => { + if (!upload?.eventListFileId) { + logger.error(`[${new Date().toISOString()}] Cannot find upload with id ${uploadId}`); + return Future.error("Cannot find upload"); + } else { + return this.glassDocumentsRepository.download(upload.eventListFileId).flatMap(listFileFileBlob => { + return getStringFromFileBlob(listFileFileBlob).flatMap(idsList => { + const ids: Id[] = JSON.parse(idsList); + return Future.success(ids); + }); }); - }); + } }); } - private uploadIdListFileAndSave( + private uploadCalculatedEventListFileIdAndSaveInUploads( uploadId: string, summary: { importSummary: ImportSummary; eventIdList: string[] }, moduleName: string diff --git a/src/domain/usecases/data-entry/amc/CustomValidationsAMCProductData.ts b/src/domain/usecases/data-entry/amc/CustomValidationsAMCProductData.ts index fdfd6087..74987c1d 100644 --- a/src/domain/usecases/data-entry/amc/CustomValidationsAMCProductData.ts +++ b/src/domain/usecases/data-entry/amc/CustomValidationsAMCProductData.ts @@ -1,12 +1,12 @@ -import i18n from "@eyeseetea/d2-ui-components/locales"; import { Future, FutureData } from "../../../entities/Future"; import { ConsistencyError } from "../../../entities/data-entry/ImportSummary"; import { ValidationResult } from "../../../entities/program-rules/EventEffectTypes"; import { D2TrackerTrackedEntity } from "@eyeseetea/d2-api/api/trackerTrackedEntities"; -import { GlassATCDefaultRepository } from "../../../../data/repositories/GlassATCDefaultRepository"; import { GlassAtcVersionData, LAST_ATC_CODE_LEVEL, getAtcCodeByLevel } from "../../../entities/GlassAtcVersionData"; import { AMCProductDataRepository } from "../../../repositories/data-entry/AMCProductDataRepository"; import { Country } from "../../../entities/Country"; +import { GlassATCRepository } from "../../../repositories/GlassATCRepository"; +import i18n from "../../../../locales"; const AMR_GLASS_AMC_TEA_ATC = "aK1JpD14imM"; const AMR_GLASS_AMC_TEA_COMBINATION = "mG49egdYK3G"; @@ -24,10 +24,7 @@ const COMB_CODE_PRODUCT_NOT_HAVE_ATC = "Z99ZZ99_99"; const AMR_GLASS_AMC_TEA_MANUFACTURER_COUNTRY = "OCSAMKIi1BD"; export class CustomValidationsAMCProductData { - constructor( - private atcRepository: GlassATCDefaultRepository, - private amcProductRepository: AMCProductDataRepository - ) {} + constructor(private atcRepository: GlassATCRepository, private amcProductRepository: AMCProductDataRepository) {} // private dhis2EventsDefaultRepository: Dhis2EventsDefaultRepository, public getValidatedEvents( teis: D2TrackerTrackedEntity[], diff --git a/src/domain/usecases/data-entry/amc/DeleteAMCProductLevelData.ts b/src/domain/usecases/data-entry/amc/DeleteAMCProductLevelData.ts new file mode 100644 index 00000000..e76b1373 --- /dev/null +++ b/src/domain/usecases/data-entry/amc/DeleteAMCProductLevelData.ts @@ -0,0 +1,168 @@ +import _ from "lodash"; + +import { ImportSummary } from "../../../entities/data-entry/ImportSummary"; +import { Future, FutureData } from "../../../entities/Future"; +import { ExcelRepository } from "../../../repositories/ExcelRepository"; +import * as templates from "../../../entities/data-entry/program-templates"; +import { TrackerRepository } from "../../../repositories/TrackerRepository"; +import { GlassDocumentsRepository } from "../../../repositories/GlassDocumentsRepository"; +import { mapToImportSummary, readTemplate } from "../ImportBLTemplateEventProgram"; +import { MetadataRepository } from "../../../repositories/MetadataRepository"; +import { AMCSubstanceDataRepository } from "../../../repositories/data-entry/AMCSubstanceDataRepository"; +import { downloadIdsAndDeleteTrackedEntitiesUsingFileBlob } from "../utils/downloadIdsAndDeleteTrackedEntities"; +import { getStringFromFileBlob } from "../utils/fileToString"; +import { InstanceRepository } from "../../../repositories/InstanceRepository"; +import { AMC_PRODUCT_REGISTER_PROGRAM_ID, AMR_GLASS_AMC_TET_PRODUCT_REGISTER } from "./ImportAMCProductLevelData"; +import { GlassUploadsRepository } from "../../../repositories/GlassUploadsRepository"; +import { GlassUploads } from "../../../entities/GlassUploads"; +import { Id } from "../../../entities/Ref"; +import { AMC_SUBSTANCE_CALCULATED_CONSUMPTION_PROGRAM_ID } from "./ImportAMCSubstanceLevelData"; + +// NOTICE: code adapted for node environment from ImportAMCProductLevelData.ts (only DELETE) +export class DeleteAMCProductLevelData { + constructor( + private options: { + excelRepository: ExcelRepository; + instanceRepository: InstanceRepository; + trackerRepository: TrackerRepository; + glassDocumentsRepository: GlassDocumentsRepository; + metadataRepository: MetadataRepository; + amcSubstanceDataRepository: AMCSubstanceDataRepository; + glassUploadsRepository: GlassUploadsRepository; + } + ) {} + + public delete(arrayBuffer: ArrayBuffer, upload: GlassUploads): FutureData { + const { id: uploadId, calculatedEventListFileId, calculatedEventListDataDeleted } = upload; + return this.options.excelRepository + .loadTemplateFromArrayBuffer(arrayBuffer, AMC_PRODUCT_REGISTER_PROGRAM_ID) + .flatMap(_templateId => { + const amcTemplate = _.values(templates) + .map(TemplateClass => new TemplateClass()) + .filter(t => t.id === "TRACKER_PROGRAM_GENERATED_v3")[0]; + + return this.options.instanceRepository + .getProgram(AMC_PRODUCT_REGISTER_PROGRAM_ID) + .flatMap(amcProgram => { + if (!amcTemplate) return Future.error("Cannot find template"); + + return readTemplate( + amcTemplate, + amcProgram, + this.options.excelRepository, + this.options.instanceRepository, + AMC_PRODUCT_REGISTER_PROGRAM_ID + ).flatMap(dataPackage => { + if (!dataPackage) return Future.error("Cannot find data package"); + + return downloadIdsAndDeleteTrackedEntitiesUsingFileBlob( + upload, + AMC_PRODUCT_REGISTER_PROGRAM_ID, + "DELETE", + AMR_GLASS_AMC_TET_PRODUCT_REGISTER, + this.options.glassDocumentsRepository, + this.options.trackerRepository, + this.options.metadataRepository, + this.options.glassUploadsRepository + ).flatMap(deleteProductSummary => { + if ( + (deleteProductSummary.status === "SUCCESS" || + deleteProductSummary.status === "WARNING") && + calculatedEventListFileId && + !calculatedEventListDataDeleted + ) { + return this.deleteCalculatedSubstanceConsumptionData( + uploadId, + deleteProductSummary, + calculatedEventListFileId + ); + } else { + return Future.success(deleteProductSummary); + } + }); + }); + }); + }); + } + + private deleteCalculatedSubstanceConsumptionData( + uploadId: Id, + deleteProductSummary: ImportSummary, + calculatedSubstanceConsumptionListFileId: string + ): FutureData { + return this.options.glassDocumentsRepository + .download(calculatedSubstanceConsumptionListFileId) + .flatMap(eventListFileBlob => { + return getStringFromFileBlob(eventListFileBlob).flatMap(_events => { + const calculatedConsumptionIds: Id[] = JSON.parse(_events); + + return this.options.trackerRepository + .getExistingEventsIdsByIds( + calculatedConsumptionIds, + AMC_SUBSTANCE_CALCULATED_CONSUMPTION_PROGRAM_ID + ) + .flatMap(existingEventsIds => { + if (!existingEventsIds.length) { + return this.options.glassUploadsRepository + .setCalculatedEventListDataDeleted(uploadId) + .flatMap(() => { + return Future.success(deleteProductSummary); + }); + } + + return this.options.amcSubstanceDataRepository + .deleteCalculatedSubstanceConsumptionDataById(existingEventsIds) + .flatMap(deleteCalculatedSubstanceConsumptionResponse => { + return mapToImportSummary( + deleteCalculatedSubstanceConsumptionResponse, + "event", + this.options.metadataRepository + ).flatMap(deleteCalculatedSubstanceConsumptionSummary => { + const mergedSummary: ImportSummary = { + ...deleteCalculatedSubstanceConsumptionSummary.importSummary, + importCount: { + imported: + deleteCalculatedSubstanceConsumptionSummary.importSummary + .importCount.imported + + deleteProductSummary.importCount.imported, + updated: + deleteCalculatedSubstanceConsumptionSummary.importSummary + .importCount.updated + deleteProductSummary.importCount.updated, + ignored: + deleteCalculatedSubstanceConsumptionSummary.importSummary + .importCount.ignored + deleteProductSummary.importCount.ignored, + deleted: + deleteCalculatedSubstanceConsumptionSummary.importSummary + .importCount.deleted + deleteProductSummary.importCount.deleted, + }, + nonBlockingErrors: [ + ...deleteCalculatedSubstanceConsumptionSummary.importSummary + .nonBlockingErrors, + ...deleteProductSummary.nonBlockingErrors, + ], + blockingErrors: [ + ...deleteCalculatedSubstanceConsumptionSummary.importSummary + .blockingErrors, + ...deleteProductSummary.blockingErrors, + ], + }; + + if ( + deleteCalculatedSubstanceConsumptionSummary.importSummary.status === + "SUCCESS" + ) { + return this.options.glassUploadsRepository + .setCalculatedEventListDataDeleted(uploadId) + .flatMap(() => { + return Future.success(mergedSummary); + }); + } else { + return Future.success(mergedSummary); + } + }); + }); + }); + }); + }); + } +} diff --git a/src/domain/usecases/data-entry/amc/DeleteAMCSubstanceLevelData.ts b/src/domain/usecases/data-entry/amc/DeleteAMCSubstanceLevelData.ts new file mode 100644 index 00000000..8669b9b9 --- /dev/null +++ b/src/domain/usecases/data-entry/amc/DeleteAMCSubstanceLevelData.ts @@ -0,0 +1,47 @@ +import { Dhis2EventsDefaultRepository } from "../../../../data/repositories/Dhis2EventsDefaultRepository"; +import { ImportSummary } from "../../../entities/data-entry/ImportSummary"; +import { FutureData } from "../../../entities/Future"; +import { ExcelRepository } from "../../../repositories/ExcelRepository"; +import { GlassDocumentsRepository } from "../../../repositories/GlassDocumentsRepository"; +import { MetadataRepository } from "../../../repositories/MetadataRepository"; +import { InstanceRepository } from "../../../repositories/InstanceRepository"; +import { DeleteBLTemplateEventProgram } from "../DeleteBLTemplateEventProgram"; +import { + AMC_RAW_SUBSTANCE_CONSUMPTION_PROGRAM_ID, + AMC_SUBSTANCE_CALCULATED_CONSUMPTION_PROGRAM_ID, +} from "./ImportAMCSubstanceLevelData"; +import { GlassUploads } from "../../../entities/GlassUploads"; +import { GlassUploadsRepository } from "../../../repositories/GlassUploadsRepository"; +import { TrackerRepository } from "../../../repositories/TrackerRepository"; + +export class DeleteAMCSubstanceLevelData { + constructor( + private options: { + dhis2EventsDefaultRepository: Dhis2EventsDefaultRepository; + excelRepository: ExcelRepository; + glassDocumentsRepository: GlassDocumentsRepository; + metadataRepository: MetadataRepository; + instanceRepository: InstanceRepository; + glassUploadsRepository: GlassUploadsRepository; + trackerRepository: TrackerRepository; + } + ) {} + public delete(arrayBuffer: ArrayBuffer, upload: GlassUploads): FutureData { + const deleteBLTemplateEventProgram = new DeleteBLTemplateEventProgram( + this.options.excelRepository, + this.options.instanceRepository, + this.options.glassDocumentsRepository, + this.options.dhis2EventsDefaultRepository, + this.options.metadataRepository, + this.options.glassUploadsRepository, + this.options.trackerRepository + ); + + return deleteBLTemplateEventProgram.delete( + arrayBuffer, + AMC_RAW_SUBSTANCE_CONSUMPTION_PROGRAM_ID, + upload, + AMC_SUBSTANCE_CALCULATED_CONSUMPTION_PROGRAM_ID + ); + } +} diff --git a/src/domain/usecases/data-entry/amc/ImportAMCProductLevelData.ts b/src/domain/usecases/data-entry/amc/ImportAMCProductLevelData.ts index ad4772ff..17e08d62 100644 --- a/src/domain/usecases/data-entry/amc/ImportAMCProductLevelData.ts +++ b/src/domain/usecases/data-entry/amc/ImportAMCProductLevelData.ts @@ -3,7 +3,6 @@ import { ImportSummary } from "../../../entities/data-entry/ImportSummary"; import { Future, FutureData } from "../../../entities/Future"; import { ExcelRepository } from "../../../repositories/ExcelRepository"; import * as templates from "../../../entities/data-entry/program-templates"; -import { InstanceDefaultRepository } from "../../../../data/repositories/InstanceDefaultRepository"; import { DataPackage } from "../../../entities/data-entry/DataPackage"; import { TrackerRepository } from "../../../repositories/TrackerRepository"; import { GlassDocumentsRepository } from "../../../repositories/GlassDocumentsRepository"; @@ -12,13 +11,12 @@ import { Id } from "../../../entities/Ref"; import { D2TrackerTrackedEntity } from "@eyeseetea/d2-api/api/trackerTrackedEntities"; import { D2TrackerEnrollment, D2TrackerEnrollmentAttribute } from "@eyeseetea/d2-api/api/trackerEnrollments"; import { D2TrackerEvent } from "@eyeseetea/d2-api/api/trackerEvents"; -import { mapToImportSummary, readTemplate, uploadIdListFileAndSave } from "../ImportBLTemplateEventProgram"; +import { mapToImportSummary, readTemplate } from "../ImportBLTemplateEventProgram"; import { MetadataRepository } from "../../../repositories/MetadataRepository"; import { ValidationResult } from "../../../entities/program-rules/EventEffectTypes"; import { ProgramRuleValidationForBLEventProgram } from "../../program-rules-processing/ProgramRuleValidationForBLEventProgram"; import { ProgramRulesMetadataRepository } from "../../../repositories/program-rules/ProgramRulesMetadataRepository"; import { CustomValidationsAMCProductData } from "./CustomValidationsAMCProductData"; -import { GlassATCDefaultRepository } from "../../../../data/repositories/GlassATCDefaultRepository"; import moment from "moment"; import { AMCProductDataRepository } from "../../../repositories/data-entry/AMCProductDataRepository"; import { CODE_PRODUCT_NOT_HAVE_ATC, COMB_CODE_PRODUCT_NOT_HAVE_ATC } from "../../../entities/GlassAtcVersionData"; @@ -27,24 +25,26 @@ import { downloadIdsAndDeleteTrackedEntities } from "../utils/downloadIdsAndDele import { getStringFromFile } from "../utils/fileToString"; import { getTEAValueFromOrganisationUnitCountryEntry } from "../utils/getTEAValueFromOrganisationUnitCountryEntry"; import { Country } from "../../../entities/Country"; +import { InstanceRepository } from "../../../repositories/InstanceRepository"; +import { GlassATCRepository } from "../../../repositories/GlassATCRepository"; export const AMC_PRODUCT_REGISTER_PROGRAM_ID = "G6ChA5zMW9n"; export const AMC_RAW_PRODUCT_CONSUMPTION_STAGE_ID = "GmElQHKXLIE"; export const AMC_RAW_PRODUCT_CONSUMPTION_CALCULATED_STAGE_ID = "q8cl5qllyjd"; -const AMR_GLASS_AMC_TET_PRODUCT_REGISTER = "uE6bIKLsGYW"; +export const AMR_GLASS_AMC_TET_PRODUCT_REGISTER = "uE6bIKLsGYW"; const AMR_GLASS_AMC_TEA_ATC = "aK1JpD14imM"; const AMR_GLASS_AMC_TEA_COMBINATION = "mG49egdYK3G"; export class ImportAMCProductLevelData { constructor( private excelRepository: ExcelRepository, - private instanceRepository: InstanceDefaultRepository, + private instanceRepository: InstanceRepository, private trackerRepository: TrackerRepository, private glassDocumentsRepository: GlassDocumentsRepository, private glassUploadsRepository: GlassUploadsRepository, private metadataRepository: MetadataRepository, private programRulesMetadataRepository: ProgramRulesMetadataRepository, - private atcRepository: GlassATCDefaultRepository, + private atcRepository: GlassATCRepository, private amcProductRepository: AMCProductDataRepository, private amcSubstanceDataRepository: AMCSubstanceDataRepository ) {} @@ -84,6 +84,9 @@ export class ImportAMCProductLevelData { period, allCountries ).flatMap(entities => { + if (!entities.length) + return Future.error("The file is empty or failed while reading the file."); + return this.validateTEIsAndEvents( entities, orgUnitId, @@ -124,18 +127,17 @@ export class ImportAMCProductLevelData { this.metadataRepository, validationResults.nonBlockingErrors ).flatMap(summary => { - return uploadIdListFileAndSave( + return this.uploadTeiIdListFileAndSave( "primaryUploadId", summary, - moduleName, - this.glassDocumentsRepository, - this.glassUploadsRepository + moduleName ); }); }); }); }); } else { + // NOTICE: check also DeleteAMCProductLevelDataUseCase.ts that contains same code adapted for node environment return downloadIdsAndDeleteTrackedEntities( eventListId, orgUnitId, @@ -367,6 +369,29 @@ export class ImportAMCProductLevelData { }); } + private uploadTeiIdListFileAndSave = ( + uploadIdLocalStorageName: string, + summary: { importSummary: ImportSummary; eventIdList: string[] }, + moduleName: string + ): FutureData => { + const uploadId = localStorage.getItem(uploadIdLocalStorageName); + if (summary.eventIdList.length > 0 && uploadId) { + //Events were imported successfully, so create and uplaod a file with tei ids + // and associate it with the upload datastore object + const teisListBlob = new Blob([JSON.stringify(summary.eventIdList)], { + type: "text/plain", + }); + const teiIdListFile = new File([teisListBlob], `${uploadId}_eventIdsFile`); + return this.glassDocumentsRepository.save(teiIdListFile, moduleName).flatMap(fileId => { + return this.glassUploadsRepository.setEventListFileId(uploadId, fileId).flatMap(() => { + return Future.success(summary.importSummary); + }); + }); + } else { + return Future.success(summary.importSummary); + } + }; + private deleteCalculatedSubstanceConsumptionData( deleteProductSummary: ImportSummary, calculatedSubstanceConsumptionListFileId: string diff --git a/src/domain/usecases/data-entry/amc/ImportAMCSubstanceLevelData.ts b/src/domain/usecases/data-entry/amc/ImportAMCSubstanceLevelData.ts index b506000b..c53d6665 100644 --- a/src/domain/usecases/data-entry/amc/ImportAMCSubstanceLevelData.ts +++ b/src/domain/usecases/data-entry/amc/ImportAMCSubstanceLevelData.ts @@ -2,14 +2,14 @@ import { ImportStrategy } from "../../../entities/data-entry/DataValuesSaveSumma import { ImportSummary } from "../../../entities/data-entry/ImportSummary"; import { FutureData } from "../../../entities/Future"; import { ExcelRepository } from "../../../repositories/ExcelRepository"; -import { InstanceDefaultRepository } from "../../../../data/repositories/InstanceDefaultRepository"; import { GlassDocumentsRepository } from "../../../repositories/GlassDocumentsRepository"; import { GlassUploadsRepository } from "../../../repositories/GlassUploadsRepository"; import { Dhis2EventsDefaultRepository } from "../../../../data/repositories/Dhis2EventsDefaultRepository"; import { MetadataRepository } from "../../../repositories/MetadataRepository"; import { ImportBLTemplateEventProgram } from "../ImportBLTemplateEventProgram"; import { ProgramRulesMetadataRepository } from "../../../repositories/program-rules/ProgramRulesMetadataRepository"; -import { GlassATCDefaultRepository } from "../../../../data/repositories/GlassATCDefaultRepository"; +import { GlassATCRepository } from "../../../repositories/GlassATCRepository"; +import { InstanceRepository } from "../../../repositories/InstanceRepository"; export const AMC_RAW_SUBSTANCE_CONSUMPTION_PROGRAM_ID = "q8aSKr17J5S"; export const AMC_SUBSTANCE_CALCULATED_CONSUMPTION_PROGRAM_ID = "eUmWZeKZNrg"; @@ -17,13 +17,13 @@ export const AMC_SUBSTANCE_CALCULATED_CONSUMPTION_PROGRAM_ID = "eUmWZeKZNrg"; export class ImportAMCSubstanceLevelData { constructor( private excelRepository: ExcelRepository, - private instanceRepository: InstanceDefaultRepository, + private instanceRepository: InstanceRepository, private glassDocumentsRepository: GlassDocumentsRepository, private glassUploadsRepository: GlassUploadsRepository, private dhis2EventsDefaultRepository: Dhis2EventsDefaultRepository, private metadataRepository: MetadataRepository, private programRulesMetadataRepository: ProgramRulesMetadataRepository, - private glassAtcRepository: GlassATCDefaultRepository + private glassAtcRepository: GlassATCRepository ) {} public import( diff --git a/src/domain/usecases/data-entry/amc/utils/__tests__/calculationConsumptionProductLevelData.spec.ts b/src/domain/usecases/data-entry/amc/utils/__tests__/calculationConsumptionProductLevelData.spec.ts index 0c10705b..fb24d13d 100644 --- a/src/domain/usecases/data-entry/amc/utils/__tests__/calculationConsumptionProductLevelData.spec.ts +++ b/src/domain/usecases/data-entry/amc/utils/__tests__/calculationConsumptionProductLevelData.spec.ts @@ -175,7 +175,7 @@ describe("Given calculate Consumption Product Level Data function", () => { }); function givenProductRegistryAttributesByType(type?: string): ProductRegistryAttributes[] { - const productRegistryAttributesTypes = { + const productRegistryAttributesTypes: Record = { basic: productRegistryAttributesBasic, no_atc_data: productRegistryAttributesBasic, conc_volume_and_volume: productRegistryAttributesConcVolumeAndVolume, @@ -184,7 +184,7 @@ function givenProductRegistryAttributesByType(type?: string): ProductRegistryAtt productRegistryAttributesMillionInternationalUnitDifferentDDDUnit, no_combination_code_no_found_ddd: productRegistryAttributesNoCombCodeNoFoundDDD, wrong_strength_unit: productRegistryAttributesWrongStrengthUnit, - } as Record; + }; const productRegistryAttributes = type ? productRegistryAttributesTypes[type] diff --git a/src/domain/usecases/data-entry/amc/utils/__tests__/data/productRegistryAttributesBasic.json b/src/domain/usecases/data-entry/amc/utils/__tests__/data/productRegistryAttributesBasic.json index 5f24f905..fafe2bf0 100644 --- a/src/domain/usecases/data-entry/amc/utils/__tests__/data/productRegistryAttributesBasic.json +++ b/src/domain/usecases/data-entry/amc/utils/__tests__/data/productRegistryAttributesBasic.json @@ -8,7 +8,8 @@ "AMR_GLASS_AMC_TEA_ATC": "J01FA01", "AMR_GLASS_AMC_TEA_ROUTE_ADMIN": "O", "AMR_GLASS_AMC_TEA_SALT": "XXXX", - "AMR_GLASS_AMC_TEA_VOLUME": 1 + "AMR_GLASS_AMC_TEA_VOLUME": 1, + "AMR_GLASS_AMC_TEA_COMBINATION": "" }, { "AMR_GLASS_AMC_TEA_PRODUCT_ID": "P37", @@ -19,6 +20,7 @@ "AMR_GLASS_AMC_TEA_ATC": "A07AA09", "AMR_GLASS_AMC_TEA_ROUTE_ADMIN": "O", "AMR_GLASS_AMC_TEA_SALT": "XXXX", - "AMR_GLASS_AMC_TEA_VOLUME": 1 + "AMR_GLASS_AMC_TEA_VOLUME": 1, + "AMR_GLASS_AMC_TEA_COMBINATION": "" } ] diff --git a/src/domain/usecases/data-entry/amc/utils/__tests__/data/productRegistryAttributesConcVolumeAndVolume.json b/src/domain/usecases/data-entry/amc/utils/__tests__/data/productRegistryAttributesConcVolumeAndVolume.json index a811a7c4..8eb0fbd0 100644 --- a/src/domain/usecases/data-entry/amc/utils/__tests__/data/productRegistryAttributesConcVolumeAndVolume.json +++ b/src/domain/usecases/data-entry/amc/utils/__tests__/data/productRegistryAttributesConcVolumeAndVolume.json @@ -8,7 +8,8 @@ "AMR_GLASS_AMC_TEA_ATC": "J01CA04", "AMR_GLASS_AMC_TEA_ROUTE_ADMIN": "O", "AMR_GLASS_AMC_TEA_SALT": "XXXX", - "AMR_GLASS_AMC_TEA_VOLUME": 100 + "AMR_GLASS_AMC_TEA_VOLUME": 100, + "AMR_GLASS_AMC_TEA_COMBINATION": "" }, { "AMR_GLASS_AMC_TEA_PRODUCT_ID": "P37", @@ -19,6 +20,7 @@ "AMR_GLASS_AMC_TEA_ATC": "J01CA04", "AMR_GLASS_AMC_TEA_ROUTE_ADMIN": "O", "AMR_GLASS_AMC_TEA_SALT": "XXXX", - "AMR_GLASS_AMC_TEA_VOLUME": 100 + "AMR_GLASS_AMC_TEA_VOLUME": 100, + "AMR_GLASS_AMC_TEA_COMBINATION": "" } ] diff --git a/src/domain/usecases/data-entry/amc/utils/__tests__/data/productRegistryAttributesMillionInternationalUnitDifferentDDDUnit.json b/src/domain/usecases/data-entry/amc/utils/__tests__/data/productRegistryAttributesMillionInternationalUnitDifferentDDDUnit.json index f149c25f..d3d8bb5b 100644 --- a/src/domain/usecases/data-entry/amc/utils/__tests__/data/productRegistryAttributesMillionInternationalUnitDifferentDDDUnit.json +++ b/src/domain/usecases/data-entry/amc/utils/__tests__/data/productRegistryAttributesMillionInternationalUnitDifferentDDDUnit.json @@ -8,7 +8,8 @@ "AMR_GLASS_AMC_TEA_ATC": "J01CE01", "AMR_GLASS_AMC_TEA_ROUTE_ADMIN": "P", "AMR_GLASS_AMC_TEA_SALT": "XXXX", - "AMR_GLASS_AMC_TEA_VOLUME": 1 + "AMR_GLASS_AMC_TEA_VOLUME": 1, + "AMR_GLASS_AMC_TEA_COMBINATION": "" }, { "AMR_GLASS_AMC_TEA_PRODUCT_ID": "P37", @@ -19,6 +20,7 @@ "AMR_GLASS_AMC_TEA_ATC": "J01CE01", "AMR_GLASS_AMC_TEA_ROUTE_ADMIN": "P", "AMR_GLASS_AMC_TEA_SALT": "XXXX", - "AMR_GLASS_AMC_TEA_VOLUME": 1 + "AMR_GLASS_AMC_TEA_VOLUME": 1, + "AMR_GLASS_AMC_TEA_COMBINATION": "" } ] diff --git a/src/domain/usecases/data-entry/amc/utils/__tests__/data/productRegistryAttributesNoCombCodeNoFoundDDD.json b/src/domain/usecases/data-entry/amc/utils/__tests__/data/productRegistryAttributesNoCombCodeNoFoundDDD.json index c92307bb..02a2f1e6 100644 --- a/src/domain/usecases/data-entry/amc/utils/__tests__/data/productRegistryAttributesNoCombCodeNoFoundDDD.json +++ b/src/domain/usecases/data-entry/amc/utils/__tests__/data/productRegistryAttributesNoCombCodeNoFoundDDD.json @@ -8,7 +8,8 @@ "AMR_GLASS_AMC_TEA_ATC": "ATC_NOT_IN_atcCurrentVersionData", "AMR_GLASS_AMC_TEA_ROUTE_ADMIN": "O", "AMR_GLASS_AMC_TEA_SALT": "XXXX", - "AMR_GLASS_AMC_TEA_VOLUME": 1 + "AMR_GLASS_AMC_TEA_VOLUME": 1, + "AMR_GLASS_AMC_TEA_COMBINATION": "" }, { "AMR_GLASS_AMC_TEA_PRODUCT_ID": "P37", @@ -19,6 +20,7 @@ "AMR_GLASS_AMC_TEA_ATC": "ATC_NOT_IN_atcCurrentVersionData", "AMR_GLASS_AMC_TEA_ROUTE_ADMIN": "O", "AMR_GLASS_AMC_TEA_SALT": "XXXX", - "AMR_GLASS_AMC_TEA_VOLUME": 1 + "AMR_GLASS_AMC_TEA_VOLUME": 1, + "AMR_GLASS_AMC_TEA_COMBINATION": "" } ] diff --git a/src/domain/usecases/data-entry/amc/utils/__tests__/data/productRegistryAttributesWrongStrengthUnit.json b/src/domain/usecases/data-entry/amc/utils/__tests__/data/productRegistryAttributesWrongStrengthUnit.json index 786b6cd2..7cb2cd12 100644 --- a/src/domain/usecases/data-entry/amc/utils/__tests__/data/productRegistryAttributesWrongStrengthUnit.json +++ b/src/domain/usecases/data-entry/amc/utils/__tests__/data/productRegistryAttributesWrongStrengthUnit.json @@ -8,7 +8,8 @@ "AMR_GLASS_AMC_TEA_ATC": "J01FA01", "AMR_GLASS_AMC_TEA_ROUTE_ADMIN": "O", "AMR_GLASS_AMC_TEA_SALT": "XXXX", - "AMR_GLASS_AMC_TEA_VOLUME": 1 + "AMR_GLASS_AMC_TEA_VOLUME": 1, + "AMR_GLASS_AMC_TEA_COMBINATION": "" }, { "AMR_GLASS_AMC_TEA_PRODUCT_ID": "P37", @@ -19,6 +20,7 @@ "AMR_GLASS_AMC_TEA_ATC": "A07AA09", "AMR_GLASS_AMC_TEA_ROUTE_ADMIN": "O", "AMR_GLASS_AMC_TEA_SALT": "XXXX", - "AMR_GLASS_AMC_TEA_VOLUME": 1 + "AMR_GLASS_AMC_TEA_VOLUME": 1, + "AMR_GLASS_AMC_TEA_COMBINATION": "" } ] diff --git a/src/domain/usecases/data-entry/amc/utils/calculationConsumptionProductLevelData.ts b/src/domain/usecases/data-entry/amc/utils/calculationConsumptionProductLevelData.ts index 4c15e17c..0503cd19 100644 --- a/src/domain/usecases/data-entry/amc/utils/calculationConsumptionProductLevelData.ts +++ b/src/domain/usecases/data-entry/amc/utils/calculationConsumptionProductLevelData.ts @@ -515,7 +515,7 @@ function calculateDDDPerProductConsumptionPackages( productConsumption; // 4b - ddd_cons_product = ddd_per_pack × packages (in year, health_sector and health_level) - const dddConsumptionPackages = dddPerPackage.value * packages_manual; + const dddConsumptionPackages = packages_manual ? dddPerPackage.value * packages_manual : undefined; return { result: { AMR_GLASS_AMC_TEA_PRODUCT_ID, @@ -530,7 +530,7 @@ function calculateDDDPerProductConsumptionPackages( { content: `[${new Date().toISOString()}] Product ${ productConsumption.AMR_GLASS_AMC_TEA_PRODUCT_ID - } - DDD per product consumption packages: ${dddConsumptionPackages}`, + } - DDD per product consumption packages: ${dddConsumptionPackages} (# packages: ${packages_manual})`, messageType: "Info", }, ], @@ -587,7 +587,7 @@ function getTonnesPerProduct( year: period, health_sector_manual, health_level_manual, - contentTonnes: (content.value * conversionFactor * packages_manual) / 1e6, + contentTonnes: packages_manual ? (content.value * conversionFactor * packages_manual) / 1e6 : undefined, }, logs: [ ...calculationLogs, @@ -595,7 +595,9 @@ function getTonnesPerProduct( content: `[${new Date().toISOString()}] Product ${ productConsumption.AMR_GLASS_AMC_TEA_PRODUCT_ID } - Conversion factor used to calculate content_tonnes: ${conversionFactor}. Content tonnes: ${ - (content.value * conversionFactor * packages_manual) / 1e6 + packages_manual + ? (content.value * conversionFactor * packages_manual) / 1e6 + : "Not packages manual defined" }`, messageType: "Debug", }, @@ -706,17 +708,24 @@ function aggregateDataByAtcRouteAdminYearHealthSectorAndHealthLevel( ]; } - const contentKilograms = contentTonnesOfProduct.result.contentTonnes * 1000; + const contentKilograms = contentTonnesOfProduct.result.contentTonnes + ? contentTonnesOfProduct.result.contentTonnes * 1000 + : undefined; return { ...acc, [id]: isAlreadyInTheAggregation ? { ...accWithThisId, - kilograms_autocalculated: accWithThisId.kilograms_autocalculated + contentKilograms, - packages_autocalculated: accWithThisId.packages_autocalculated + packages_manual, - ddds_autocalculated: - accWithThisId.ddds_autocalculated + - dddPerProductConsumptionPackages.result.dddConsumptionPackages, + kilograms_autocalculated: contentKilograms + ? (accWithThisId.kilograms_autocalculated || 0) + contentKilograms + : accWithThisId.kilograms_autocalculated, + packages_autocalculated: packages_manual + ? (accWithThisId.packages_autocalculated || 0) + packages_manual + : accWithThisId.packages_autocalculated, + ddds_autocalculated: dddPerProductConsumptionPackages.result.dddConsumptionPackages + ? (accWithThisId.ddds_autocalculated || 0) + + dddPerProductConsumptionPackages.result.dddConsumptionPackages + : accWithThisId.ddds_autocalculated, } : { AMR_GLASS_AMC_TEA_PRODUCT_ID, diff --git a/src/domain/usecases/data-entry/amc/utils/calculationConsumptionSubstanceLevelData.ts b/src/domain/usecases/data-entry/amc/utils/calculationConsumptionSubstanceLevelData.ts index d8b17b56..bb4d8de9 100644 --- a/src/domain/usecases/data-entry/amc/utils/calculationConsumptionSubstanceLevelData.ts +++ b/src/domain/usecases/data-entry/amc/utils/calculationConsumptionSubstanceLevelData.ts @@ -150,7 +150,9 @@ export function calculateConsumptionSubstanceLevelData( const atcCodeByLevel = getAtcCodeByLevel(atcData, rawSubstanceConsumption.atc_manual); const aware = getAwareClass(awareClassData, rawSubstanceConsumption.atc_manual); - const rawSubstanceConsumptionKilograms = rawSubstanceConsumption.tons_manual * 1000; + const rawSubstanceConsumptionKilograms = rawSubstanceConsumption.tons_manual + ? rawSubstanceConsumption.tons_manual * 1000 + : undefined; return { period, orgUnitId, diff --git a/src/domain/usecases/data-entry/amr-individual-fungal/DeleteRISIndividualFungalFile.ts b/src/domain/usecases/data-entry/amr-individual-fungal/DeleteRISIndividualFungalFile.ts new file mode 100644 index 00000000..cbcd5a97 --- /dev/null +++ b/src/domain/usecases/data-entry/amr-individual-fungal/DeleteRISIndividualFungalFile.ts @@ -0,0 +1,36 @@ +import { FutureData } from "../../../entities/Future"; +import { ImportSummary } from "../../../entities/data-entry/ImportSummary"; +import { GlassDocumentsRepository } from "../../../repositories/GlassDocumentsRepository"; +import { TrackerRepository } from "../../../repositories/TrackerRepository"; +import { MetadataRepository } from "../../../repositories/MetadataRepository"; +import { downloadIdsAndDeleteTrackedEntitiesUsingFileBlob } from "../utils/downloadIdsAndDeleteTrackedEntities"; +import { AMR_GLASS_AMR_TET_PATIENT, AMRIProgramID } from "./ImportRISIndividualFungalFile"; +import { Id } from "../../../entities/Ref"; +import { GlassUploadsRepository } from "../../../repositories/GlassUploadsRepository"; +import { GlassUploads } from "../../../entities/GlassUploads"; + +// NOTICE: code adapted for node environment from ImportRISIndividualFungalFile.ts (only DELETE) +export class DeleteRISIndividualFungalFile { + constructor( + private options: { + trackerRepository: TrackerRepository; + glassDocumentsRepository: GlassDocumentsRepository; + metadataRepository: MetadataRepository; + glassUploadsRepository: GlassUploadsRepository; + } + ) {} + + public delete(upload: GlassUploads, programId: Id | undefined): FutureData { + const AMRIProgramIDl = programId || AMRIProgramID; + return downloadIdsAndDeleteTrackedEntitiesUsingFileBlob( + upload, + AMRIProgramIDl, + "DELETE", + AMR_GLASS_AMR_TET_PATIENT, + this.options.glassDocumentsRepository, + this.options.trackerRepository, + this.options.metadataRepository, + this.options.glassUploadsRepository + ); + } +} diff --git a/src/domain/usecases/data-entry/amr-individual-fungal/ImportRISIndividualFungalFile.ts b/src/domain/usecases/data-entry/amr-individual-fungal/ImportRISIndividualFungalFile.ts index c40ed272..47188d3b 100644 --- a/src/domain/usecases/data-entry/amr-individual-fungal/ImportRISIndividualFungalFile.ts +++ b/src/domain/usecases/data-entry/amr-individual-fungal/ImportRISIndividualFungalFile.ts @@ -1,4 +1,3 @@ -import i18n from "@eyeseetea/d2-ui-components/locales"; import { Future, FutureData } from "../../../entities/Future"; import { ImportStrategy } from "../../../entities/data-entry/DataValuesSaveSummary"; import { ConsistencyError, ImportSummary } from "../../../entities/data-entry/ImportSummary"; @@ -19,9 +18,10 @@ import { ProgramRulesMetadataRepository } from "../../../repositories/program-ru import { downloadIdsAndDeleteTrackedEntities } from "../utils/downloadIdsAndDeleteTrackedEntities"; import { getTEAValueFromOrganisationUnitCountryEntry } from "../utils/getTEAValueFromOrganisationUnitCountryEntry"; import { Country } from "../../../entities/Country"; +import i18n from "../../../../locales"; export const AMRIProgramID = "mMAj6Gofe49"; -const AMR_GLASS_AMR_TET_PATIENT = "CcgnfemKr5U"; +export const AMR_GLASS_AMR_TET_PATIENT = "CcgnfemKr5U"; export const AMRDataProgramStageId = "KCmWZD8qoAk"; export const AMRCandidaProgramStageId = "ysGSonDq9Bc"; @@ -139,6 +139,7 @@ export class ImportRISIndividualFungalFile { ); }); } else { + // NOTICE: check also DeleteRISIndividualFungalFileUseCase.ts that contains same code adapted for node environment (only DELETE) return downloadIdsAndDeleteTrackedEntities( eventListId, orgUnit, diff --git a/src/domain/usecases/data-entry/amr/DeleteRISDataset.ts b/src/domain/usecases/data-entry/amr/DeleteRISDataset.ts new file mode 100644 index 00000000..86cdb82a --- /dev/null +++ b/src/domain/usecases/data-entry/amr/DeleteRISDataset.ts @@ -0,0 +1,93 @@ +import { Future, FutureData } from "../../../entities/Future"; +import { ImportSummary } from "../../../entities/data-entry/ImportSummary"; +import { MetadataRepository } from "../../../repositories/MetadataRepository"; +import { DataValuesRepository } from "../../../repositories/data-entry/DataValuesRepository"; +import { RISDataRepository } from "../../../repositories/data-entry/RISDataRepository"; +import { + AMR_SPECIMEN_GENDER_AGE_ORIGIN_CC_ID, + getCategoryOptionComboByDataElement, + getCategoryOptionComboByOptionCodes, +} from "../utils/getCategoryOptionCombo"; +import { includeBlockingErrors } from "../utils/includeBlockingErrors"; +import { mapDataValuesToImportSummary } from "../utils/mapDhis2Summary"; +import { RISData } from "../../../entities/data-entry/amr-external/RISData"; +import { AMR_AMR_DS_INPUT_FILES_RIS_DS_ID, AMR_DATA_PATHOGEN_ANTIBIOTIC_BATCHID_CC_ID } from "./ImportRISFile"; + +// NOTICE: code adapted for node environment from ImportRISFile.ts (only DELETE) +export class DeleteRISDataset { + constructor( + private options: { + risDataRepository: RISDataRepository; + metadataRepository: MetadataRepository; + dataValuesRepository: DataValuesRepository; + } + ) {} + + public delete(arrayBuffer: ArrayBuffer): FutureData { + return this.options.risDataRepository + .getFromArayBuffer(arrayBuffer) + .flatMap(risDataItems => { + return Future.joinObj({ + risDataItems: Future.success(risDataItems), + dataSet: this.options.metadataRepository.getDataSet(AMR_AMR_DS_INPUT_FILES_RIS_DS_ID), + dataSet_CC: this.options.metadataRepository.getCategoryCombination( + AMR_DATA_PATHOGEN_ANTIBIOTIC_BATCHID_CC_ID + ), + dataElement_CC: this.options.metadataRepository.getCategoryCombination( + AMR_SPECIMEN_GENDER_AGE_ORIGIN_CC_ID + ), + orgUnits: this.options.metadataRepository.getOrgUnitsByCode([ + ...new Set(risDataItems.map(item => item.COUNTRY)), + ]), + }); + }) + .flatMap(({ risDataItems, dataSet, dataSet_CC, dataElement_CC, orgUnits }) => { + const blockingCategoryOptionErrors: { error: string; line: number }[] = []; + + const dataValues = risDataItems + .map((risData, index) => { + return dataSet.dataElements.map(dataElement => { + const dataSetCategoryOptionValues = dataSet_CC.categories.map(category => + risData[category.code as keyof RISData].toString() + ); + + const { categoryOptionComboId: attributeOptionCombo, error: aocBlockingError } = + getCategoryOptionComboByOptionCodes(dataSet_CC, dataSetCategoryOptionValues); + + if (aocBlockingError !== "") + blockingCategoryOptionErrors.push({ error: aocBlockingError, line: index + 1 }); + + const { categoryOptionComboId: categoryOptionCombo, error: ccoBlockingError } = + getCategoryOptionComboByDataElement(dataElement, dataElement_CC, risData); + + if (ccoBlockingError !== "") + blockingCategoryOptionErrors.push({ error: ccoBlockingError, line: index + 1 }); + + const value = risData[dataElement.code as keyof RISData]?.toString() || ""; + + const dataValue = { + orgUnit: orgUnits.find(ou => ou.code === risData.COUNTRY)?.id || "", + period: risData.YEAR.toString(), + attributeOptionCombo: attributeOptionCombo, + dataElement: dataElement.id, + categoryOptionCombo: categoryOptionCombo, + value, + }; + + return dataValue; + }); + }) + .flat(); + + return this.options.dataValuesRepository.save(dataValues, "DELETE", false).map(saveSummary => { + const importSummary = mapDataValuesToImportSummary(saveSummary, "DELETE"); + + const summaryWithConsistencyBlokingErrors = includeBlockingErrors(importSummary, []); + + summaryWithConsistencyBlokingErrors.importTime = saveSummary.importTime; + + return summaryWithConsistencyBlokingErrors; + }); + }); + } +} diff --git a/src/domain/usecases/data-entry/amr/DeleteSampleDataset.ts b/src/domain/usecases/data-entry/amr/DeleteSampleDataset.ts new file mode 100644 index 00000000..6e7549a3 --- /dev/null +++ b/src/domain/usecases/data-entry/amr/DeleteSampleDataset.ts @@ -0,0 +1,89 @@ +import { Future, FutureData } from "../../../entities/Future"; +import { ImportSummary } from "../../../entities/data-entry/ImportSummary"; +import { MetadataRepository } from "../../../repositories/MetadataRepository"; +import { DataValuesRepository } from "../../../repositories/data-entry/DataValuesRepository"; +import { + AMR_SPECIMEN_GENDER_AGE_ORIGIN_CC_ID, + getCategoryOptionComboByDataElement, + getCategoryOptionComboByOptionCodes, +} from "../utils/getCategoryOptionCombo"; +import { includeBlockingErrors } from "../utils/includeBlockingErrors"; +import { mapDataValuesToImportSummary } from "../utils/mapDhis2Summary"; +import { SampleDataRepository } from "../../../repositories/data-entry/SampleDataRepository"; +import { SampleData } from "../../../entities/data-entry/amr-external/SampleData"; +import { AMR_AMR_DS_Input_files_Sample_DS_ID, AMR_BATCHID_CC_ID } from "./ImportSampleFile"; + +// NOTICE: code adapted for node environment from ImportSampleFile.ts (only DELETE) +export class DeleteSampleDataset { + constructor( + private options: { + sampleDataRepository: SampleDataRepository; + metadataRepository: MetadataRepository; + dataValuesRepository: DataValuesRepository; + } + ) {} + + public delete(arrayBuffer: ArrayBuffer): FutureData { + return this.options.sampleDataRepository + .getFromArayBuffer(arrayBuffer) + .flatMap(risDataItems => { + return Future.joinObj({ + risDataItems: Future.success(risDataItems), + dataSet: this.options.metadataRepository.getDataSet(AMR_AMR_DS_Input_files_Sample_DS_ID), + dataSet_CC: this.options.metadataRepository.getCategoryCombination(AMR_BATCHID_CC_ID), + dataElement_CC: this.options.metadataRepository.getCategoryCombination( + AMR_SPECIMEN_GENDER_AGE_ORIGIN_CC_ID + ), + orgUnits: this.options.metadataRepository.getOrgUnitsByCode([ + ...new Set(risDataItems.map(item => item.COUNTRY)), + ]), + }); + }) + .flatMap(({ risDataItems, dataSet, dataSet_CC, dataElement_CC, orgUnits }) => { + const blockingCategoryOptionErrors: { error: string; line: number }[] = []; + + const dataValues = risDataItems + .map((risData, index) => { + return dataSet.dataElements.map(dataElement => { + const dataSetCategoryOptionValues = dataSet_CC.categories.map(category => + risData[category.code as keyof SampleData].toString() + ); + + const { categoryOptionComboId: attributeOptionCombo, error: aocBlockingError } = + getCategoryOptionComboByOptionCodes(dataSet_CC, dataSetCategoryOptionValues); + + if (aocBlockingError !== "") + blockingCategoryOptionErrors.push({ error: aocBlockingError, line: index + 1 }); + + const { categoryOptionComboId: categoryOptionCombo, error: ccoBlockingError } = + getCategoryOptionComboByDataElement(dataElement, dataElement_CC, risData); + + if (ccoBlockingError !== "") + blockingCategoryOptionErrors.push({ error: ccoBlockingError, line: index + 1 }); + + const value = risData[dataElement.code as keyof SampleData]?.toString() || ""; + + const dataValue = { + orgUnit: orgUnits.find(ou => ou.code === risData.COUNTRY)?.id || "", + period: risData.YEAR.toString(), + attributeOptionCombo, + dataElement: dataElement.id, + categoryOptionCombo: categoryOptionCombo, + value, + }; + + return dataValue; + }); + }) + .flat(); + + return this.options.dataValuesRepository.save(dataValues, "DELETE", false).map(saveSummary => { + const importSummary = mapDataValuesToImportSummary(saveSummary, "DELETE"); + + const summaryWithConsistencyBlokingErrors = includeBlockingErrors(importSummary, []); + + return summaryWithConsistencyBlokingErrors; + }); + }); + } +} diff --git a/src/domain/usecases/data-entry/amr/ImportRISFile.ts b/src/domain/usecases/data-entry/amr/ImportRISFile.ts index 0a30cab6..4b40f803 100644 --- a/src/domain/usecases/data-entry/amr/ImportRISFile.ts +++ b/src/domain/usecases/data-entry/amr/ImportRISFile.ts @@ -22,8 +22,8 @@ import { mapDataValuesToImportSummary } from "../utils/mapDhis2Summary"; import { RISData } from "../../../entities/data-entry/amr-external/RISData"; import { checkDuplicateRowsRIS } from "../utils/checkDuplicateRows"; -const AMR_AMR_DS_INPUT_FILES_RIS_DS_ID = "CeQPmXgrhHF"; -const AMR_DATA_PATHOGEN_ANTIBIOTIC_BATCHID_CC_ID = "S427AvQESbw"; +export const AMR_AMR_DS_INPUT_FILES_RIS_DS_ID = "CeQPmXgrhHF"; +export const AMR_DATA_PATHOGEN_ANTIBIOTIC_BATCHID_CC_ID = "S427AvQESbw"; export class ImportRISFile { constructor( @@ -33,6 +33,7 @@ export class ImportRISFile { private moduleRepository: GlassModuleRepository ) {} + // NOTICE: check also DeleteRISDatasetUseCase.ts that contains same code adapted for node environment (only DELETE) public importRISFile( inputFile: File, batchId: string, diff --git a/src/domain/usecases/data-entry/amr/ImportSampleFile.ts b/src/domain/usecases/data-entry/amr/ImportSampleFile.ts index 635994d6..2cf39394 100644 --- a/src/domain/usecases/data-entry/amr/ImportSampleFile.ts +++ b/src/domain/usecases/data-entry/amr/ImportSampleFile.ts @@ -19,8 +19,8 @@ import { SampleDataRepository } from "../../../repositories/data-entry/SampleDat import { SampleData } from "../../../entities/data-entry/amr-external/SampleData"; import { checkDuplicateRowsSAMPLE } from "../utils/checkDuplicateRows"; -const AMR_AMR_DS_Input_files_Sample_DS_ID = "OcAB7oaC072"; -const AMR_BATCHID_CC_ID = "rEMx3WFeLcU"; +export const AMR_AMR_DS_Input_files_Sample_DS_ID = "OcAB7oaC072"; +export const AMR_BATCHID_CC_ID = "rEMx3WFeLcU"; export class ImportSampleFile { constructor( @@ -29,6 +29,7 @@ export class ImportSampleFile { private dataValuesRepository: DataValuesRepository ) {} + // NOTICE: check also DeleteSampleDatasetUseCase.ts that contains same code adapted for node environment (only DELETE) public import( inputFile: File, batchId: string, diff --git a/src/domain/usecases/data-entry/egasp/CustomValidationForEventProgram.ts b/src/domain/usecases/data-entry/egasp/CustomValidationForEventProgram.ts index 9a90eb3a..e8e62324 100644 --- a/src/domain/usecases/data-entry/egasp/CustomValidationForEventProgram.ts +++ b/src/domain/usecases/data-entry/egasp/CustomValidationForEventProgram.ts @@ -1,4 +1,3 @@ -import i18n from "@eyeseetea/d2-ui-components/locales"; import { Dhis2EventsDefaultRepository } from "../../../../data/repositories/Dhis2EventsDefaultRepository"; import { Future, FutureData } from "../../../entities/Future"; import { ConsistencyError } from "../../../entities/data-entry/ImportSummary"; @@ -8,6 +7,7 @@ import { MetadataRepository } from "../../../repositories/MetadataRepository"; import { AMC_RAW_SUBSTANCE_CONSUMPTION_PROGRAM_ID } from "../amc/ImportAMCSubstanceLevelData"; import { EGASP_PROGRAM_ID } from "../../../../data/repositories/program-rule/ProgramRulesMetadataDefaultRepository"; import { validateAtcVersion } from "../../../entities/GlassAtcVersionData"; +import i18n from "../../../../locales"; const EGASP_DATAELEMENT_ID = "KaS2YBRN8eH"; const PATIENT_DATAELEMENT_ID = "aocFHBxcQa0"; diff --git a/src/domain/usecases/data-entry/egasp/DeleteEGASPDataset.ts b/src/domain/usecases/data-entry/egasp/DeleteEGASPDataset.ts new file mode 100644 index 00000000..9e631d22 --- /dev/null +++ b/src/domain/usecases/data-entry/egasp/DeleteEGASPDataset.ts @@ -0,0 +1,40 @@ +import { Dhis2EventsDefaultRepository } from "../../../../data/repositories/Dhis2EventsDefaultRepository"; +import { FutureData } from "../../../entities/Future"; +import { ImportSummary } from "../../../entities/data-entry/ImportSummary"; +import { ExcelRepository } from "../../../repositories/ExcelRepository"; +import { GlassDocumentsRepository } from "../../../repositories/GlassDocumentsRepository"; +import { MetadataRepository } from "../../../repositories/MetadataRepository"; +import { EGASP_PROGRAM_ID } from "../../../../data/repositories/program-rule/ProgramRulesMetadataDefaultRepository"; +import { InstanceRepository } from "../../../repositories/InstanceRepository"; +import { DeleteBLTemplateEventProgram } from "../DeleteBLTemplateEventProgram"; +import { GlassUploads } from "../../../entities/GlassUploads"; +import { GlassUploadsRepository } from "../../../repositories/GlassUploadsRepository"; +import { TrackerRepository } from "../../../repositories/TrackerRepository"; + +export class DeleteEGASPDataset { + constructor( + private options: { + dhis2EventsDefaultRepository: Dhis2EventsDefaultRepository; + excelRepository: ExcelRepository; + glassDocumentsRepository: GlassDocumentsRepository; + metadataRepository: MetadataRepository; + instanceRepository: InstanceRepository; + glassUploadsRepository: GlassUploadsRepository; + trackerRepository: TrackerRepository; + } + ) {} + + public delete(arrayBuffer: ArrayBuffer, upload: GlassUploads): FutureData { + const deleteBLTemplateEventProgram = new DeleteBLTemplateEventProgram( + this.options.excelRepository, + this.options.instanceRepository, + this.options.glassDocumentsRepository, + this.options.dhis2EventsDefaultRepository, + this.options.metadataRepository, + this.options.glassUploadsRepository, + this.options.trackerRepository + ); + + return deleteBLTemplateEventProgram.delete(arrayBuffer, EGASP_PROGRAM_ID, upload); + } +} diff --git a/src/domain/usecases/data-entry/egasp/ImportEGASPFile.ts b/src/domain/usecases/data-entry/egasp/ImportEGASPFile.ts index 393524c4..48811830 100644 --- a/src/domain/usecases/data-entry/egasp/ImportEGASPFile.ts +++ b/src/domain/usecases/data-entry/egasp/ImportEGASPFile.ts @@ -8,9 +8,9 @@ import { GlassUploadsRepository } from "../../../repositories/GlassUploadsReposi import { ProgramRulesMetadataRepository } from "../../../repositories/program-rules/ProgramRulesMetadataRepository"; import { MetadataRepository } from "../../../repositories/MetadataRepository"; import { EGASP_PROGRAM_ID } from "../../../../data/repositories/program-rule/ProgramRulesMetadataDefaultRepository"; -import { InstanceDefaultRepository } from "../../../../data/repositories/InstanceDefaultRepository"; import { ImportBLTemplateEventProgram } from "../ImportBLTemplateEventProgram"; -import { GlassATCDefaultRepository } from "../../../../data/repositories/GlassATCDefaultRepository"; +import { InstanceRepository } from "../../../repositories/InstanceRepository"; +import { GlassATCRepository } from "../../../repositories/GlassATCRepository"; export class ImportEGASPFile { constructor( @@ -21,8 +21,8 @@ export class ImportEGASPFile { private glassUploadsRepository: GlassUploadsRepository, private programRulesMetadataRepository: ProgramRulesMetadataRepository, private metadataRepository: MetadataRepository, - private instanceRepository: InstanceDefaultRepository, - private glassAtcRepository: GlassATCDefaultRepository + private instanceRepository: InstanceRepository, + private glassAtcRepository: GlassATCRepository ) {} public importEGASPFile( diff --git a/src/domain/usecases/data-entry/utils/downloadIdsAndDeleteTrackedEntities.ts b/src/domain/usecases/data-entry/utils/downloadIdsAndDeleteTrackedEntities.ts index 71e1fe42..6182cfe1 100644 --- a/src/domain/usecases/data-entry/utils/downloadIdsAndDeleteTrackedEntities.ts +++ b/src/domain/usecases/data-entry/utils/downloadIdsAndDeleteTrackedEntities.ts @@ -5,8 +5,11 @@ import { ImportSummary } from "../../../entities/data-entry/ImportSummary"; import { GlassDocumentsRepository } from "../../../repositories/GlassDocumentsRepository"; import { MetadataRepository } from "../../../repositories/MetadataRepository"; import { TrackerRepository } from "../../../repositories/TrackerRepository"; -import { getStringFromFile } from "./fileToString"; +import { getStringFromFile, getStringFromFileBlob } from "./fileToString"; import { mapToImportSummary } from "../ImportBLTemplateEventProgram"; +import { Id } from "../../../entities/Ref"; +import { GlassUploadsRepository } from "../../../repositories/GlassUploadsRepository"; +import { GlassUploads } from "../../../entities/GlassUploads"; export const downloadIdsAndDeleteTrackedEntities = ( eventListId: string | undefined, @@ -54,3 +57,87 @@ export const downloadIdsAndDeleteTrackedEntities = ( return Future.success(summary); } }; + +export const downloadIdsAndDeleteTrackedEntitiesUsingFileBlob = ( + upload: GlassUploads, + programId: Id, + action: ImportStrategy, + trackedEntityType: string, + glassDocumentsRepository: GlassDocumentsRepository, + trackerRepository: TrackerRepository, + metadataRepository: MetadataRepository, + glassUploadsRepository: GlassUploadsRepository +): FutureData => { + const { id: uploadId, orgUnit: orgUnitId, eventListFileId } = upload; + if (eventListFileId && !upload.eventListDataDeleted) { + return glassDocumentsRepository.download(eventListFileId).flatMap(fileBlob => { + return getStringFromFileBlob(fileBlob).flatMap(_trackedEntities => { + const trackedEntitiesIdList: Id[] = JSON.parse(_trackedEntities); + + return trackerRepository + .getExistingTrackedEntitiesIdsByIds(trackedEntitiesIdList, programId) + .flatMap(existingTrackedEntitiesIds => { + if (existingTrackedEntitiesIds.length === 0) { + return glassUploadsRepository.setEventListDataDeleted(uploadId).flatMap(() => { + const summary: ImportSummary = { + status: "SUCCESS", + importCount: { + ignored: 0, + imported: 0, + deleted: 0, + updated: 0, + }, + nonBlockingErrors: [], + blockingErrors: [], + }; + return Future.success(summary); + }); + } + + const trackedEntities = existingTrackedEntitiesIds.map(id => { + const trackedEntity: D2TrackerTrackedEntity = { + orgUnit: orgUnitId, + trackedEntity: id, + trackedEntityType: trackedEntityType, + }; + return trackedEntity; + }); + + return trackerRepository + .import({ trackedEntities: trackedEntities }, action) + .flatMap(response => { + return mapToImportSummary(response, "trackedEntity", metadataRepository).flatMap( + ({ importSummary }) => { + if (importSummary.status === "SUCCESS") { + return glassUploadsRepository + .setEventListDataDeleted(uploadId) + .flatMap(() => { + return Future.success(importSummary); + }); + } else { + return Future.success(importSummary); + } + } + ); + }); + }); + }); + }); + } else { + //No enrollments were created during import, so no events to delete. + return glassUploadsRepository.setEventListDataDeleted(uploadId).flatMap(() => { + const summary: ImportSummary = { + status: "SUCCESS", + importCount: { + ignored: 0, + imported: 0, + deleted: 0, + updated: 0, + }, + nonBlockingErrors: [], + blockingErrors: [], + }; + return Future.success(summary); + }); + } +}; diff --git a/src/domain/usecases/data-entry/utils/fileToString.ts b/src/domain/usecases/data-entry/utils/fileToString.ts index 977db89a..6c4bbb1e 100644 --- a/src/domain/usecases/data-entry/utils/fileToString.ts +++ b/src/domain/usecases/data-entry/utils/fileToString.ts @@ -1,3 +1,5 @@ +import { Future, FutureData } from "../../../entities/Future"; + export const getStringFromFile = (file: Blob): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -6,3 +8,7 @@ export const getStringFromFile = (file: Blob): Promise => { reader.onerror = error => reject(error); }); }; + +export const getStringFromFileBlob = (fileBlob: Blob): FutureData => { + return Future.fromPromise(fileBlob.text()); +}; diff --git a/src/domain/usecases/data-entry/utils/mapDhis2Summary.ts b/src/domain/usecases/data-entry/utils/mapDhis2Summary.ts index 6a487f1d..25c14365 100644 --- a/src/domain/usecases/data-entry/utils/mapDhis2Summary.ts +++ b/src/domain/usecases/data-entry/utils/mapDhis2Summary.ts @@ -1,4 +1,6 @@ -import i18n from "@eyeseetea/d2-ui-components/locales"; +import _ from "lodash"; + +import i18n from "../../../../locales"; import { DataValuesSaveSummary, ImportStrategy } from "../../../entities/data-entry/DataValuesSaveSummary"; import { ImportSummary } from "../../../entities/data-entry/ImportSummary"; diff --git a/src/domain/utils/ExcelReader.ts b/src/domain/utils/ExcelReader.ts index d8dc11a7..24d8043d 100644 --- a/src/domain/utils/ExcelReader.ts +++ b/src/domain/utils/ExcelReader.ts @@ -16,6 +16,7 @@ import { CellDataSource, } from "../entities/Template"; import { ExcelRepository, ExcelValue, ReadCellOptions } from "../repositories/ExcelRepository"; +import _ from "lodash"; import { promiseMap } from "../../utils/promises"; import moment from "moment"; diff --git a/src/domain/utils/ModuleProperties.ts b/src/domain/utils/ModuleProperties.ts index 25297039..ca848925 100644 --- a/src/domain/utils/ModuleProperties.ts +++ b/src/domain/utils/ModuleProperties.ts @@ -1,4 +1,4 @@ -type ModuleDetails = { +export type ModuleDetails = { isbatchReq: boolean; isQuestionnaireReq: boolean; completeStatusChange: "QUESTIONNAIRE" | "DATASET" | "QUESTIONNAIRE_AND_DATASET"; @@ -33,11 +33,26 @@ type ModuleDetails = { submittedDownloadLabel?: string; calculatedProductDownloadLabel?: string; calculatedSubstanceFileDownloadLabel?: string; + hasAsyncDeletion?: boolean; }; +export const MODULE_NAMES = { + AMC: "AMC", + AMR: "AMR", + EGASP: "EGASP", + AMR_INDIVIDUAL: "AMR - Individual", + AMR_FUNGAL: "AMR - Fungal", +} as const; + +export type GlassModuleName = typeof MODULE_NAMES[keyof typeof MODULE_NAMES]; + +export function isGlassModuleName(value: unknown): value is GlassModuleName { + return Object.values(MODULE_NAMES).includes(value as GlassModuleName); +} + export const moduleProperties = new Map([ [ - "AMR", + MODULE_NAMES.AMR, { isbatchReq: true, isQuestionnaireReq: true, @@ -65,10 +80,11 @@ export const moduleProperties = new Map([ datasetString: "Upto 6 datasets", isDatasetMandatory: false, autoGenerateDataSubmission: true, + hasAsyncDeletion: true, }, ], [ - "EGASP", + MODULE_NAMES.EGASP, { isbatchReq: false, isQuestionnaireReq: false, @@ -93,10 +109,11 @@ export const moduleProperties = new Map([ isDatasetMandatory: true, autoGenerateDataSubmission: true, downloadAllDataButtonReq: true, + hasAsyncDeletion: true, }, ], [ - "AMR - Individual", + MODULE_NAMES.AMR_INDIVIDUAL, { isbatchReq: false, isQuestionnaireReq: true, @@ -123,14 +140,15 @@ export const moduleProperties = new Map([ autoGenerateDataSubmission: true, downloadAllDataButtonReq: true, isExternalSecondaryFile: true, + hasAsyncDeletion: true, }, ], [ - "AMC", + MODULE_NAMES.AMC, { isbatchReq: false, isQuestionnaireReq: true, - completeStatusChange: "QUESTIONNAIRE_AND_DATASET", + completeStatusChange: "DATASET", isSecondaryFileApplicable: true, isDryRunReq: false, importLoadingMsg: { @@ -148,7 +166,7 @@ export const moduleProperties = new Map([ unit: "event", isSpecimenReq: false, isDownloadEmptyTemplateReq: true, - applyQuestionnaireValidation: true, + applyQuestionnaireValidation: false, isSingleFileTypePerSubmission: true, isDatasetMandatory: true, autoGenerateDataSubmission: true, @@ -159,10 +177,11 @@ export const moduleProperties = new Map([ calculatedSubstanceFileDownloadLabel: "Download calculated substance data", downloadAllDataButtonReq: true, isMultiDashboard: true, + hasAsyncDeletion: true, }, ], [ - "AMR - Fungal", + MODULE_NAMES.AMR_FUNGAL, { isbatchReq: false, isQuestionnaireReq: true, @@ -187,6 +206,7 @@ export const moduleProperties = new Map([ isDatasetMandatory: true, autoGenerateDataSubmission: true, downloadAllDataButtonReq: true, + hasAsyncDeletion: true, }, ], ]); diff --git a/src/scripts/cliAsyncDeletions.ts b/src/scripts/cliAsyncDeletions.ts new file mode 100644 index 00000000..0de99ae5 --- /dev/null +++ b/src/scripts/cliAsyncDeletions.ts @@ -0,0 +1,524 @@ +import { command, run } from "cmd-ts"; +import "dotenv/config"; + +import { getD2ApiFromArgs, getInstance } from "./common"; +import { DataStoreClient } from "../data/data-store/DataStoreClient"; +import { Id } from "../domain/entities/Ref"; +import { GetAsyncDeletionsUseCase } from "../domain/usecases/GetAsyncDeletionsUseCase"; +import { GlassUploadsRepository } from "../domain/repositories/GlassUploadsRepository"; +import { GlassUploadsDefaultRepository } from "../data/repositories/GlassUploadsDefaultRepository"; +import { GetGlassUploadsUseCase } from "../domain/usecases/GetGlassUploadsUseCase"; +import { Future, FutureData } from "../domain/entities/Future"; +import { GlassModule } from "../domain/entities/GlassModule"; +import { GlassModuleRepository } from "../domain/repositories/GlassModuleRepository"; +import { GlassUploads } from "../domain/entities/GlassUploads"; +import { GlassModuleDefaultRepository } from "../data/repositories/GlassModuleDefaultRepository"; +import { GlassModuleName, isGlassModuleName, moduleProperties } from "../domain/utils/ModuleProperties"; +import { getPrimaryAndSecondaryFilesToDelete } from "../webapp/utils/getPrimaryAndSecondaryFilesToDelete"; +import { GlassDocumentsRepository } from "../domain/repositories/GlassDocumentsRepository"; +import { GlassDocumentsDefaultRepository } from "../data/repositories/GlassDocumentsDefaultRepository"; +import { ImportSummary } from "../domain/entities/data-entry/ImportSummary"; +import { RISDataCSVDefaultRepository } from "../data/repositories/data-entry/RISDataCSVDefaultRepository"; +import { RISIndividualFungalDataCSVDefaultRepository } from "../data/repositories/data-entry/RISIndividualFungalDataCSVDefaultRepository"; +import { SampleDataCSVDeafultRepository } from "../data/repositories/data-entry/SampleDataCSVDeafultRepository"; +import { RISDataRepository } from "../domain/repositories/data-entry/RISDataRepository"; +import { RISIndividualFungalDataRepository } from "../domain/repositories/data-entry/RISIndividualFungalDataRepository"; +import { MetadataRepository } from "../domain/repositories/MetadataRepository"; +import { DataValuesRepository } from "../domain/repositories/data-entry/DataValuesRepository"; +import { Dhis2EventsDefaultRepository } from "../data/repositories/Dhis2EventsDefaultRepository"; +import { ExcelRepository } from "../domain/repositories/ExcelRepository"; +import { TrackerRepository } from "../domain/repositories/TrackerRepository"; +import { InstanceDefaultRepository } from "../data/repositories/InstanceDefaultRepository"; +import { ProgramRulesMetadataRepository } from "../domain/repositories/program-rules/ProgramRulesMetadataRepository"; +import { GlassATCDefaultRepository } from "../data/repositories/GlassATCDefaultRepository"; +import { AMCProductDataRepository } from "../domain/repositories/data-entry/AMCProductDataRepository"; +import { AMCSubstanceDataRepository } from "../domain/repositories/data-entry/AMCSubstanceDataRepository"; +import { InstanceRepository } from "../domain/repositories/InstanceRepository"; +import { GlassATCRepository } from "../domain/repositories/GlassATCRepository"; +import { SampleDataRepository } from "../domain/repositories/data-entry/SampleDataRepository"; +import { AMCProductDataDefaultRepository } from "../data/repositories/data-entry/AMCProductDataDefaultRepository"; +import { AMCSubstanceDataDefaultRepository } from "../data/repositories/data-entry/AMCSubstanceDataDefaultRepository"; +import { DataValuesDefaultRepository } from "../data/repositories/data-entry/DataValuesDefaultRepository"; +import { ExcelPopulateDefaultRepository } from "../data/repositories/ExcelPopulateDefaultRepository"; +import { MetadataDefaultRepository } from "../data/repositories/MetadataDefaultRepository"; +import { ProgramRulesMetadataDefaultRepository } from "../data/repositories/program-rule/ProgramRulesMetadataDefaultRepository"; +import { TrackerDefaultRepository } from "../data/repositories/TrackerDefaultRepository"; +import { DeleteDocumentInfoByUploadIdUseCase } from "../domain/usecases/DeleteDocumentInfoByUploadIdUseCase"; +import { RemoveAsyncDeletionsUseCase } from "../domain/usecases/RemoveAsyncDeletionsUseCase"; +import { SendNotificationsUseCase } from "../domain/usecases/SendNotificationsUseCase"; +import { NotificationRepository } from "../domain/repositories/NotificationRepository"; +import { UsersRepository } from "../domain/repositories/UsersRepository"; +import { DeletePrimaryFileDataUseCase } from "../domain/usecases/data-entry/DeletePrimaryFileDataUseCase"; +import { DeleteSecondaryFileDataUseCase } from "../domain/usecases/data-entry/DeleteSecondaryFileDataUseCase"; +import { DownloadDocumentAsArrayBufferUseCase } from "../domain/usecases/DownloadDocumentAsArrayBufferUseCase"; + +const UPLOADED_FILE_STATUS_LOWERCASE = "uploaded"; +const IMPORT_SUMMARY_STATUS_ERROR = "ERROR"; + +async function main() { + const cmd = command({ + name: "Async deletions of uploaded files", + description: + "This script takes the ids of uploaded files that are in async-deletions in Datastore and deletes them", + args: {}, + handler: async () => { + try { + if (!process.env.REACT_APP_DHIS2_BASE_URL) + throw new Error("REACT_APP_DHIS2_BASE_URL must be set in the .env file"); + + if (!process.env.REACT_APP_DHIS2_AUTH) + throw new Error("REACT_APP_DHIS2_AUTH must be set in the .env file"); + + const username = process.env.REACT_APP_DHIS2_AUTH.split(":")[0] ?? ""; + const password = process.env.REACT_APP_DHIS2_AUTH.split(":")[1] ?? ""; + + if (username === "" || password === "") { + throw new Error("REACT_APP_DHIS2_AUTH must be in the format 'username:password'"); + } + const envVars = { + url: process.env.REACT_APP_DHIS2_BASE_URL, + auth: { + username: username, + password: password, + }, + }; + + const api = getD2ApiFromArgs(envVars); + const instance = getInstance(envVars); + const dataStoreClient = new DataStoreClient(instance); + + const instanceRepository = new InstanceDefaultRepository(instance, dataStoreClient); + const glassModuleRepository = new GlassModuleDefaultRepository(dataStoreClient); + const glassUploadsRepository = new GlassUploadsDefaultRepository(dataStoreClient); + const glassDocumentsRepository = new GlassDocumentsDefaultRepository(dataStoreClient, instance); + const risDataRepository = new RISDataCSVDefaultRepository(); + const risIndividualFungalRepository = new RISIndividualFungalDataCSVDefaultRepository(); + const sampleDataRepository = new SampleDataCSVDeafultRepository(); + const dataValuesRepository = new DataValuesDefaultRepository(instance); + const metadataRepository = new MetadataDefaultRepository(instance); + const dhis2EventsDefaultRepository = new Dhis2EventsDefaultRepository(instance); + const excelRepository = new ExcelPopulateDefaultRepository(); + const programRulesMetadataDefaultRepository = new ProgramRulesMetadataDefaultRepository(instance); + const trackerRepository = new TrackerDefaultRepository(instance); + const amcProductDataRepository = new AMCProductDataDefaultRepository(api); + const amcSubstanceDataRepository = new AMCSubstanceDataDefaultRepository(api); + const glassAtcRepository = new GlassATCDefaultRepository(dataStoreClient); + const atcRepository = new GlassATCDefaultRepository(dataStoreClient); + + console.debug(`Running asynchronous deletion for URL ${envVars.url}`); + + return getAsyncDeletionsFromDatastore(glassUploadsRepository).run( + uploadIdsToDelete => { + if (uploadIdsToDelete && uploadIdsToDelete.length > 0) { + console.debug( + `There are ${uploadIdsToDelete.length} uploaded datasets marked for deletion` + ); + return Future.joinObj({ + glassModules: getGlassModulesFromDatastore(glassModuleRepository), + allUploads: getGlassUploadsDatastore(glassUploadsRepository), + }).run( + ({ glassModules, allUploads }) => { + const uploadsToDelete: GlassUploadsWithModuleName[] = allUploads + .filter(upload => uploadIdsToDelete.includes(upload.id)) + .map(upload => { + const moduleName = glassModules.find( + module => module.id === upload.module + )?.name; + + if (!isGlassModuleName(moduleName)) { + console.error(`Module name not found for upload ${upload.id}`); + throw new Error(`Module name not found for upload ${upload.id}`); + } + + return { + ...upload, + moduleName: moduleName, + }; + }); + + if (uploadsToDelete.length === 0) { + console.error(`ERROR - Uploads to delete not found in Datastore`); + return Future.error(`Uploads to delete not found in Datastore`); + } + + return deleteUploadedDatasets(uploadsToDelete, allUploads, glassModules, { + sampleDataRepository, + metadataRepository, + dataValuesRepository, + excelRepository, + instanceRepository, + glassDocumentsRepository, + glassUploadsRepository, + dhis2EventsDefaultRepository, + programRulesMetadataRepository: programRulesMetadataDefaultRepository, + glassAtcRepository, + risDataRepository, + risIndividualFungalRepository, + trackerRepository, + glassModuleRepository, + atcRepository, + amcProductRepository: amcProductDataRepository, + amcSubstanceDataRepository, + }).run( + () => { + console.debug( + `SUCCESS - Deleted all uploaded datasets marked for deletion: ${uploadIdsToDelete.length}` + ); + }, + error => { + console.error(`ERROR - An error occured while deleting: ${error}`); + } + ); + }, + error => + console.error( + `ERROR - Error while getting glass modules and all uploads from Datastore, and all countries: ${error}.` + ) + ); + } else { + console.debug(`There is nothing marked for deletion`); + } + }, + error => console.error(`ERROR - Error while getting async deletions from Datastore: ${error}.`) + ); + } catch (e) { + console.error(`Async deletions have stopped with error: ${e}. Please, restart again.`); + process.exit(1); + } + }, + }); + + run(cmd, process.argv.slice(2)); +} + +type GlassUploadsWithModuleName = GlassUploads & { moduleName: GlassModuleName }; + +function getAsyncDeletionsFromDatastore(glassUploadsRepository: GlassUploadsRepository): FutureData { + return new GetAsyncDeletionsUseCase(glassUploadsRepository).execute(); +} + +function removeAsyncDeletionsFromDatastore( + uploadIdsToRemove: Id[], + glassUploadsRepository: GlassUploadsRepository +): FutureData { + return new RemoveAsyncDeletionsUseCase(glassUploadsRepository).execute(uploadIdsToRemove); +} + +// TODO: send notification to users +function _sendNotification( + usergroupIds: Id[], + repositories: { + notificationRepository: NotificationRepository; + usersRepository: UsersRepository; + } +): FutureData { + const { notificationRepository, usersRepository } = repositories; + const notificationText = `The datasets marked for deletion have been successfully deleted.`; + const notOrgUnitPath = ""; + return new SendNotificationsUseCase(notificationRepository, usersRepository).execute( + notificationText, + notificationText, + usergroupIds, + notOrgUnitPath + ); +} + +function getGlassModulesFromDatastore(glassModuleRepository: GlassModuleRepository): FutureData { + return glassModuleRepository.getAll(); +} + +function getGlassUploadsDatastore(glassUploadsRepository: GlassUploadsRepository): FutureData { + return new GetGlassUploadsUseCase(glassUploadsRepository).execute(); +} + +function getArrayBufferOfFile( + fileId: Id, + repositories: { glassDocumentsRepository: GlassDocumentsRepository } +): FutureData { + return new DownloadDocumentAsArrayBufferUseCase(repositories.glassDocumentsRepository).execute(fileId); +} + +function deleteUploadAndDocumentFromDatasoreAndDHIS2( + upload: GlassUploads, + repositories: { + glassDocumentsRepository: GlassDocumentsRepository; + glassUploadsRepository: GlassUploadsRepository; + } +): FutureData { + const { glassDocumentsRepository, glassUploadsRepository } = repositories; + return new DeleteDocumentInfoByUploadIdUseCase(glassDocumentsRepository, glassUploadsRepository) + .execute(upload.id) + .flatMap(() => { + console.debug(`Upload and document ${upload.fileName} deleted in Datastore and from DHIS2 documents`); + return Future.success(undefined); + }); +} + +function deleteDatasetValuesOrEventsFromPrimaryUploaded( + currentModule: GlassModule, + upload: GlassUploads, + arrayBuffer: ArrayBuffer, + repositories: { + risDataRepository: RISDataRepository; + metadataRepository: MetadataRepository; + dataValuesRepository: DataValuesRepository; + dhis2EventsDefaultRepository: Dhis2EventsDefaultRepository; + excelRepository: ExcelRepository; + glassDocumentsRepository: GlassDocumentsRepository; + instanceRepository: InstanceRepository; + glassUploadsRepository: GlassUploadsRepository; + trackerRepository: TrackerRepository; + amcSubstanceDataRepository: AMCSubstanceDataRepository; + } +): FutureData { + console.debug(`Deleting data from primary file ${upload.id}`); + return new DeletePrimaryFileDataUseCase(repositories).execute(currentModule, upload, arrayBuffer); +} + +function deleteDatasetValuesOrEventsFromSecondaryUploaded( + currentModule: GlassModule, + upload: GlassUploads, + arrayBuffer: ArrayBuffer, + repositories: { + sampleDataRepository: SampleDataRepository; + dataValuesRepository: DataValuesRepository; + dhis2EventsDefaultRepository: Dhis2EventsDefaultRepository; + excelRepository: ExcelRepository; + glassDocumentsRepository: GlassDocumentsRepository; + metadataRepository: MetadataRepository; + instanceRepository: InstanceRepository; + glassUploadsRepository: GlassUploadsRepository; + trackerRepository: TrackerRepository; + } +): FutureData { + console.debug(`Deleting data from secondary file ${upload.id}`); + return new DeleteSecondaryFileDataUseCase(repositories).execute(currentModule, upload, arrayBuffer); +} + +function deleteDatasetValuesOrEvents( + primaryFileToDelete: GlassUploads | undefined, + secondaryFileToDelete: GlassUploads | undefined, + primaryArrayBuffer: ArrayBuffer | undefined, + secondaryArrayBuffer: ArrayBuffer | undefined, + currentModule: GlassModule, + repositories: { + sampleDataRepository: SampleDataRepository; + metadataRepository: MetadataRepository; + dataValuesRepository: DataValuesRepository; + excelRepository: ExcelRepository; + instanceRepository: InstanceRepository; + glassDocumentsRepository: GlassDocumentsRepository; + glassUploadsRepository: GlassUploadsRepository; + dhis2EventsDefaultRepository: Dhis2EventsDefaultRepository; + programRulesMetadataRepository: ProgramRulesMetadataRepository; + glassAtcRepository: GlassATCRepository; + risDataRepository: RISDataRepository; + risIndividualFungalRepository: RISIndividualFungalDataRepository; + trackerRepository: TrackerRepository; + glassModuleRepository: GlassModuleRepository; + atcRepository: GlassATCRepository; + amcProductRepository: AMCProductDataRepository; + amcSubstanceDataRepository: AMCSubstanceDataRepository; + } +): FutureData<{ + deletePrimaryFileSummary: ImportSummary | undefined; + deleteSecondaryFileSummary: ImportSummary | undefined; +}> { + const { name: currentModuleName } = currentModule; + return Future.joinObj({ + deletePrimaryFileSummary: + primaryArrayBuffer && + primaryFileToDelete && + (primaryFileToDelete.status.toLowerCase() !== UPLOADED_FILE_STATUS_LOWERCASE || + !moduleProperties.get(currentModuleName)?.isDryRunReq) + ? deleteDatasetValuesOrEventsFromPrimaryUploaded( + currentModule, + primaryFileToDelete, + primaryArrayBuffer, + repositories + ) + : Future.success(undefined), + deleteSecondaryFileSummary: + secondaryArrayBuffer && + secondaryFileToDelete && + secondaryFileToDelete.status.toLowerCase() !== UPLOADED_FILE_STATUS_LOWERCASE + ? deleteDatasetValuesOrEventsFromSecondaryUploaded( + currentModule, + secondaryFileToDelete, + secondaryArrayBuffer, + repositories + ) + : Future.success(undefined), + }); +} + +// Deleting a uploaded dataset completely has the following steps: +// 1. Delete corresponsding datasetValue/event for each row in the file if the file has status different than "uploaded" +// 2. Delete corresponding 'upload' and 'document' from Datastore, and delete corresponding document from DHIS2 +function deleteUploadedDatasets( + uploadsToDelete: GlassUploadsWithModuleName[], + allUploads: GlassUploads[], + glassModules: GlassModule[], + repositories: { + sampleDataRepository: SampleDataRepository; + metadataRepository: MetadataRepository; + dataValuesRepository: DataValuesRepository; + excelRepository: ExcelRepository; + instanceRepository: InstanceRepository; + glassDocumentsRepository: GlassDocumentsRepository; + glassUploadsRepository: GlassUploadsRepository; + dhis2EventsDefaultRepository: Dhis2EventsDefaultRepository; + programRulesMetadataRepository: ProgramRulesMetadataRepository; + glassAtcRepository: GlassATCRepository; + risDataRepository: RISDataRepository; + risIndividualFungalRepository: RISIndividualFungalDataRepository; + trackerRepository: TrackerRepository; + glassModuleRepository: GlassModuleRepository; + atcRepository: GlassATCRepository; + amcProductRepository: AMCProductDataRepository; + amcSubstanceDataRepository: AMCSubstanceDataRepository; + } +): FutureData { + return Future.sequential( + uploadsToDelete.map(uploadToDelete => { + return Future.fromPromise(new Promise(resolve => setTimeout(resolve, 500))).flatMap(() => { + const { primaryFileToDelete, secondaryFileToDelete } = getPrimaryAndSecondaryFilesToDelete( + uploadToDelete, + moduleProperties, + uploadToDelete.moduleName, + allUploads + ); + + const currentModule = glassModules.find(module => module.name === uploadToDelete.moduleName); + if (!currentModule) { + return Future.error(`Module ${uploadToDelete.moduleName} not found`); + } + + return Future.joinObj({ + primaryArrayBuffer: primaryFileToDelete + ? getArrayBufferOfFile(primaryFileToDelete.fileId, repositories) + : Future.success(undefined), + secondaryArrayBuffer: secondaryFileToDelete + ? getArrayBufferOfFile(secondaryFileToDelete.fileId, repositories) + : Future.success(undefined), + }).flatMap(({ primaryArrayBuffer, secondaryArrayBuffer }) => { + if (primaryFileToDelete && primaryArrayBuffer) { + return deleteDatasetValuesOrEvents( + primaryFileToDelete, + secondaryFileToDelete, + primaryArrayBuffer, + secondaryArrayBuffer, + currentModule, + repositories + ).flatMap(({ deletePrimaryFileSummary, deleteSecondaryFileSummary }) => { + if ( + deletePrimaryFileSummary?.status === IMPORT_SUMMARY_STATUS_ERROR || + deleteSecondaryFileSummary?.status === IMPORT_SUMMARY_STATUS_ERROR + ) { + return Future.error( + `An error occured while deleting the data exiting. Primary file: ${primaryFileToDelete.fileName}, secondary file: ${secondaryFileToDelete?.fileName}` + ); + } + + if (deletePrimaryFileSummary) { + console.debug(`Data from primary file ${primaryFileToDelete.fileName} deleted`); + } + + if (secondaryFileToDelete && deleteSecondaryFileSummary) { + console.debug(`Data from secondary file ${secondaryFileToDelete.fileName} deleted`); + } + + return deleteUploadAndDocumentFromDatasoreAndDHIS2( + primaryFileToDelete, + repositories + ).flatMap(() => { + if (secondaryFileToDelete) { + return deleteUploadAndDocumentFromDatasoreAndDHIS2( + secondaryFileToDelete, + repositories + ).flatMap(() => { + return removeAsyncDeletionsFromDatastore( + [primaryFileToDelete.id], + repositories.glassUploadsRepository + ).flatMap(() => { + console.debug( + `SUCCESS - Deleted async-deletions id from Datastore ${primaryFileToDelete.id}` + ); + return Future.success(undefined); + }); + }); + } else { + return removeAsyncDeletionsFromDatastore( + [primaryFileToDelete.id], + repositories.glassUploadsRepository + ).flatMap(() => { + console.debug( + `SUCCESS - Deleted async-deletions id from Datastore ${primaryFileToDelete.id}` + ); + return Future.success(undefined); + }); + } + }); + }); + } else if (secondaryFileToDelete && secondaryArrayBuffer) { + if (secondaryFileToDelete.status.toLowerCase() !== UPLOADED_FILE_STATUS_LOWERCASE) { + console.debug("Delete only secondary uploaded dataset"); + return deleteDatasetValuesOrEventsFromSecondaryUploaded( + currentModule, + secondaryFileToDelete, + secondaryArrayBuffer, + repositories + ).flatMap(deleteSecondaryFileSummary => { + if ( + deleteSecondaryFileSummary && + deleteSecondaryFileSummary.status !== IMPORT_SUMMARY_STATUS_ERROR + ) { + console.debug(`Data from secondary file ${secondaryFileToDelete.fileName} deleted`); + return deleteUploadAndDocumentFromDatasoreAndDHIS2( + secondaryFileToDelete, + repositories + ).flatMap(() => { + return removeAsyncDeletionsFromDatastore( + [secondaryFileToDelete.id], + repositories.glassUploadsRepository + ).flatMap(() => { + console.debug( + `SUCCESS - Deleted async-deletions id from Datastore ${secondaryFileToDelete.id}` + ); + return Future.success(undefined); + }); + }); + } else { + return Future.error( + `An error occured while deleting the data exiting. Secondary file: ${secondaryFileToDelete?.fileName}` + ); + } + }); + } else { + return deleteUploadAndDocumentFromDatasoreAndDHIS2( + secondaryFileToDelete, + repositories + ).flatMap(() => { + return removeAsyncDeletionsFromDatastore( + [secondaryFileToDelete.id], + repositories.glassUploadsRepository + ).flatMap(() => { + console.debug( + `SUCCESS - Deleted async-deletions id from Datastore ${secondaryFileToDelete.id}` + ); + return Future.success(undefined); + }); + }); + } + } else { + return Future.error( + `An error occured while deleting file, file not found. Upload selected to delete: ${uploadToDelete.id}` + ); + } + }); + }); + }) + ).toVoid(); +} + +main(); diff --git a/src/webapp/components/current-data-submission/ListOfDatasets.tsx b/src/webapp/components/current-data-submission/ListOfDatasets.tsx index 4050dc77..f88003fb 100644 --- a/src/webapp/components/current-data-submission/ListOfDatasets.tsx +++ b/src/webapp/components/current-data-submission/ListOfDatasets.tsx @@ -41,6 +41,12 @@ function getNotCompletedUploads(upload: GlassUploadsState) { } } +function getImportedUploads(upload: GlassUploadsState) { + if (upload.kind === "loaded") { + return upload.data.filter((row: UploadsDataItem) => row.status.toLowerCase() === "imported"); + } +} + interface ListOfDatasetsProps { setRefetchStatus: Dispatch>; } @@ -64,6 +70,7 @@ export const ListOfDatasets: React.FC = ({ setRefetchStatus const completeUploads = getCompletedUploads(uploads); const validatedUploads = getValidatedUploads(uploads); const incompleteUploads = getNotCompletedUploads(uploads); + const importedUploads = getImportedUploads(uploads); const dataSubmissionId = useCurrentDataSubmissionId( moduleId, @@ -72,9 +79,16 @@ export const ListOfDatasets: React.FC = ({ setRefetchStatus currentPeriod ); const { captureAccessGroup } = useCurrentUserGroupsAccess(); + const [isDatasetMarkAsCompleted, setIsDatasetMarkAsCompleted] = React.useState(false); + useEffect(() => { if ( + uploads.kind === "loaded" && + dataSubmissionId && completeUploads?.length === 0 && + !isDatasetMarkAsCompleted && + currentDataSubmissionStatus.kind === "loaded" && + currentDataSubmissionStatus.data.status !== "NOT_COMPLETED" && (moduleProperties.get(moduleName)?.completeStatusChange === "DATASET" || moduleProperties.get(moduleName)?.completeStatusChange === "QUESTIONNAIRE_AND_DATASET") ) { @@ -95,6 +109,9 @@ export const ListOfDatasets: React.FC = ({ setRefetchStatus currentOrgUnitAccess, currentPeriod, dataSubmissionId, + isDatasetMarkAsCompleted, + uploads.kind, + currentDataSubmissionStatus, setRefetchStatus, questionnaires, ]); @@ -125,9 +142,17 @@ export const ListOfDatasets: React.FC = ({ setRefetchStatus {moduleProperties.get(moduleName)?.isSingleFileTypePerSubmission && completeUploads && - completeUploads.length > 0 ? ( + ((moduleName === "AMC" && + ((validatedUploads || []).length > 0 || + (importedUploads || []).length > 0 || + completeUploads.length > 0)) || + (moduleName !== "AMC" && completeUploads.length > 0)) ? ( - {i18n.t(`You can upload only one successful file for ${moduleName}.`)} + {moduleName === "AMC" + ? i18n.t( + `You can upload data from only one file for ${moduleName}. Please, delete all COMPLETED, VALIDATED or IMPORTED files to upload a new one.` + ) + : i18n.t(`You can upload only one successful file for ${moduleName}.`)} ) : ( - {currentDataSubmissionStatus.kind === "loaded" ? ( + {currentDataSubmissionStatus.kind === "loaded" && + asyncDeletionsState.kind === "loaded" ? ( ) : ( diff --git a/src/webapp/components/current-data-submission/overview/StatusDetails.ts b/src/webapp/components/current-data-submission/overview/StatusDetails.ts index 082c38bc..bb589d7c 100644 --- a/src/webapp/components/current-data-submission/overview/StatusDetails.ts +++ b/src/webapp/components/current-data-submission/overview/StatusDetails.ts @@ -1,3 +1,5 @@ +import { DataSubmissionStatusTypes } from "../../../../domain/entities/GlassDataSubmission"; + export type StatusCTAs = | "Go to questionnaires" | "Display full status history" @@ -26,4 +28,5 @@ export interface StatusDetails { isActionRequired: boolean; actionReqText: string; isSubmissionStatus: boolean; + status: DataSubmissionStatusTypes; } diff --git a/src/webapp/components/questionnaire/QuestionRow.tsx b/src/webapp/components/questionnaire/QuestionRow.tsx index 2700fc02..d11012af 100644 --- a/src/webapp/components/questionnaire/QuestionRow.tsx +++ b/src/webapp/components/questionnaire/QuestionRow.tsx @@ -1,10 +1,8 @@ -import { useSnackbar } from "@eyeseetea/d2-ui-components"; import { makeStyles } from "@material-ui/core"; import React from "react"; // @ts-ignore import { DataTableRow, DataTableCell } from "@dhis2/ui"; import { Question, QuestionnaireSelector } from "../../../domain/entities/Questionnaire"; -import { useAppContext } from "../../contexts/app-context"; import styled from "styled-components"; import { QuestionWidget } from "./QuestionInput"; @@ -12,20 +10,15 @@ export interface DataElementItemProps { selector?: QuestionnaireSelector; question: Question; disabled: boolean; - setQuestion(newQuestion: Question): void; + handleQuestionChange(newQuestion: Question): void; } const QuestionRow: React.FC = React.memo(props => { - const { question } = props; + const { question, handleQuestionChange: updateQuestionnaireState } = props; const classes = useStyles(); - const [saveState, setQuestionToSave] = useSaveActions(props); - - const updateQuestion = (question: Question) => { - setQuestionToSave(question); - }; return ( - + {question.text} @@ -33,52 +26,16 @@ const QuestionRow: React.FC = React.memo(props => {
- +
-
+ ); }); -function useSaveActions(options: DataElementItemProps) { - const { selector, setQuestion } = options; - - const snackbar = useSnackbar(); - const { compositionRoot } = useAppContext(); - - const [saveState, setSaveState] = React.useState("original"); - const [questionToSave, setQuestionToSave] = React.useState(); - - React.useEffect(() => { - if (!questionToSave) return; - - if (selector) { - setSaveState("saving"); - return compositionRoot.questionnaires.saveResponse(selector, questionToSave).run( - () => { - setSaveState("saveSuccessful"); - setQuestion(questionToSave); - }, - err => { - setSaveState("saveError"); - snackbar.error(err); - } - ); - } - }, [compositionRoot, snackbar, selector, setQuestion, questionToSave]); - - return [saveState, setQuestionToSave] as const; -} - -interface SavingFeedbackProps { - saveState: SaveState; -} - -const DataTableRowWithSavingFeedback = styled(DataTableRow)` +const StyledDataTableRow = styled(DataTableRow)` transition: background-color 0.5s; - background-color: ${(props: SavingFeedbackProps) => colorByState[props.saveState]}; - td { background-color: inherit !important; vertical-align: middle; @@ -90,13 +47,4 @@ const useStyles = makeStyles({ valueWrapper: { display: "flex" }, }); -type SaveState = "original" | "saving" | "saveSuccessful" | "saveError"; - -const colorByState: Record = { - original: "rgb(255, 255, 255)", - saving: "rgb(255, 255, 225)", - saveSuccessful: "rgb(255, 255, 255)", - saveError: "rgb(255, 225, 225)", -}; - export default React.memo(QuestionRow); diff --git a/src/webapp/components/questionnaire/QuestionnaireActions.tsx b/src/webapp/components/questionnaire/QuestionnaireActions.tsx index 5eb68143..733de844 100644 --- a/src/webapp/components/questionnaire/QuestionnaireActions.tsx +++ b/src/webapp/components/questionnaire/QuestionnaireActions.tsx @@ -12,10 +12,16 @@ interface QuestionnaireHeaderProps { isSaving: boolean; setAsCompleted: (isCompleted: boolean) => void; mode: QuestionnarieFormProps["mode"]; + saveQuestionnaireActions: { + saveQuestionnaire: () => void; + disableSave: boolean; + isSavingQuestionnaire: boolean; + }; } export const QuestionnaireActions: React.FC = props => { - const { description, isCompleted, isSaving, mode, setAsCompleted } = props; + const { description, isCompleted, isSaving, mode, saveQuestionnaireActions, setAsCompleted } = props; + const { saveQuestionnaire, disableSave, isSavingQuestionnaire } = saveQuestionnaireActions; const hasCurrentUserCaptureAccess = useGlassCaptureAccess(); return ( @@ -33,6 +39,16 @@ export const QuestionnaireActions: React.FC = props => {mode === "edit" && (
+ {isSavingQuestionnaire && } + + {i18n.t("Save Questionnaire")} + + {isSaving && } {isCompleted ? ( + + + {isRunningCalculation && } + )}
@@ -387,3 +446,9 @@ const StyledProgress = styled.div` justify-content: center; padding: 10px; `; + +const FlexWrapper = styled.div` + display: flex; + justify-content: center; + gap: 8px; +`; diff --git a/src/webapp/components/upload/UploadContent.tsx b/src/webapp/components/upload/UploadContent.tsx index fc7900b5..3ff41da8 100644 --- a/src/webapp/components/upload/UploadContent.tsx +++ b/src/webapp/components/upload/UploadContent.tsx @@ -31,6 +31,9 @@ export const UploadContent: React.FC = ({ resetWizard, setRe removeSecondaryFile, hasSecondaryFile, setHasSecondaryFile, + dataSubmissionId, + isRunningCalculation, + setIsRunningCalculation, } = useUploadContent(); const snackbar = useSnackbar(); @@ -97,7 +100,10 @@ export const UploadContent: React.FC = ({ resetWizard, setRe isLoadingPrimary, setIsLoadingPrimary, isLoadingSecondary, - setIsLoadingSecondary + setIsLoadingSecondary, + dataSubmissionId, + isRunningCalculation, + setIsRunningCalculation )} ); @@ -123,7 +129,10 @@ const renderStep = ( isLoadingPrimary: boolean, setIsLoadingPrimary: React.Dispatch>, isLoadingSecondary: boolean, - setIsLoadingSecondary: React.Dispatch> + setIsLoadingSecondary: React.Dispatch>, + dataSubmissionId: string | undefined, + isRunningCalculation: boolean, + setIsRunningCalculation: React.Dispatch> ) => { switch (step) { case 1: @@ -146,6 +155,7 @@ const renderStep = ( setIsLoadingPrimary={setIsLoadingPrimary} isLoadingSecondary={isLoadingSecondary} setIsLoadingSecondary={setIsLoadingSecondary} + dataSubmissionId={dataSubmissionId} /> ); case 2: @@ -160,6 +170,7 @@ const renderStep = ( secondaryFileImportSummary={secondaryFileImportSummary} setPrimaryFileImportSummary={setPrimaryFileImportSummary} setSecondaryFileImportSummary={setSecondaryFileImportSummary} + setIsRunningCalculation={setIsRunningCalculation} /> ); @@ -169,6 +180,9 @@ const renderStep = ( changeStep={changeStep} primaryFileImportSummary={primaryFileImportSummary} secondaryFileImportSummary={secondaryFileImportSummary} + isRunningCalculation={isRunningCalculation} + primaryFile={primaryFile} + secondaryFile={secondaryFile} /> ); case 4: diff --git a/src/webapp/components/upload/UploadFiles.tsx b/src/webapp/components/upload/UploadFiles.tsx index b59e389e..f4c1cd44 100644 --- a/src/webapp/components/upload/UploadFiles.tsx +++ b/src/webapp/components/upload/UploadFiles.tsx @@ -44,6 +44,7 @@ interface UploadFilesProps { setIsLoadingSecondary: React.Dispatch>; isLoadingSecondary: boolean; setIsLoadingPrimary: React.Dispatch>; + dataSubmissionId: string | undefined; } const UPLOADED_STATUS = "uploaded"; @@ -93,6 +94,7 @@ export const UploadFiles: React.FC = ({ setIsLoadingPrimary, isLoadingSecondary, setIsLoadingSecondary, + dataSubmissionId, }) => { const { compositionRoot, allCountries } = useAppContext(); const snackbar = useSnackbar(); @@ -297,10 +299,15 @@ export const UploadFiles: React.FC = ({ !currentModuleProperties?.isDryRunReq && importPrimaryFileSummary.blockingErrors.length === 0 ) - compositionRoot.glassUploads.setStatus({ id: primaryUploadId, status: "VALIDATED" }).run( - () => {}, - () => {} - ); + compositionRoot.glassUploads + .setStatus({ + id: primaryUploadId, + status: moduleName === "AMC" ? "IMPORTED" : "VALIDATED", + }) + .run( + () => {}, + () => {} + ); } setImportLoading(false); @@ -345,7 +352,10 @@ export const UploadFiles: React.FC = ({ secondaryUploadId ) compositionRoot.glassUploads - .setStatus({ id: secondaryUploadId, status: "VALIDATED" }) + .setStatus({ + id: secondaryUploadId, + status: moduleName === "AMC" ? "IMPORTED" : "VALIDATED", + }) .run( () => {}, () => {} @@ -460,7 +470,7 @@ export const UploadFiles: React.FC = ({ return ( - + {i18n.t("Loading")} @@ -518,6 +528,7 @@ export const UploadFiles: React.FC = ({ removePrimaryFile={removePrimaryFile} isLoading={isLoadingPrimary} setIsLoading={setIsLoadingPrimary} + dataSubmissionId={dataSubmissionId} /> ) : ( = ({ removeSecondaryFile={removeSecondaryFile} isLoading={isLoadingSecondary} setIsLoading={setIsLoadingSecondary} + dataSubmissionId={dataSubmissionId} /> )} @@ -556,6 +568,7 @@ export const UploadFiles: React.FC = ({ removePrimaryFile={removePrimaryFile} isLoading={isLoadingPrimary} setIsLoading={setIsLoadingPrimary} + dataSubmissionId={dataSubmissionId} /> {moduleProperties.get(moduleName)?.isSecondaryFileApplicable && ( = ({ removeSecondaryFile={removeSecondaryFile} isLoading={isLoadingSecondary} setIsLoading={setIsLoadingSecondary} + dataSubmissionId={dataSubmissionId} /> )} diff --git a/src/webapp/components/upload/UploadPrimaryFile.tsx b/src/webapp/components/upload/UploadPrimaryFile.tsx index f6bf14ae..0156adab 100644 --- a/src/webapp/components/upload/UploadPrimaryFile.tsx +++ b/src/webapp/components/upload/UploadPrimaryFile.tsx @@ -8,7 +8,6 @@ import { Dropzone, DropzoneRef } from "../dropzone/Dropzone"; import { FileRejection } from "react-dropzone"; import { RemoveContainer, StyledRemoveButton } from "./UploadFiles"; import { useAppContext } from "../../contexts/app-context"; -import { useCurrentDataSubmissionId } from "../../hooks/useCurrentDataSubmissionId"; import { useCurrentModuleContext } from "../../contexts/current-module-context"; import { useCurrentOrgUnitContext } from "../../contexts/current-orgUnit-context"; import { EffectFn, useCallbackEffect } from "../../hooks/use-callback-effect"; @@ -22,6 +21,7 @@ interface UploadPrimaryFileProps { removePrimaryFile: EffectFn<[event: React.MouseEvent]>; isLoading: boolean; setIsLoading: React.Dispatch>; + dataSubmissionId: string | undefined; } export const UploadPrimaryFile: React.FC = ({ @@ -32,6 +32,7 @@ export const UploadPrimaryFile: React.FC = ({ removePrimaryFile, isLoading, setIsLoading, + dataSubmissionId, }) => { const { compositionRoot } = useAppContext(); @@ -48,8 +49,6 @@ export const UploadPrimaryFile: React.FC = ({ const primaryFileUploadRef = useRef(null); - const dataSubmissionId = useCurrentDataSubmissionId(moduleId, moduleName, orgUnitId, currentPeriod); - const openFileUploadDialog = useCallback(async () => { primaryFileUploadRef.current?.openDialog(); }, [primaryFileUploadRef]); @@ -76,6 +75,7 @@ export const UploadPrimaryFile: React.FC = ({ if (!dataSubmissionId) { snackbar.error(i18n.t("Data submission id not found. Please try again")); setIsLoading(false); + return; } if (primaryFileData.isValid) { @@ -147,7 +147,7 @@ export const UploadPrimaryFile: React.FC = ({ className="choose-file-button" endIcon={} onClick={openFileUploadDialog} - disabled={primaryFile === null ? false : true} + disabled={!dataSubmissionId || (primaryFile === null ? false : true)} > {i18n.t("Select file")} diff --git a/src/webapp/components/upload/UploadSecondary.tsx b/src/webapp/components/upload/UploadSecondary.tsx index 9f531b11..ce2256bf 100644 --- a/src/webapp/components/upload/UploadSecondary.tsx +++ b/src/webapp/components/upload/UploadSecondary.tsx @@ -9,7 +9,6 @@ import { Dropzone, DropzoneRef } from "../dropzone/Dropzone"; import { useSnackbar } from "@eyeseetea/d2-ui-components"; import { RemoveContainer, StyledRemoveButton } from "./UploadFiles"; import { useAppContext } from "../../contexts/app-context"; -import { useCurrentDataSubmissionId } from "../../hooks/useCurrentDataSubmissionId"; import { useCurrentModuleContext } from "../../contexts/current-module-context"; import { useCurrentOrgUnitContext } from "../../contexts/current-orgUnit-context"; import { EffectFn, useCallbackEffect } from "../../hooks/use-callback-effect"; @@ -25,6 +24,7 @@ interface UploadSecondaryProps { removeSecondaryFile: EffectFn<[event: React.MouseEvent]>; isLoading: boolean; setIsLoading: React.Dispatch>; + dataSubmissionId: string | undefined; } export const UploadSecondary: React.FC = ({ @@ -36,6 +36,7 @@ export const UploadSecondary: React.FC = ({ removeSecondaryFile, isLoading, setIsLoading, + dataSubmissionId, }) => { const { compositionRoot } = useAppContext(); @@ -50,8 +51,6 @@ export const UploadSecondary: React.FC = ({ const snackbar = useSnackbar(); const secondaryFileUploadRef = useRef(null); - const dataSubmissionId = useCurrentDataSubmissionId(moduleId, moduleName, orgUnitId, currentPeriod); - useEffect(() => { if (secondaryFile) { validate(true); @@ -78,6 +77,7 @@ export const UploadSecondary: React.FC = ({ if (!dataSubmissionId) { snackbar.error(i18n.t("Data submission id not found. Please try again")); setIsLoading(false); + return; } if (sampleData.isValid) { @@ -155,7 +155,7 @@ export const UploadSecondary: React.FC = ({ className="choose-file-button" endIcon={} onClick={openFileUploadDialog} - disabled={secondaryFile === null ? false : true} + disabled={!dataSubmissionId || (secondaryFile === null ? false : true)} > {i18n.t("Select file")} diff --git a/src/webapp/components/upload/useUploadContent.ts b/src/webapp/components/upload/useUploadContent.ts index 6ffb2cb0..1a1dc663 100644 --- a/src/webapp/components/upload/useUploadContent.ts +++ b/src/webapp/components/upload/useUploadContent.ts @@ -1,6 +1,10 @@ import { Dispatch, SetStateAction, useCallback, useState } from "react"; import { useAppContext } from "../../contexts/app-context"; import { EffectFn, useCallbackEffect } from "../../hooks/use-callback-effect"; +import { useCurrentModuleContext } from "../../contexts/current-module-context"; +import { useCurrentOrgUnitContext } from "../../contexts/current-orgUnit-context"; +import { useCurrentPeriodContext } from "../../contexts/current-period-context"; +import { useCurrentDataSubmissionId } from "../../hooks/useCurrentDataSubmissionId"; export type UploadContentState = { errorMessage: string; @@ -16,16 +20,30 @@ export type UploadContentState = { setHasSecondaryFile: Dispatch>; removePrimaryFile: EffectFn<[event: React.MouseEvent]>; removeSecondaryFile: EffectFn<[event: React.MouseEvent]>; + dataSubmissionId: string | undefined; + isRunningCalculation: boolean; + setIsRunningCalculation: Dispatch>; }; export function useUploadContent(): UploadContentState { const { compositionRoot } = useAppContext(); + const { + currentModuleAccess: { moduleId, moduleName }, + } = useCurrentModuleContext(); + const { + currentOrgUnitAccess: { orgUnitId }, + } = useCurrentOrgUnitContext(); + + const { currentPeriod } = useCurrentPeriodContext(); + const dataSubmissionId = useCurrentDataSubmissionId(moduleId, moduleName, orgUnitId, currentPeriod); + const [primaryFile, setPrimaryFile] = useState(null); const [secondaryFile, setSecondaryFile] = useState(null); const [hasSecondaryFile, setHasSecondaryFile] = useState(false); const [errorMessage, setErrorMessage] = useState(""); const [isLoadingPrimary, setIsLoadingPrimary] = useState(false); const [isLoadingSecondary, setIsLoadingSecondary] = useState(false); + const [isRunningCalculation, setIsRunningCalculation] = useState(false); const onSetSecondaryFile = useCallback((maybeFile: File | null) => { setSecondaryFile(maybeFile); @@ -101,5 +119,8 @@ export function useUploadContent(): UploadContentState { removeSecondaryFile, hasSecondaryFile, setHasSecondaryFile, + dataSubmissionId, + isRunningCalculation, + setIsRunningCalculation, }; } diff --git a/src/webapp/entities/uploads.ts b/src/webapp/entities/uploads.ts index a18185ce..da883419 100644 --- a/src/webapp/entities/uploads.ts +++ b/src/webapp/entities/uploads.ts @@ -15,10 +15,13 @@ export interface UploadsDataItem { uploadDate: string; dataSubmission: string; module: string; + orgUnit: string; records?: number; // TODO: Delete when no items in DataStore with records (because becomes rows) rows?: number; correspondingRisUploadId: string; eventListFileId?: string; calculatedEventListFileId?: string; importSummary?: ImportSummaryErrors; + eventListDataDeleted?: boolean; + calculatedEventListDataDeleted?: boolean; } diff --git a/src/webapp/hooks/useGlassUploadsAsyncDeletions.ts b/src/webapp/hooks/useGlassUploadsAsyncDeletions.ts new file mode 100644 index 00000000..329ba22b --- /dev/null +++ b/src/webapp/hooks/useGlassUploadsAsyncDeletions.ts @@ -0,0 +1,53 @@ +import React, { useCallback } from "react"; +import { GlassState } from "./State"; +import { useAppContext } from "../contexts/app-context"; +import { Id } from "../../domain/entities/Ref"; +import { useSnackbar } from "@eyeseetea/d2-ui-components"; + +export type GlassUploadsAsyncDeletionsState = GlassState; + +type State = { + asyncDeletions: GlassUploadsAsyncDeletionsState; + setToAsyncDeletions: (uploadIdsToDelete: Id[]) => void; +}; + +export function useGlassUploadsAsyncDeletions(): State { + const { compositionRoot } = useAppContext(); + const snackbar = useSnackbar(); + + const [asyncDeletions, setAsyncDeletions] = React.useState({ + kind: "loading", + }); + + const getAsyncDeletions = useCallback(() => { + compositionRoot.glassUploads.getAsyncDeletions().run( + deletions => setAsyncDeletions({ kind: "loaded", data: deletions }), + error => setAsyncDeletions({ kind: "error", message: error }) + ); + }, [compositionRoot.glassUploads]); + + React.useEffect(() => { + getAsyncDeletions(); + }, [getAsyncDeletions]); + + const setToAsyncDeletions = useCallback( + (uploadIdsToDelete: Id[]) => { + compositionRoot.glassUploads.setToAsyncDeletions(uploadIdsToDelete).run( + () => { + snackbar.info(`File marked to be deleted`); + getAsyncDeletions(); + }, + error => { + snackbar.error(`Error setting file to be deleted, error : ${error} `); + console.error(error); + } + ); + }, + [compositionRoot.glassUploads, getAsyncDeletions, snackbar] + ); + + return { + asyncDeletions, + setToAsyncDeletions, + }; +} diff --git a/src/webapp/utils/getPrimaryAndSecondaryFilesToDelete.ts b/src/webapp/utils/getPrimaryAndSecondaryFilesToDelete.ts new file mode 100644 index 00000000..fd072525 --- /dev/null +++ b/src/webapp/utils/getPrimaryAndSecondaryFilesToDelete.ts @@ -0,0 +1,49 @@ +import { ModuleDetails } from "../../domain/utils/ModuleProperties"; +import { UploadsDataItem } from "../entities/uploads"; + +export type PrimaryAndSecondaryFilesToDelete = { + primaryFileToDelete: UploadsDataItem | undefined; + secondaryFileToDelete: UploadsDataItem | undefined; +}; + +export function getPrimaryAndSecondaryFilesToDelete( + rowToDelete: UploadsDataItem, + moduleProperties: Map, + currentModuleName: string, + allUploads?: UploadsDataItem[] +): PrimaryAndSecondaryFilesToDelete { + let primaryFileToDelete: UploadsDataItem | undefined, secondaryFileToDelete: UploadsDataItem | undefined; + if ( + moduleProperties.get(currentModuleName)?.isSecondaryFileApplicable && + moduleProperties.get(currentModuleName)?.isSecondaryRelated + ) { + //For AMR, Ris file is mandatory, so there will be a ris file with given batch id. + //Sample file is optional and could be absent + if ( + rowToDelete.fileType.toLowerCase() === + moduleProperties.get(currentModuleName)?.primaryFileType.toLowerCase() + ) { + primaryFileToDelete = rowToDelete; + secondaryFileToDelete = allUploads + ?.filter(sample => sample.correspondingRisUploadId === rowToDelete.id) + ?.at(0); + } else { + secondaryFileToDelete = rowToDelete; + primaryFileToDelete = allUploads?.filter(ris => ris.id === rowToDelete.correspondingRisUploadId)?.at(0); + } + } else if (!moduleProperties.get(currentModuleName)?.isSecondaryRelated) { + if (rowToDelete.fileType === moduleProperties.get(currentModuleName)?.primaryFileType) { + primaryFileToDelete = rowToDelete; + } else { + secondaryFileToDelete = rowToDelete; + } + } else { + primaryFileToDelete = rowToDelete; + secondaryFileToDelete = undefined; + } + + return { + primaryFileToDelete: primaryFileToDelete, + secondaryFileToDelete: secondaryFileToDelete, + }; +}