diff --git a/src/data/templates/nrc/NRCModule1.01.ts b/src/data/templates/nrc/NRCModule1.01.ts index 35aae9cb..5202b0ca 100644 --- a/src/data/templates/nrc/NRCModule1.01.ts +++ b/src/data/templates/nrc/NRCModule1.01.ts @@ -60,8 +60,11 @@ class DownloadCustomization { dataEntry: "Data Entry", validation: "Validation", metadata: "Metadata", + matchingIndicators: "MatchingIndicators", }; + dataEntryLastRow = 1000; + constructor( private id: Id, private excelRepository: ExcelRepository, @@ -72,6 +75,7 @@ class DownloadCustomization { async execute() { await this.createSheet(this.sheets.validation); await this.createSheet(this.sheets.metadata); + await this.createSheet(this.sheets.matchingIndicators); const metadata = await this.moduleRepository.get({ currentUser: this.options.currentUser, @@ -131,11 +135,85 @@ class DownloadCustomization { this.getCategoryOptionComboCells(metadata), this.getDataSetCells(metadata), this.getClearedOutCellsByCategories(metadata), + this.getMatchingIndicatorsCells(metadata), + this.getMatchedRowsCells(metadata), ]; return { cells: _.flatten(cellGroups) }; } + // Returns the matching pairs in BOTH directions (source→target and target→source). + private getMatchingPairs(metadata: NRCModuleMetadata): Array<{ from: Id; to: Id }> { + const dataElementIds = new Set(metadata.dataElements.map(de => de.id)); + const validMatchings = metadata.indicatorMatchings.filter( + m => dataElementIds.has(m.source) && dataElementIds.has(m.target) + ); + + return validMatchings.flatMap(matching => [ + { from: matching.source, to: matching.target }, + { from: matching.target, to: matching.source }, + ]); + } + + private getMatchingIndicatorsCells(metadata: NRCModuleMetadata): Cell[] { + return this.getMatchingPairs(metadata).flatMap((pair, idx) => { + const row = idx + 1; + return [ + cell({ + sheet: this.sheets.matchingIndicators, + column: "A", + row: row, + value: referenceToId(pair.from), + }), + cell({ + sheet: this.sheets.matchingIndicators, + column: "B", + row: row, + value: referenceToId(pair.to), + }), + ]; + }); + } + + private getMatchedRowsCells(metadata: NRCModuleMetadata): Cell[] { + const matchingRowsCount = this.getMatchingPairs(metadata).length; + + if (matchingRowsCount === 0) return []; + + const sourcesRange = `${this.sheets.matchingIndicators}!$A$1:$A$${matchingRowsCount}`; + const lookupRange = `${this.sheets.matchingIndicators}!$A$1:$B$${matchingRowsCount}`; + + return this.getSourceRows().flatMap(sourceRow => { + const targetRow = sourceRow + 1; + const matched = `ISNUMBER(MATCH(C${sourceRow}, ${sourcesRange}, 0))`; + const complete = ["A", "B", "C", "D", "E", "F"].map(column => `${column}${sourceRow}<>""`).join(", "); + const gate = `AND(${matched}, ${complete})`; + + const mirror = (column: string, expression: string) => + cell({ + sheet: this.sheets.dataEntry, + column: column, + row: targetRow, + value: `=IF(${gate}, ${expression}, "")`, + }); + + return [ + mirror("A", `A${sourceRow}`), // orgUnit + mirror("B", `B${sourceRow}`), // phase of emergency + mirror("C", `VLOOKUP(C${sourceRow}, ${lookupRange}, 2, FALSE)`), // matched (target) indicator + mirror("D", `D${sourceRow}`), // disaggregation + mirror("E", `E${sourceRow}`), // target/actual + mirror("F", `F${sourceRow}`), // value + mirror("G", `G${sourceRow}`), // attribute id (hidden) + mirror("H", `H${sourceRow}`), // categoryOption id (hidden) + ]; + }); + } + + private getSourceRows(): number[] { + return _.range(this.initialDataEntryRow, this.dataEntryLastRow, 2); + } + private getDataSetCells(metadata: NRCModuleMetadata) { return [ cell({ @@ -339,8 +417,38 @@ class DownloadCustomization { this.hideIdCells(excelRepository); + await this.applyDataEntryLocking(); + excelRepository.protectSheet(this.id, this.sheets.validation, this.password); excelRepository.protectSheet(this.id, this.sheets.metadata, this.password); + excelRepository.protectSheet(this.id, this.sheets.matchingIndicators, this.password); + excelRepository.protectSheet(this.id, this.sheets.dataEntry, this.password); + } + + private async applyDataEntryLocking() { + const { excelRepository, id } = this; + const sheet = this.sheets.dataEntry; + + const editableConfigCells = ["B1", "B3", "E3", "D1"]; + for (const ref of editableConfigCells) { + await excelRepository.styleCell(id, { type: "cell", sheet, ref }, { locked: false }); + } + + for (const sourceRow of this.getSourceRows()) { + const targetRow = sourceRow + 1; + + await excelRepository.styleCell( + id, + { type: "range", sheet, ref: `A${sourceRow}:F${sourceRow}` }, + { locked: false } + ); + + await excelRepository.styleCell( + id, + { type: "range", sheet, ref: `A${targetRow}:H${targetRow}` }, + { locked: true, fillColor: "#F2F2F2" } + ); + } } private hideIdCells(excelRepository: ExcelRepository) { diff --git a/src/data/templates/nrc/NRCModuleMetadataD2Repository.ts b/src/data/templates/nrc/NRCModuleMetadataD2Repository.ts index aac1afea..b4d5b172 100644 --- a/src/data/templates/nrc/NRCModuleMetadataD2Repository.ts +++ b/src/data/templates/nrc/NRCModuleMetadataD2Repository.ts @@ -1,7 +1,11 @@ import _ from "lodash"; import { D2Api, MetadataPick } from "../../../types/d2-api"; import { Id, NamedRef, Ref } from "../../../domain/entities/ReferenceObject"; -import { DataElement, NRCModuleMetadata } from "../../../domain/entities/templates/NRCModuleMetadata"; +import { + DataElement, + IndicatorMatching, + NRCModuleMetadata, +} from "../../../domain/entities/templates/NRCModuleMetadata"; import { NRCModuleMetadataRepository } from "../../../domain/repositories/templates/NRCModuleMetadataRepository"; import { User } from "../../../domain/entities/User"; import { Maybe } from "../../../types/utils"; @@ -15,6 +19,7 @@ type DataSetCategories = { export class NRCModuleMetadataD2Repository implements NRCModuleMetadataRepository { attributeCodes = { createdByApp: "GL_CREATED_BY_DATASET_CONFIGURATION", + indicatorMatching: "GL_INDICATOR_MATCHING", }; categoryComboCodes = { @@ -41,6 +46,7 @@ export class NRCModuleMetadataD2Repository implements NRCModuleMetadataRepositor dataElements: await this.getDataElementsWithDisaggregation(dataSet), organisationUnits: this.getOrganisationUnits(options.currentUser, projectCategoryOptions, dataSet), periods: this.getPeriods(dataSet), + indicatorMatchings: this.getIndicatorMatchings(dataSet), categoryCombo: { categories: { projects: projectCategoryOptions ? { categoryOptions: projectCategoryOptions } : undefined, @@ -231,7 +237,7 @@ export class NRCModuleMetadataD2Repository implements NRCModuleMetadataRepositor if (!dataSetCategories.projects) { return undefined; } else if (this.isCreatedByDataSetConfigurationApp(dataSet)) { - const categoryOptionCode = dataSet.code ? dataSet.code.replace(/Data Set$/, "").trim() : undefined; + const categoryOptionCode = this.getProjectFromDataSet(dataSet); const res = await this.api.metadata .get({ @@ -265,6 +271,37 @@ export class NRCModuleMetadataD2Repository implements NRCModuleMetadataRepositor } } + private getProjectFromDataSet(dataSet: D2DataSet): Maybe { + if (dataSet.code) { + return dataSet.code.replace(/Data Set$/, "").trim(); + } else if (dataSet.name) { + return dataSet.name.replace(/DataSet$/, "").trim(); + } else { + return undefined; + } + } + + private getIndicatorMatchings(dataSet: D2DataSet): IndicatorMatching[] { + const attributeValue = dataSet.attributeValues.find(av => { + return av.attribute.code === this.attributeCodes.indicatorMatching; + }); + if (!attributeValue?.value) return []; + + return _(attributeValue.value.split(";")) + .map(pair => pair.trim()) + .reject(pair => pair.length === 0) + .map((pair): IndicatorMatching | undefined => { + const [target, source] = pair.split("=").map(s => s.trim()); + if (!target || !source) { + console.error(`Invalid indicator matching format: "${pair}"`); + return undefined; + } + return { target, source }; + }) + .compact() + .value(); + } + private isCreatedByDataSetConfigurationApp(dataSet: D2DataSet): boolean { const attributeValue = dataSet.attributeValues.find(attributeValue => { return attributeValue.attribute.code === this.attributeCodes.createdByApp; diff --git a/src/domain/entities/templates/NRCModuleMetadata.ts b/src/domain/entities/templates/NRCModuleMetadata.ts index a52b23f1..d943fe48 100644 --- a/src/domain/entities/templates/NRCModuleMetadata.ts +++ b/src/domain/entities/templates/NRCModuleMetadata.ts @@ -5,6 +5,7 @@ export interface NRCModuleMetadata { dataElements: DataElement[]; organisationUnits: OrganisationUnits[]; periods: Period[]; + indicatorMatchings: IndicatorMatching[]; categoryCombo: { categories: { projects?: { categoryOptions: CategoryOption[] }; @@ -16,6 +17,11 @@ export interface NRCModuleMetadata { }; } +export interface IndicatorMatching { + source: Id; + target: Id; +} + export type CategoryOption = NamedRef; export interface DataElement extends NamedRef {