diff --git a/src/modules/pivotTable/PivotTableEngine.js b/src/modules/pivotTable/PivotTableEngine.js index d2125c7e3..515555c7c 100644 --- a/src/modules/pivotTable/PivotTableEngine.js +++ b/src/modules/pivotTable/PivotTableEngine.js @@ -254,6 +254,25 @@ const lookup = ( return { column, row } } +const isFiniteNumber = (value) => + typeof value === 'number' && Number.isFinite(value) + +const parseDataFields = (dataRow, dataHeaders) => { + const values = dataFields.reduce((out, field) => { + out[field] = parseValue(dataRow[dataHeaders[field]]) + return out + }, {}) + + // Some values (e.g. reporting rates) come with a factor but without + // multiplier and divisor, use the factor so AVERAGE totals can be computed + if (!isFiniteNumber(values.multiplier) && isFiniteNumber(values.factor)) { + values.multiplier = values.factor + values.divisor = 1 + } + + return values +} + const applyTotalAggregationType = ( { totalAggregationType, @@ -268,11 +287,13 @@ const applyTotalAggregationType = ( switch (overrideTotalAggregationType || totalAggregationType) { case AGGREGATE_TYPE_NA: return VALUE_NA - case AGGREGATE_TYPE_AVERAGE: - return ( + case AGGREGATE_TYPE_AVERAGE: { + const average = ((numerator || value) * multiplier) / (denominator * divisor || 1) - ) + + return Number.isFinite(average) ? average : VALUE_NA + } case AGGREGATE_TYPE_SUM: default: return value @@ -782,6 +803,10 @@ export class PivotTableEngine { addCellValueToTotals(pos, dataRow) { const totals = this.getDependantTotalCells(pos) const dxDimension = this.getRawCellDxDimension(pos) + const fieldValues = parseDataFields( + dataRow, + this.dimensionLookup.dataHeaders + ) Object.values(totals).forEach((totalItem) => { if (!totalItem) { @@ -832,8 +857,7 @@ export class PivotTableEngine { // (see DHIS2-9155) if (isNumericValueType(totalCell.valueType)) { dataFields.forEach((field) => { - const headerIndex = this.dimensionLookup.dataHeaders[field] - const value = parseValue(dataRow[headerIndex]) + const value = fieldValues[field] totalCell[field] = addToTotalIfNumber( value, totalCell[field] @@ -852,8 +876,7 @@ export class PivotTableEngine { } const percentageTotal = this.percentageTotals[pos.row] dataFields.forEach((field) => { - const headerIndex = this.dimensionLookup.dataHeaders[field] - const value = parseValue(dataRow[headerIndex]) + const value = fieldValues[field] percentageTotal[field] = addToTotalIfNumber( value, percentageTotal[field] @@ -870,8 +893,7 @@ export class PivotTableEngine { const percentageTotal = this.percentageTotals[totals.columnSubtotal.row] dataFields.forEach((field) => { - const headerIndex = this.dimensionLookup.dataHeaders[field] - const value = parseValue(dataRow[headerIndex]) + const value = fieldValues[field] percentageTotal[field] = addToTotalIfNumber( value, percentageTotal[field] @@ -889,8 +911,7 @@ export class PivotTableEngine { const percentageTotal = this.percentageTotals[totals.columnTotal.row] dataFields.forEach((field) => { - const headerIndex = this.dimensionLookup.dataHeaders[field] - const value = parseValue(dataRow[headerIndex]) + const value = fieldValues[field] percentageTotal[field] = addToTotalIfNumber( value, percentageTotal[field] @@ -908,8 +929,7 @@ export class PivotTableEngine { } const percentageTotal = this.percentageTotals[pos.column] dataFields.forEach((field) => { - const headerIndex = this.dimensionLookup.dataHeaders[field] - const value = parseValue(dataRow[headerIndex]) + const value = fieldValues[field] percentageTotal[field] = addToTotalIfNumber( value, percentageTotal[field] @@ -926,8 +946,7 @@ export class PivotTableEngine { const percentageTotal = this.percentageTotals[totals.rowSubtotal.column] dataFields.forEach((field) => { - const headerIndex = this.dimensionLookup.dataHeaders[field] - const value = parseValue(dataRow[headerIndex]) + const value = fieldValues[field] percentageTotal[field] = addToTotalIfNumber( value, percentageTotal[field] @@ -945,8 +964,7 @@ export class PivotTableEngine { const percentageTotal = this.percentageTotals[totals.rowTotal.column] dataFields.forEach((field) => { - const headerIndex = this.dimensionLookup.dataHeaders[field] - const value = parseValue(dataRow[headerIndex]) + const value = fieldValues[field] percentageTotal[field] = addToTotalIfNumber( value, percentageTotal[field] diff --git a/src/modules/pivotTable/__tests__/pivotTableEngineAverageTotals.spec.js b/src/modules/pivotTable/__tests__/pivotTableEngineAverageTotals.spec.js new file mode 100644 index 000000000..13e7a246b --- /dev/null +++ b/src/modules/pivotTable/__tests__/pivotTableEngineAverageTotals.spec.js @@ -0,0 +1,103 @@ +import { PivotTableEngine } from '../PivotTableEngine.js' + +/* The engine measures every cell against a canvas 2d context, which jsdom + * does not implement. */ +jest.mock('../measureText.js', () => ({ + measureTextWithWrapping: () => ({ width: 100, height: 20 }), +})) + +const RATE = 'BfMAe6Itzgt.REPORTING_RATE' +const BO = 'O6uvpzGd5pu' +const BOMBALI = 'fdc6uOvgoji' + +const headers = [ + { name: 'dx', meta: true }, + { name: 'ou', meta: true }, + { name: 'value', meta: false }, + { name: 'numerator', meta: false }, + { name: 'denominator', meta: false }, + { name: 'factor', meta: false }, + { name: 'multiplier', meta: false }, + { name: 'divisor', meta: false }, +] + +const buildData = (rows) => ({ + headers, + metaData: { + items: { + dx: { name: 'Data', dimensionType: 'DATA_X' }, + ou: { name: 'Organisation unit' }, + [RATE]: { + name: 'Child Health - Reporting rate', + dimensionItemType: 'REPORTING_RATE', + valueType: 'NUMBER', + totalAggregationType: 'AVERAGE', + }, + [BO]: { uid: BO, name: 'Bo' }, + [BOMBALI]: { uid: BOMBALI, name: 'Bombali' }, + }, + dimensions: { dx: [RATE], ou: [BO, BOMBALI] }, + }, + rows, + height: rows.length, + width: headers.length, +}) + +const visualization = { + columns: [{ dimension: 'dx' }], + rows: [{ dimension: 'ou' }], + filters: [], + colTotals: true, +} + +const getColumnTotal = (engine) => + engine.get({ row: engine.height - 1, column: 0 }) + +describe('PivotTableEngine AVERAGE totals', () => { + it('falls back to factor when multiplier and divisor are missing', () => { + const engine = new PivotTableEngine({ + visualization, + data: buildData([ + [RATE, BO, '76.98', '97.0', '126.0', '100', '', ''], + [RATE, BOMBALI, '94.79', '91.0', '96.0', '100', '', ''], + ]), + }) + + // (97 + 91) * 100 / (126 + 96) + expect(getColumnTotal(engine).rawValue).toBeCloseTo(84.68, 2) + }) + + it('uses multiplier and divisor when present', () => { + const engine = new PivotTableEngine({ + visualization, + data: buildData([ + [RATE, BO, '76.98', '97.0', '126.0', '401.1', '36500', '91'], + [ + RATE, + BOMBALI, + '94.79', + '91.0', + '96.0', + '401.1', + '36500', + '91', + ], + ]), + }) + + // (97 + 91) * 73000 / ((126 + 96) * 182) + expect(getColumnTotal(engine).rawValue).toBeCloseTo(339.67, 2) + }) + + it('shows N/A instead of NaN when the total cannot be computed', () => { + const engine = new PivotTableEngine({ + visualization, + data: buildData([ + [RATE, BO, '76.98', '97.0', '126.0', '', '', ''], + [RATE, BOMBALI, '94.79', '91.0', '96.0', '', '', ''], + ]), + }) + + expect(getColumnTotal(engine).renderedValue).toBe('N/A') + }) +})