Skip to content

Commit 9c5a5e7

Browse files
committed
[FIX] pivot: running total fallback for missing date buckets
When a measure is displayed as "running total" and the pivot is grouped by a date/datetime granularity, PIVOT.VALUE could return an empty value when the requested bucket is not materialized in the pivot. This happened because the running-total cache only stores values for existing pivot buckets, so missing date keys had no direct lookup. For date/datetime dimensions, when a running-total lookup misses, traverse the row/col tree to find the nearest previous bucket within the same running-total group (respecting dimension order) and return its cumulative value. If the bucket is before the first one, return empty. Task: 5420999
1 parent 7f1e3f8 commit 9c5a5e7

File tree

5 files changed

+270
-2
lines changed

5 files changed

+270
-2
lines changed

packages/o-spreadsheet-engine/src/helpers/pivot/pivot_presentation.ts

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
import { PivotParams, PivotUIConstructor } from "./pivot_registry";
22

33
import { handleError } from "../../functions/create_compute_function";
4-
import { transposeMatrix } from "../../functions/helpers";
4+
import { toNumber, transposeMatrix } from "../../functions/helpers";
55
import { _t } from "../../translation";
66
import { CellValue } from "../../types/cells";
77
import { CellErrorType, NotAvailableError } from "../../types/errors";
88
import { Getters } from "../../types/getters";
9+
import { DEFAULT_LOCALE } from "../../types/locale";
910
import { FunctionResultObject, isMatrix, SortDirection, UID } from "../../types/misc";
1011
import { ModelConfig } from "../../types/model";
1112
import {
1213
DimensionTree,
14+
Granularity,
1315
NEXT_VALUE,
16+
PivotDimension,
1417
PivotDomain,
1518
PivotMeasure,
1619
PivotMeasureDisplay,
@@ -34,6 +37,7 @@ import {
3437
replaceFieldValueInDomain,
3538
} from "./pivot_domain_helpers";
3639
import { AGGREGATORS_FN, isSortedColumnValid, toNormalizedPivotValue } from "./pivot_helpers";
40+
import { pivotTimeAdapter, pivotTimeAdapterRegistry } from "./pivot_time_adapter";
3741
import { SpreadsheetPivotTable } from "./table_spreadsheet_pivot";
3842

3943
const PERCENT_FORMAT = "0.00%";
@@ -420,7 +424,14 @@ export default function (PivotClass: PivotUIConstructor) {
420424
const { rowDomain, colDomain } = domainToColRowDomain(this, domain);
421425
const colDomainKey = domainToString(colDomain);
422426
const rowDomainKey = domainToString(rowDomain);
423-
const runningTotal = runningTotals[colDomainKey]?.[rowDomainKey];
427+
let runningTotal = runningTotals[colDomainKey]?.[rowDomainKey];
428+
if (runningTotal === undefined) {
429+
runningTotal = this.getPreviousRunningTotalValue(
430+
fieldNameWithGranularity,
431+
domain,
432+
runningTotals
433+
);
434+
}
424435

425436
return {
426437
value: runningTotal ?? "",
@@ -679,6 +690,112 @@ export default function (PivotClass: PivotUIConstructor) {
679690
return mainDimension === "row" ? cellsRunningTotals : transpose2dPOJO(cellsRunningTotals);
680691
}
681692

693+
private getPreviousRunningTotalValue(
694+
fieldNameWithGranularity: string,
695+
domain: PivotDomain,
696+
runningTotals: DomainGroups<number | undefined>
697+
): number | undefined {
698+
const dimension = this.definition.getDimension(fieldNameWithGranularity);
699+
if (dimension.type !== "date" && dimension.type !== "datetime") {
700+
return undefined;
701+
}
702+
703+
const mainDimension = getFieldDimensionType(this, fieldNameWithGranularity);
704+
const { rowDomain, colDomain } = domainToColRowDomain(this, domain);
705+
const mainDomain = mainDimension === "row" ? rowDomain : colDomain;
706+
const secondaryDomain = mainDimension === "row" ? colDomain : rowDomain;
707+
const targetValue = mainDomain.find((node) => node.field === fieldNameWithGranularity)?.value;
708+
if (targetValue === undefined) {
709+
return undefined;
710+
}
711+
const secondaryDomainKey = domainToString(secondaryDomain);
712+
const runningTotalKey = getRunningTotalDomainKey(mainDomain, fieldNameWithGranularity);
713+
714+
let previousMainDomainKey: string | undefined;
715+
let previousValue: CellValue | undefined;
716+
const table = this.getCollapsedTableStructure();
717+
const tree = mainDimension === "row" ? table.getRowTree() : table.getColTree();
718+
719+
const visitTree = (nodes: DimensionTree, parentDomain: PivotDomain = []) => {
720+
for (const node of nodes) {
721+
const nodeDomain: PivotDomain = [
722+
...parentDomain,
723+
{ field: node.field, value: node.value, type: node.type },
724+
];
725+
if (node.children.length) {
726+
visitTree(node.children, nodeDomain);
727+
}
728+
const nodeValue = nodeDomain.find(
729+
(domainNode) => domainNode.field === fieldNameWithGranularity
730+
)?.value;
731+
const nodeRunningTotalKey = getRunningTotalDomainKey(
732+
nodeDomain,
733+
fieldNameWithGranularity
734+
);
735+
if (
736+
nodeValue === undefined ||
737+
nodeRunningTotalKey !== runningTotalKey ||
738+
this.compareRunningTotalValues(nodeValue, targetValue, dimension) >= 0
739+
) {
740+
continue;
741+
}
742+
if (
743+
previousValue === undefined ||
744+
this.compareRunningTotalValues(previousValue, nodeValue, dimension) < 0
745+
) {
746+
previousValue = nodeValue;
747+
previousMainDomainKey = domainToString(nodeDomain);
748+
}
749+
}
750+
};
751+
752+
visitTree(tree);
753+
754+
if (!previousMainDomainKey) {
755+
return undefined;
756+
}
757+
758+
return mainDimension === "row"
759+
? runningTotals[secondaryDomainKey]?.[previousMainDomainKey]
760+
: runningTotals[previousMainDomainKey]?.[secondaryDomainKey];
761+
}
762+
763+
private compareRunningTotalValues(
764+
a: CellValue,
765+
b: CellValue,
766+
dimension: PivotDimension
767+
): number {
768+
const order = dimension.order ?? "asc";
769+
const aIsNull = a === null;
770+
const bIsNull = b === null;
771+
if (aIsNull && bIsNull) {
772+
return 0;
773+
}
774+
if (aIsNull) {
775+
return order === "asc" ? 1 : -1;
776+
}
777+
if (bIsNull) {
778+
return order === "asc" ? -1 : 1;
779+
}
780+
781+
const diff =
782+
this.getRunningTotalComparableValue(a, dimension) -
783+
this.getRunningTotalComparableValue(b, dimension);
784+
return order === "asc" ? diff : -diff;
785+
}
786+
787+
private getRunningTotalComparableValue(value: CellValue, dimension: PivotDimension): number {
788+
const granularity = dimension.granularity;
789+
if (granularity && pivotTimeAdapterRegistry.contains(granularity)) {
790+
const adapter = pivotTimeAdapter(granularity as Granularity);
791+
const comparableValue = adapter.toComparableValue?.(value);
792+
if (comparableValue !== undefined) {
793+
return comparableValue;
794+
}
795+
}
796+
return toNumber(value, DEFAULT_LOCALE);
797+
}
798+
682799
private getGrandTotal(measureId: string): number {
683800
const grandTotal = this._getPivotCellValueAndFormat(measureId, []);
684801
return this.measureValueToNumber(grandTotal);

packages/o-spreadsheet-engine/src/helpers/pivot/pivot_time_adapter.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ import { DEFAULT_LOCALE } from "../../types/locale";
77
import { Granularity, PivotTimeAdapter, PivotTimeAdapterNotNull } from "../../types/pivot";
88
import { DAYS, formatValue, MONTHS } from "../format/format";
99

10+
/**
11+
* Converts "period/year" (e.g. "48/2026", "03/2025", "1/2020")
12+
* into a numeric value that preserves chronological ordering.
13+
*/
14+
export function periodYearToComparable(value: string): number {
15+
const [periodString, yearString] = value.split("/");
16+
return Number(yearString) * 100 + Number(periodString);
17+
}
18+
1019
export const pivotTimeAdapterRegistry = new Registry<PivotTimeAdapter<CellValue>>();
1120

1221
export function pivotTimeAdapter(granularity: Granularity): PivotTimeAdapter<CellValue> {
@@ -186,6 +195,9 @@ const monthAdapter: PivotTimeAdapterNotNull<string> = {
186195
const jsDate = toJsDate(normalizedValue, DEFAULT_LOCALE);
187196
return `DATE(${jsDate.getFullYear()},${jsDate.getMonth() + 1},1)`;
188197
},
198+
toComparableValue(normalizedValue) {
199+
return periodYearToComparable(normalizedValue);
200+
},
189201
};
190202

191203
/**
@@ -328,6 +340,12 @@ function nullHandlerDecorator<T>(adapter: PivotTimeAdapterNotNull<T>): PivotTime
328340
}
329341
return adapter.toFunctionValue(normalizedValue);
330342
},
343+
toComparableValue(normalizedValue) {
344+
if (normalizedValue === null) {
345+
return undefined;
346+
}
347+
return adapter.toComparableValue?.(normalizedValue);
348+
},
331349
};
332350
}
333351

packages/o-spreadsheet-engine/src/types/pivot.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,12 +180,14 @@ export interface PivotTimeAdapterNotNull<T> {
180180
normalizeFunctionValue: (value: Exclude<CellValue, null>) => T;
181181
toValueAndFormat: (normalizedValue: T, locale?: Locale) => FunctionResultObject;
182182
toFunctionValue: (normalizedValue: T) => string;
183+
toComparableValue?: (normalizedValue: T) => number;
183184
}
184185

185186
export interface PivotTimeAdapter<T> {
186187
normalizeFunctionValue: (value: CellValue) => T | null;
187188
toValueAndFormat: (normalizedValue: T, locale?: Locale) => FunctionResultObject;
188189
toFunctionValue: (normalizedValue: T) => string;
190+
toComparableValue?: (normalizedValue: T | null) => number | undefined;
189191
}
190192

191193
export interface PivotNode {

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ import {
138138
} from "@odoo/o-spreadsheet-engine/helpers/pivot/pivot_helpers";
139139
import { pivotRegistry } from "@odoo/o-spreadsheet-engine/helpers/pivot/pivot_registry";
140140
import {
141+
periodYearToComparable,
141142
pivotTimeAdapter,
142143
pivotTimeAdapterRegistry,
143144
} from "@odoo/o-spreadsheet-engine/helpers/pivot/pivot_time_adapter";
@@ -398,6 +399,7 @@ export const helpers = {
398399
parseDimension,
399400
isDateOrDatetimeField,
400401
makeFieldProposal,
402+
periodYearToComparable,
401403
insertTokenAfterArgSeparator,
402404
insertTokenAfterLeftParenthesis,
403405
mergeContiguousZones,

tests/pivots/pivot_measure/pivot_measure_display_model.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1393,6 +1393,135 @@ describe("Measure display", () => {
13931393
A25: "Total", B25: "230200", C25: "230200", D25: "90000", E25: "320200", F25: "320200", G25: "",
13941394
});
13951395
});
1396+
1397+
test("PIVOT.VALUE running total falls back to the previous value for a missing date bucket", () => {
1398+
const model = createModelWithTestPivotDataset();
1399+
setCellContent(model, "A40", `=PIVOT.VALUE(1, "${measureId}", "Created on:month_number", 5)`);
1400+
expect(getEvaluatedCell(model, "A40").value).toBe("");
1401+
updatePivotMeasureDisplay(model, pivotId, measureId, {
1402+
type: "running_total",
1403+
fieldNameWithGranularity: "Created on:month_number",
1404+
});
1405+
expect(getEvaluatedCell(model, "A40").value).toBe(320200);
1406+
});
1407+
1408+
test("PIVOT.VALUE running total supports child row and column domains", () => {
1409+
const model = createModelWithTestPivotDataset({
1410+
rows: [
1411+
{ fieldName: "Created on", granularity: "month_number", order: "asc" },
1412+
{ fieldName: "Stage", order: "asc" },
1413+
],
1414+
columns: [{ fieldName: "Salesperson", order: "asc" }],
1415+
});
1416+
setCellContent(
1417+
model,
1418+
"A40",
1419+
`=PIVOT.VALUE(1, "${measureId}", "Created on:month_number", 4, "Stage", "New", "Salesperson", "Alice")`
1420+
);
1421+
setCellContent(
1422+
model,
1423+
"A41",
1424+
`=PIVOT.VALUE(1, "${measureId}", "Created on:month_number", 5, "Stage", "New", "Salesperson", "Alice")`
1425+
);
1426+
1427+
expect(getEvaluatedCell(model, "A40").value).toBe(49000);
1428+
expect(getEvaluatedCell(model, "A41").value).toBe("");
1429+
1430+
updatePivotMeasureDisplay(model, pivotId, measureId, {
1431+
type: "running_total",
1432+
fieldNameWithGranularity: "Created on:month_number",
1433+
});
1434+
1435+
expect(getEvaluatedCell(model, "A40").value).toBe(154600);
1436+
expect(getEvaluatedCell(model, "A41").value).toBe(154600);
1437+
});
1438+
1439+
test("PIVOT.VALUE running total fallback works with multi-level col domain", () => {
1440+
const model = createModelWithTestPivotDataset({
1441+
rows: [{ fieldName: "Created on", granularity: "month_number", order: "asc" }],
1442+
columns: [
1443+
{ fieldName: "Salesperson", order: "asc" },
1444+
{ fieldName: "Active", order: "asc" },
1445+
],
1446+
});
1447+
1448+
setCellContent(
1449+
model,
1450+
"A40",
1451+
`=PIVOT.VALUE(1, "${measureId}", "Created on:month_number", 4, "Salesperson", "Alice", "Active", false)`
1452+
);
1453+
setCellContent(
1454+
model,
1455+
"A41",
1456+
`=PIVOT.VALUE(1, "${measureId}", "Created on:month_number", 5, "Salesperson", "Alice", "Active", false)`
1457+
);
1458+
1459+
expect(getEvaluatedCell(model, "A40").value).toBe(65000);
1460+
expect(getEvaluatedCell(model, "A41").value).toBe("");
1461+
1462+
updatePivotMeasureDisplay(model, pivotId, measureId, {
1463+
type: "running_total",
1464+
fieldNameWithGranularity: "Created on:month_number",
1465+
});
1466+
1467+
expect(getEvaluatedCell(model, "A40").value).toBe(193100);
1468+
expect(getEvaluatedCell(model, "A41").value).toBe(193100);
1469+
});
1470+
1471+
test("PIVOT.VALUE running total handles Month & Year granularity with column domain", () => {
1472+
const model = createModelWithTestPivotDataset({
1473+
rows: [{ fieldName: "Created on", granularity: "month", order: "asc" }],
1474+
columns: [{ fieldName: "Stage", order: "asc" }],
1475+
});
1476+
setCellContent(
1477+
model,
1478+
"A40",
1479+
`=PIVOT.VALUE(1, "${measureId}", "Created on:month", DATE(2024,4,1), "Stage", "New")`
1480+
);
1481+
setCellContent(
1482+
model,
1483+
"A41",
1484+
`=PIVOT.VALUE(1, "${measureId}", "Created on:month", DATE(2024,5,1), "Stage", "New")`
1485+
);
1486+
1487+
expect(getEvaluatedCell(model, "A40").value).toBe(73000);
1488+
expect(getEvaluatedCell(model, "A41").value).toBe("");
1489+
1490+
updatePivotMeasureDisplay(model, pivotId, measureId, {
1491+
type: "running_total",
1492+
fieldNameWithGranularity: "Created on:month",
1493+
});
1494+
1495+
expect(getEvaluatedCell(model, "A40").value).toBe(204600);
1496+
expect(getEvaluatedCell(model, "A41").value).toBe(204600);
1497+
});
1498+
1499+
test("PIVOT.VALUE running total returns empty before the first date bucket", () => {
1500+
const model = createModelWithTestPivotDataset();
1501+
setCellContent(model, "A40", `=PIVOT.VALUE(1, "${measureId}", "Created on:month_number", 1)`);
1502+
expect(getEvaluatedCell(model, "A40").value).toBe("");
1503+
updatePivotMeasureDisplay(model, pivotId, measureId, {
1504+
type: "running_total",
1505+
fieldNameWithGranularity: "Created on:month_number",
1506+
});
1507+
expect(getEvaluatedCell(model, "A40").value).toBe("");
1508+
});
1509+
1510+
test("PIVOT.VALUE running total picks the previous bucket in descending date order", () => {
1511+
const model = createModelWithTestPivotDataset({
1512+
rows: [{ fieldName: "Created on", granularity: "month_number", order: "desc" }],
1513+
});
1514+
setCellContent(model, "A40", `=PIVOT.VALUE(1, "${measureId}", "Created on:month_number", 1)`);
1515+
setCellContent(model, "A41", `=PIVOT.VALUE(1, "${measureId}", "Created on:month_number", 5)`);
1516+
expect(getEvaluatedCell(model, "A40").value).toBe("");
1517+
expect(getEvaluatedCell(model, "A41").value).toBe("");
1518+
updatePivotMeasureDisplay(model, pivotId, measureId, {
1519+
type: "running_total",
1520+
fieldNameWithGranularity: "Created on:month_number",
1521+
});
1522+
expect(getEvaluatedCell(model, "A40").value).toBe(320200);
1523+
expect(getEvaluatedCell(model, "A41").value).toBe("");
1524+
});
13961525
});
13971526

13981527
describe("%_running_total", () => {

0 commit comments

Comments
 (0)