diff --git a/i18n/en.pot b/i18n/en.pot index 3792954c..3869e8db 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: 2026-03-16T13:43:15.193Z\n" -"PO-Revision-Date: 2026-03-16T13:43:15.193Z\n" +"POT-Creation-Date: 2026-05-06T07:59:02.822Z\n" +"PO-Revision-Date: 2026-05-06T07:59:02.822Z\n" msgid "Validating Project" msgstr "" @@ -871,6 +871,18 @@ msgstr "" msgid "Projects with global validations reported ({{projectsCount}}): {{-projects}}" msgstr "" +msgid "" +"Paired indicator missing values: benefit indicator {{-benefitName}} has " +"values but its paired people indicator {{-peopleName}} is empty. Both must " +"be filled before leaving the page." +msgstr "" + +msgid "" +"Paired indicator missing values: people indicator {{-peopleName}} has " +"values but its paired benefit indicator {{-benefitName}} is empty. Both " +"must be filled before leaving the page." +msgstr "" + msgid "" "Returning value ({{returningFormula}}) is greater than the sum of New " "values for past periods: {{pastFormula}}" diff --git a/src/models/Validator.ts b/src/models/Validator.ts index da2755cb..2e9a1d19 100644 --- a/src/models/Validator.ts +++ b/src/models/Validator.ts @@ -7,6 +7,7 @@ import Project, { DataSetType } from "./Project"; import { DataValue, ValidationItem, ValidationResult } from "./validators/validator-common"; import { GlobalValidator } from "./validators/GlobalValidator"; import { BenefitValidator } from "./validators/BenefitValidator"; +import { PairedValidator } from "./validators/PairedValidator"; import { Config } from "./Config"; interface Validators { @@ -14,6 +15,7 @@ interface Validators { recurring: RecurringValidator; global: GlobalValidator; benefit: BenefitValidator; + paired: PairedValidator; } export class Validator { @@ -31,6 +33,7 @@ export class Validator { recurring: await RecurringValidator.build(api, project, dataSetType, period), global: await GlobalValidator.build(api, project, dataSetType, period), benefit: await BenefitValidator.build(config), + paired: await PairedValidator.build(api, config, project, dataSetType, period), }; return new Validator(period, validators); } @@ -51,12 +54,16 @@ export class Validator { const newValidators = { ...this.validators, global: this.validators.global.onSave(dataValue), + paired: this.validators.paired.onSave(dataValue), }; return new Validator(this.period, newValidators); } async validate(): Promise { - const items: ValidationItem[] = await this.validators.global.validate(); + const items: ValidationItem[] = _.concat( + await this.validators.global.validate(), + this.validators.paired.validate() + ); return this.getValidationResult(items); } diff --git a/src/models/validators/PairedValidator.ts b/src/models/validators/PairedValidator.ts new file mode 100644 index 00000000..abfbc2f3 --- /dev/null +++ b/src/models/validators/PairedValidator.ts @@ -0,0 +1,184 @@ +import _ from "lodash"; + +import { D2Api, DataValueSetsGetRequest } from "../../types/d2-api"; +import i18n from "../../locales"; +import { Config } from "../Config"; +import { DataElementBase, PeopleOrBenefit } from "../dataElementsSet"; +import { DataSet, DataSetType, ProjectBasic } from "../Project"; +import { getDataValuesFromD2, getDataValueId, Id } from "./GlobalValidator"; +import { DataValue, ValidationItem } from "./validator-common"; + +/* + Validate that paired people/benefit indicators are filled together. + + A benefit data element may declare a `pairedDataElement` attribute pointing to + one or more people data elements (by code). On page exit (Actual or Target + screens), if one side of a pair has any value entered while the paired side is + fully empty, the user must complete the pair before navigating away. +*/ + +type IndexedDataValues = Record; + +interface IndicatorRef { + id: Id; + name: string; + peopleOrBenefit: PeopleOrBenefit; +} + +interface PairedIndicators { + benefit: IndicatorRef; + people: IndicatorRef; +} + +interface Data { + pairs: PairedIndicators[]; + dataValues: IndexedDataValues; + period: string; + orgUnitId: Id; + attributeOptionComboId: Id; +} + +export class PairedValidator { + constructor(private data: Data) {} + + static async build( + api: D2Api, + config: Config, + project: ProjectBasic, + dataSetType: DataSetType, + period: string + ): Promise { + if (!project.orgUnit || !project.dataSets) + throw new Error("Cannot build PairedValidator: missing data"); + + const categoryOption = config.categoryOptions[dataSetType]; + const aocId = categoryOption.categoryOptionCombos.map(coc => coc.id)[0]; + const orgUnitId = project.orgUnit.id; + const dataSet = project.dataSets[dataSetType]; + + const getSetOptions: DataValueSetsGetRequest = { + orgUnit: [orgUnitId], + dataSet: [dataSet.id], + period: [period], + attributeOptionCombo: [aocId], + }; + + const res = await api.dataValues.getSet(getSetOptions).getData(); + const dataValues = getDataValuesFromD2(res.dataValues); + const indexedDataValues = indexDataValues(dataValues); + const pairs = PairedValidator.getPairs(config, dataSet); + + return new PairedValidator({ + pairs, + dataValues: indexedDataValues, + period, + orgUnitId, + attributeOptionComboId: aocId, + }); + } + + static getPairs(config: Config, dataSet: DataSet): PairedIndicators[] { + const dataElementsById = _.keyBy(config.dataElements, de => de.id); + const projectDataElementIds = new Set( + dataSet.dataSetElements.map(dse => dse.dataElement.id) + ); + + const seen = new Set(); + const pairs: PairedIndicators[] = []; + + for (const id of projectDataElementIds) { + const de = dataElementsById[id]; + if (!de) continue; + + for (const pairedRef of de.pairedDataElements) { + if (!projectDataElementIds.has(pairedRef.id)) continue; + const pairedDe = dataElementsById[pairedRef.id]; + if (!pairedDe) continue; + + const pair = toPair(de, pairedDe); + if (!pair) continue; + + const key = [pair.benefit.id, pair.people.id].join(":"); + if (seen.has(key)) continue; + seen.add(key); + pairs.push(pair); + } + } + + return pairs; + } + + onSave(dataValue: DataValue): PairedValidator { + const updated = { ...this.data.dataValues, [getDataValueId(dataValue)]: dataValue }; + return new PairedValidator({ ...this.data, dataValues: updated }); + } + + validate(): ValidationItem[] { + const valuesByDeId = _(this.data.dataValues) + .values() + .groupBy(dv => dv.dataElementId) + .value(); + + const hasAnyValue = (deId: Id): boolean => + (valuesByDeId[deId] || []).some(dv => Boolean(dv.value && dv.value.trim())); + + return _(this.data.pairs) + .flatMap((pair): ValidationItem[] => { + const benefitFilled = hasAnyValue(pair.benefit.id); + const peopleFilled = hasAnyValue(pair.people.id); + + if (benefitFilled && !peopleFilled) { + return [ + { + level: "error", + message: i18n.t( + "Paired indicator missing values: benefit indicator {{-benefitName}} has values but its paired people indicator {{-peopleName}} is empty. Both must be filled before leaving the page.", + { + benefitName: pair.benefit.name, + peopleName: pair.people.name, + nsSeparator: false, + } + ), + }, + ]; + } else if (peopleFilled && !benefitFilled) { + return [ + { + level: "error", + message: i18n.t( + "Paired indicator missing values: people indicator {{-peopleName}} has values but its paired benefit indicator {{-benefitName}} is empty. Both must be filled before leaving the page.", + { + benefitName: pair.benefit.name, + peopleName: pair.people.name, + nsSeparator: false, + } + ), + }, + ]; + } else { + return []; + } + }) + .value(); + } +} + +function toPair(a: DataElementBase, b: DataElementBase): PairedIndicators | null { + const aRef: IndicatorRef = { id: a.id, name: a.name, peopleOrBenefit: a.peopleOrBenefit }; + const bRef: IndicatorRef = { id: b.id, name: b.name, peopleOrBenefit: b.peopleOrBenefit }; + + if (a.peopleOrBenefit === "benefit" && b.peopleOrBenefit === "people") { + return { benefit: aRef, people: bRef }; + } else if (a.peopleOrBenefit === "people" && b.peopleOrBenefit === "benefit") { + return { benefit: bRef, people: aRef }; + } else { + return null; + } +} + +function indexDataValues(dataValues: DataValue[]): IndexedDataValues { + return _(dataValues) + .map(dv => [getDataValueId(dv), dv] as [string, DataValue]) + .fromPairs() + .value(); +}