From 8ba2e8ff659cdfde88c5541dc411cf640ee75a39 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Date: Wed, 27 May 2026 12:58:06 -0500 Subject: [PATCH 1/4] add logic to get matched indicators from dataset attribute --- src/data/templates/nrc/NRCModule1.01.ts | 53 +++++++++++++++++++ .../nrc/NRCModuleMetadataD2Repository.ts | 29 +++++++++- .../entities/templates/NRCModuleMetadata.ts | 6 +++ 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/data/templates/nrc/NRCModule1.01.ts b/src/data/templates/nrc/NRCModule1.01.ts index 35aae9cb..b91c9a23 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,59 @@ class DownloadCustomization { this.getCategoryOptionComboCells(metadata), this.getDataSetCells(metadata), this.getClearedOutCellsByCategories(metadata), + this.getMatchingIndicatorsCells(metadata), + this.getMatchingIndicatorsFormulaCells(metadata), ]; return { cells: _.flatten(cellGroups) }; } + private getMatchingIndicatorsCells(metadata: NRCModuleMetadata): Cell[] { + 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, idx) => { + const row = idx + 1; + return [ + cell({ + sheet: this.sheets.matchingIndicators, + column: "A", + row: row, + value: referenceToId(matching.source), + }), + cell({ + sheet: this.sheets.matchingIndicators, + column: "B", + row: row, + value: referenceToId(matching.target), + }), + ]; + }); + } + + private getMatchingIndicatorsFormulaCells(metadata: NRCModuleMetadata): Cell[] { + const dataElementIds = new Set(metadata.dataElements.map(de => de.id)); + const validMatchingsCount = metadata.indicatorMatchings.filter( + m => dataElementIds.has(m.source) && dataElementIds.has(m.target) + ).length; + + if (validMatchingsCount === 0) return []; + + const lookupRange = `${this.sheets.matchingIndicators}!$A$1:$B$${validMatchingsCount}`; + const firstFormulaRow = this.initialDataEntryRow + 1; + + return _.range(firstFormulaRow, this.dataEntryLastRow + 1).map(row => + cell({ + sheet: this.sheets.dataEntry, + column: "C", + row: row, + value: `=IFERROR(VLOOKUP(C${row - 1}, ${lookupRange}, 2, FALSE), "")`, + }) + ); + } + private getDataSetCells(metadata: NRCModuleMetadata) { return [ cell({ @@ -341,6 +393,7 @@ class DownloadCustomization { 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); } private hideIdCells(excelRepository: ExcelRepository) { diff --git a/src/data/templates/nrc/NRCModuleMetadataD2Repository.ts b/src/data/templates/nrc/NRCModuleMetadataD2Repository.ts index aac1afea..c10ec823 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, @@ -265,6 +271,27 @@ export class NRCModuleMetadataD2Repository implements NRCModuleMetadataRepositor } } + 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 { From 081fceb0522599de703e1f39ca963a4ffa350a7d Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Wed, 27 May 2026 16:20:31 -0500 Subject: [PATCH 2/4] replicate value for matched indicators --- src/data/templates/nrc/NRCModule1.01.ts | 27 +++++++++++++++++++ .../nrc/NRCModuleMetadataD2Repository.ts | 12 ++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/data/templates/nrc/NRCModule1.01.ts b/src/data/templates/nrc/NRCModule1.01.ts index b91c9a23..3026f467 100644 --- a/src/data/templates/nrc/NRCModule1.01.ts +++ b/src/data/templates/nrc/NRCModule1.01.ts @@ -137,6 +137,7 @@ class DownloadCustomization { this.getClearedOutCellsByCategories(metadata), this.getMatchingIndicatorsCells(metadata), this.getMatchingIndicatorsFormulaCells(metadata), + this.getMatchingIndicatorsValueFormulaCells(metadata), ]; return { cells: _.flatten(cellGroups) }; @@ -188,6 +189,32 @@ class DownloadCustomization { ); } + private getMatchingIndicatorsValueFormulaCells(metadata: NRCModuleMetadata): Cell[] { + const dataElementIds = new Set(metadata.dataElements.map(de => de.id)); + const validMatchingsCount = metadata.indicatorMatchings.filter( + m => dataElementIds.has(m.source) && dataElementIds.has(m.target) + ).length; + + if (validMatchingsCount === 0) return []; + + const sourcesRange = `${this.sheets.matchingIndicators}!$A$1:$A$${validMatchingsCount}`; + const firstFormulaRow = this.initialDataEntryRow + 1; + + return _.range(firstFormulaRow, this.dataEntryLastRow + 1).map(row => { + const prevSourceMatch = `MATCH(C${row - 1}, ${sourcesRange}, 0)`; + const grandparentSourceMatch = `MATCH(C${row - 2}, ${sourcesRange}, 0)`; + const formula = `=IF(AND(C${row}<>"", ISNUMBER(${prevSourceMatch}), ISNA(${grandparentSourceMatch}), F${ + row - 1 + }<>""), F${row - 1}, "")`; + return cell({ + sheet: this.sheets.dataEntry, + column: "F", + row: row, + value: formula, + }); + }); + } + private getDataSetCells(metadata: NRCModuleMetadata) { return [ cell({ diff --git a/src/data/templates/nrc/NRCModuleMetadataD2Repository.ts b/src/data/templates/nrc/NRCModuleMetadataD2Repository.ts index c10ec823..b4d5b172 100644 --- a/src/data/templates/nrc/NRCModuleMetadataD2Repository.ts +++ b/src/data/templates/nrc/NRCModuleMetadataD2Repository.ts @@ -237,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({ @@ -271,6 +271,16 @@ 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; From 28feee22bba466d690a7187c5d4ab67bd34f4f3f Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Sun, 7 Jun 2026 14:14:26 -0500 Subject: [PATCH 3/4] match indicators only if all the value in the other columns are present. Update cells if one of the values in the columns change --- src/data/templates/nrc/NRCModule1.01.ts | 93 +++++++++++++++---------- 1 file changed, 58 insertions(+), 35 deletions(-) diff --git a/src/data/templates/nrc/NRCModule1.01.ts b/src/data/templates/nrc/NRCModule1.01.ts index 3026f467..460c98c6 100644 --- a/src/data/templates/nrc/NRCModule1.01.ts +++ b/src/data/templates/nrc/NRCModule1.01.ts @@ -136,8 +136,7 @@ class DownloadCustomization { this.getDataSetCells(metadata), this.getClearedOutCellsByCategories(metadata), this.getMatchingIndicatorsCells(metadata), - this.getMatchingIndicatorsFormulaCells(metadata), - this.getMatchingIndicatorsValueFormulaCells(metadata), + this.getMatchedRowsCells(metadata), ]; return { cells: _.flatten(cellGroups) }; @@ -168,7 +167,7 @@ class DownloadCustomization { }); } - private getMatchingIndicatorsFormulaCells(metadata: NRCModuleMetadata): Cell[] { + private getMatchedRowsCells(metadata: NRCModuleMetadata): Cell[] { const dataElementIds = new Set(metadata.dataElements.map(de => de.id)); const validMatchingsCount = metadata.indicatorMatchings.filter( m => dataElementIds.has(m.source) && dataElementIds.has(m.target) @@ -176,45 +175,40 @@ class DownloadCustomization { if (validMatchingsCount === 0) return []; + const sourcesRange = `${this.sheets.matchingIndicators}!$A$1:$A$${validMatchingsCount}`; const lookupRange = `${this.sheets.matchingIndicators}!$A$1:$B$${validMatchingsCount}`; - const firstFormulaRow = this.initialDataEntryRow + 1; - return _.range(firstFormulaRow, this.dataEntryLastRow + 1).map(row => - cell({ - sheet: this.sheets.dataEntry, - column: "C", - row: row, - value: `=IFERROR(VLOOKUP(C${row - 1}, ${lookupRange}, 2, FALSE), "")`, - }) - ); - } - - private getMatchingIndicatorsValueFormulaCells(metadata: NRCModuleMetadata): Cell[] { - const dataElementIds = new Set(metadata.dataElements.map(de => de.id)); - const validMatchingsCount = metadata.indicatorMatchings.filter( - m => dataElementIds.has(m.source) && dataElementIds.has(m.target) - ).length; + 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})`; - if (validMatchingsCount === 0) return []; + const mirror = (column: string, expression: string) => + cell({ + sheet: this.sheets.dataEntry, + column: column, + row: targetRow, + value: `=IF(${gate}, ${expression}, "")`, + }); - const sourcesRange = `${this.sheets.matchingIndicators}!$A$1:$A$${validMatchingsCount}`; - const firstFormulaRow = this.initialDataEntryRow + 1; - - return _.range(firstFormulaRow, this.dataEntryLastRow + 1).map(row => { - const prevSourceMatch = `MATCH(C${row - 1}, ${sourcesRange}, 0)`; - const grandparentSourceMatch = `MATCH(C${row - 2}, ${sourcesRange}, 0)`; - const formula = `=IF(AND(C${row}<>"", ISNUMBER(${prevSourceMatch}), ISNA(${grandparentSourceMatch}), F${ - row - 1 - }<>""), F${row - 1}, "")`; - return cell({ - sheet: this.sheets.dataEntry, - column: "F", - row: row, - value: formula, - }); + 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({ @@ -418,9 +412,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) { From 2c3f6297418224b4d74c91c3d3eadf8c77a9922e Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Sun, 14 Jun 2026 17:10:18 -0500 Subject: [PATCH 4/4] add both source-target and target-source indicators in the matching --- src/data/templates/nrc/NRCModule1.01.ts | 27 +++++++++++++++---------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/data/templates/nrc/NRCModule1.01.ts b/src/data/templates/nrc/NRCModule1.01.ts index 460c98c6..5202b0ca 100644 --- a/src/data/templates/nrc/NRCModule1.01.ts +++ b/src/data/templates/nrc/NRCModule1.01.ts @@ -142,41 +142,46 @@ class DownloadCustomization { return { cells: _.flatten(cellGroups) }; } - private getMatchingIndicatorsCells(metadata: NRCModuleMetadata): Cell[] { + // 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, idx) => { + 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(matching.source), + value: referenceToId(pair.from), }), cell({ sheet: this.sheets.matchingIndicators, column: "B", row: row, - value: referenceToId(matching.target), + value: referenceToId(pair.to), }), ]; }); } private getMatchedRowsCells(metadata: NRCModuleMetadata): Cell[] { - const dataElementIds = new Set(metadata.dataElements.map(de => de.id)); - const validMatchingsCount = metadata.indicatorMatchings.filter( - m => dataElementIds.has(m.source) && dataElementIds.has(m.target) - ).length; + const matchingRowsCount = this.getMatchingPairs(metadata).length; - if (validMatchingsCount === 0) return []; + if (matchingRowsCount === 0) return []; - const sourcesRange = `${this.sheets.matchingIndicators}!$A$1:$A$${validMatchingsCount}`; - const lookupRange = `${this.sheets.matchingIndicators}!$A$1:$B$${validMatchingsCount}`; + 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;